takt: implement-task-base-branch (#455)

This commit is contained in:
nrs 2026-03-03 19:37:07 +09:00 committed by GitHub
parent ed16c05160
commit 290d085f5e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1328 additions and 719 deletions

View File

@ -40,6 +40,11 @@ vi.mock('../features/tasks/execute/selectAndExecute.js', () => ({
vi.mock('../infra/task/index.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
summarizeTaskName: vi.fn().mockResolvedValue('test-task'),
getCurrentBranch: vi.fn().mockReturnValue('main'),
}));
vi.mock('../infra/task/clone-base-branch.js', () => ({
branchExists: vi.fn(),
}));
vi.mock('../infra/git/index.js', () => ({
@ -76,6 +81,8 @@ import { promptInput, confirm } from '../shared/prompt/index.js';
import { error, info } from '../shared/ui/index.js';
import { determinePiece } from '../features/tasks/execute/selectAndExecute.js';
import { addTask } from '../features/tasks/index.js';
import { getCurrentBranch } from '../infra/task/index.js';
import { branchExists } from '../infra/task/clone-base-branch.js';
import type { PrReviewData } from '../infra/git/index.js';
const mockInteractiveMode = vi.mocked(interactiveMode);
@ -84,6 +91,8 @@ const mockConfirm = vi.mocked(confirm);
const mockInfo = vi.mocked(info);
const mockError = vi.mocked(error);
const mockDeterminePiece = vi.mocked(determinePiece);
const mockGetCurrentBranch = vi.mocked(getCurrentBranch);
const mockBranchExists = vi.mocked(branchExists);
let testDir: string;
@ -96,7 +105,7 @@ function addTaskWithPrOption(cwd: string, task: string, prNumber: number): Promi
return addTask(cwd, task, { prNumber });
}
function createMockPrReview(overrides: Partial<PrReviewData> = {}): PrReviewData {
function createMockPrReview(overrides: Partial<PrReviewData & { baseRefName?: string }> = {}): PrReviewData {
return {
number: 456,
title: 'Fix auth bug',
@ -107,7 +116,7 @@ function createMockPrReview(overrides: Partial<PrReviewData> = {}): PrReviewData
reviews: [{ author: 'reviewer', body: 'Fix null check' }],
files: ['src/auth.ts'],
...overrides,
};
} as PrReviewData;
}
beforeEach(() => {
@ -115,6 +124,8 @@ beforeEach(() => {
testDir = fs.mkdtempSync(path.join(tmpdir(), 'takt-test-'));
mockDeterminePiece.mockResolvedValue('default');
mockConfirm.mockResolvedValue(false);
mockGetCurrentBranch.mockReturnValue('main');
mockBranchExists.mockReturnValue(true);
mockCheckCliStatus.mockReturnValue({ available: true });
});
@ -169,6 +180,61 @@ describe('addTask', () => {
expect(task.auto_pr).toBe(true);
});
it('should set base_branch when current branch is not main/master and user confirms', async () => {
mockGetCurrentBranch.mockReturnValue('feat/awesome');
mockConfirm.mockResolvedValueOnce(true);
mockPromptInput.mockResolvedValueOnce('').mockResolvedValueOnce('');
mockConfirm.mockResolvedValueOnce(false);
await addTask(testDir, 'Task content');
const task = loadTasks(testDir).tasks[0]!;
expect(task.base_branch).toBe('feat/awesome');
});
it('should not set base_branch when current branch prompt is declined', async () => {
mockGetCurrentBranch.mockReturnValue('feat/awesome');
mockConfirm.mockResolvedValueOnce(false);
mockPromptInput.mockResolvedValueOnce('').mockResolvedValueOnce('');
await addTask(testDir, 'Task content');
const task = loadTasks(testDir).tasks[0]!;
expect(task.base_branch).toBeUndefined();
expect(mockBranchExists).not.toHaveBeenCalled();
});
it('should skip base branch prompt when current branch detection fails', async () => {
mockGetCurrentBranch.mockImplementationOnce(() => {
throw new Error('not a git repository');
});
await addTask(testDir, 'Task content');
expect(mockConfirm).toHaveBeenCalledTimes(1);
expect(mockConfirm).toHaveBeenCalledWith('Auto-create PR?', true);
expect(mockConfirm).not.toHaveBeenCalledWith(expect.stringContaining('現在のブランチ'));
const task = loadTasks(testDir).tasks[0]!;
expect(task.base_branch).toBeUndefined();
});
it('should reprompt when base branch does not exist', async () => {
mockGetCurrentBranch.mockReturnValue('feat/missing');
mockConfirm.mockResolvedValueOnce(true);
mockBranchExists.mockReturnValueOnce(false).mockReturnValueOnce(true);
mockPromptInput
.mockResolvedValueOnce('develop')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('');
mockConfirm.mockResolvedValueOnce(false);
await addTask(testDir, 'Task content');
const task = loadTasks(testDir).tasks[0]!;
expect(task.base_branch).toBe('develop');
expect(mockError).toHaveBeenCalledWith('Base branch does not exist: feat/missing');
});
it('should create task from issue reference without interactive mode', async () => {
mockResolveIssueTask.mockReturnValue('Issue #99: Fix login timeout');
@ -214,6 +280,18 @@ describe('addTask', () => {
expect(readOrderContent(testDir, task.task_dir)).toContain(formattedTask);
});
it('should store PR base_ref as base_branch when adding with --pr', async () => {
const prReview = createMockPrReview({ baseRefName: 'release/main' });
const formattedTask = '## PR #456 Review Comments: Fix auth bug';
mockFetchPrReviewComments.mockReturnValue(prReview);
mockFormatPrReviewAsTask.mockReturnValue(formattedTask);
await addTaskWithPrOption(testDir, 'placeholder', 456);
const task = loadTasks(testDir).tasks[0]!;
expect(task.base_branch).toBe('release/main');
});
it('should not create a PR task when PR has no review comments', async () => {
const prReview = createMockPrReview({ comments: [], reviews: [] });
mockFetchPrReviewComments.mockReturnValue(prReview);

View File

@ -1,10 +1,3 @@
/**
* Tests for PR resolution in routing module.
*
* Verifies that --pr option fetches review comments
* and passes formatted task to interactive mode.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../shared/ui/index.js', () => ({
@ -117,11 +110,12 @@ vi.mock('../app/cli/helpers.js', () => ({
isDirectTask: vi.fn(() => false),
}));
import { selectAndExecuteTask, determinePiece } from '../features/tasks/index.js';
import { selectAndExecuteTask, determinePiece, saveTaskFromInteractive } from '../features/tasks/index.js';
import { interactiveMode } from '../features/interactive/index.js';
import { executePipeline } from '../features/pipeline/index.js';
import { executeDefaultAction } from '../app/cli/routing.js';
import { error as logError } from '../shared/ui/index.js';
import type { InteractiveModeResult } from '../features/interactive/index.js';
import type { PrReviewData } from '../infra/git/index.js';
const mockSelectAndExecuteTask = vi.mocked(selectAndExecuteTask);
@ -129,8 +123,9 @@ const mockDeterminePiece = vi.mocked(determinePiece);
const mockInteractiveMode = vi.mocked(interactiveMode);
const mockExecutePipeline = vi.mocked(executePipeline);
const mockLogError = vi.mocked(logError);
const mockSaveTaskFromInteractive = vi.mocked(saveTaskFromInteractive);
function createMockPrReview(overrides: Partial<PrReviewData> = {}): PrReviewData {
function createMockPrReview(overrides: Partial<PrReviewData & { baseRefName?: string }> = {}): PrReviewData {
return {
number: 456,
title: 'Fix auth bug',
@ -179,6 +174,37 @@ describe('PR resolution in routing', () => {
);
});
it('should pass PR base branch as baseBranch when interactive save_task is selected', async () => {
// Given
mockOpts.pr = 456;
const actionResult: InteractiveModeResult = {
action: 'save_task',
task: 'Saved PR task',
};
mockInteractiveMode.mockResolvedValue(actionResult);
const prReview = createMockPrReview({ baseRefName: 'release/main', headRefName: 'feat/my-pr-branch' });
mockCheckCliStatus.mockReturnValue({ available: true });
mockFetchPrReviewComments.mockReturnValue(prReview);
// When
await executeDefaultAction();
// Then
expect(mockSaveTaskFromInteractive).toHaveBeenCalledWith(
'/test/cwd',
'Saved PR task',
'default',
expect.objectContaining({
presetSettings: expect.objectContaining({
worktree: true,
branch: 'feat/my-pr-branch',
autoPr: false,
baseBranch: 'release/main',
}),
}),
);
});
it('should execute task after resolving PR review comments', async () => {
// Given
mockOpts.pr = 456;

View File

@ -1,7 +1,3 @@
/**
* Tests for clone module (cloneAndIsolate git config propagation)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockLogInfo, mockLogDebug, mockLogError } = vi.hoisted(() => ({
@ -425,6 +421,98 @@ describe('resolveBaseBranch', () => {
expect(cloneCalls[0]).toContain('develop');
});
it('should use explicit baseBranch from options when provided', () => {
const cloneCalls: string[][] = [];
mockExecFileSync.mockImplementation((_cmd, args) => {
const argsArr = args as string[];
if (argsArr[0] === 'rev-parse' && argsArr[1] === '--abbrev-ref') {
return 'main\n';
}
if (argsArr[0] === 'symbolic-ref' && argsArr[1] === 'refs/remotes/origin/HEAD') {
return 'refs/remotes/origin/develop\n';
}
if (argsArr[0] === 'clone') {
cloneCalls.push(argsArr);
return Buffer.from('');
}
if (argsArr[0] === 'remote') {
return Buffer.from('');
}
if (argsArr[0] === 'config') {
if (argsArr[1] === '--local') {
throw new Error('not set');
}
return Buffer.from('');
}
if (argsArr[0] === 'rev-parse' && argsArr[1] === '--verify') {
const ref = argsArr[2] === '--' ? argsArr[3] : argsArr[2];
if (ref === 'release/main' || ref === 'origin/release/main') {
return Buffer.from('');
}
throw new Error('branch not found');
}
if (argsArr[0] === 'checkout') {
return Buffer.from('');
}
return Buffer.from('');
});
createSharedClone('/project', ({
worktree: true,
taskSlug: 'explicit-base-branch',
baseBranch: 'release/main',
} as unknown) as { worktree: true; taskSlug: string; baseBranch: string });
expect(cloneCalls).toHaveLength(1);
expect(cloneCalls[0]).toContain('--branch');
expect(cloneCalls[0]).toContain('release/main');
});
it('should throw when explicit baseBranch is whitespace', () => {
expect(() => createSharedClone('/project', {
worktree: true,
taskSlug: 'whitespace-base-branch',
baseBranch: ' ',
})).toThrow('Base branch override must not be empty.');
});
it('should throw when explicit baseBranch is invalid ref', () => {
mockExecFileSync.mockImplementation((_cmd, args) => {
const argsArr = args as string[];
if (argsArr[0] === 'check-ref-format') {
throw new Error('invalid ref');
}
return Buffer.from('');
});
expect(() => createSharedClone('/project', {
worktree: true,
taskSlug: 'invalid-base-branch',
baseBranch: 'invalid..name',
})).toThrow('Invalid base branch: invalid..name');
});
it('should throw when explicit baseBranch does not exist locally or on origin', () => {
mockExecFileSync.mockImplementation((_cmd, args) => {
const argsArr = args as string[];
if (argsArr[0] === 'rev-parse' && argsArr[1] === '--verify') {
throw new Error('branch not found');
}
return Buffer.from('');
});
expect(() => createSharedClone('/project', {
worktree: true,
taskSlug: 'missing-base-branch',
baseBranch: 'missing/branch',
})).toThrow('Base branch does not exist: missing/branch');
});
it('should continue clone creation when fetch fails (network error)', () => {
// Given: fetch throws (no network)
mockExecFileSync.mockImplementation((_cmd, args) => {
@ -593,7 +681,7 @@ describe('branchExists remote tracking branch fallback', () => {
// branchExists: git rev-parse --verify <branch>
if (argsArr[0] === 'rev-parse' && argsArr[1] === '--verify') {
const ref = argsArr[2];
const ref = argsArr[2] === '--' ? argsArr[3] : argsArr[2];
if (typeof ref === 'string' && ref.startsWith('origin/')) {
// Remote tracking branch exists
return Buffer.from('abc123');
@ -966,6 +1054,7 @@ describe('cleanupOrphanedClone path traversal protection', () => {
vi.mocked(fs.readFileSync).mockReturnValueOnce(
JSON.stringify({ clonePath: '/etc/malicious' })
);
vi.mocked(fs.existsSync).mockReturnValueOnce(true);
cleanupOrphanedClone(PROJECT_DIR, BRANCH);
@ -982,7 +1071,7 @@ describe('cleanupOrphanedClone path traversal protection', () => {
vi.mocked(fs.readFileSync).mockReturnValueOnce(
JSON.stringify({ clonePath: validClonePath })
);
vi.mocked(fs.existsSync).mockReturnValueOnce(true);
vi.mocked(fs.existsSync).mockReturnValueOnce(true).mockReturnValueOnce(true);
cleanupOrphanedClone(PROJECT_DIR, BRANCH);

View File

@ -1,10 +1,3 @@
/**
* Tests for github/pr module
*
* Tests buildPrBody formatting and findExistingPr logic.
* createPullRequest/commentOnPr call `gh` CLI, not unit-tested here.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockExecFileSync = vi.fn();
@ -183,13 +176,14 @@ describe('fetchPrReviewComments', () => {
vi.clearAllMocks();
});
it('should parse gh pr view JSON and return PrReviewData', () => {
it('should return PrReviewData when gh pr view JSON is valid', () => {
// Given
const ghResponse = {
number: 456,
title: 'Fix auth bug',
body: 'PR description',
url: 'https://github.com/org/repo/pull/456',
baseRefName: 'release/main',
headRefName: 'fix/auth-bug',
comments: [
{ author: { login: 'commenter1' }, body: 'Please update tests' },
@ -221,11 +215,12 @@ describe('fetchPrReviewComments', () => {
// Then
expect(mockExecFileSync).toHaveBeenCalledWith(
'gh',
['pr', 'view', '456', '--json', 'number,title,body,url,headRefName,comments,reviews,files'],
['pr', 'view', '456', '--json', 'number,title,body,url,headRefName,baseRefName,comments,reviews,files'],
expect.objectContaining({ encoding: 'utf-8' }),
);
expect(result.number).toBe(456);
expect(result.title).toBe('Fix auth bug');
expect((result as { baseRefName?: string }).baseRefName).toBe('release/main');
expect(result.headRefName).toBe('fix/auth-bug');
expect(result.comments).toEqual([{ author: 'commenter1', body: 'Please update tests' }]);
expect(result.reviews).toEqual([

View File

@ -1,12 +1,5 @@
/**
* Tests for pipeline execution
*
* Tests the orchestration logic with mocked dependencies.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock all external dependencies
const mockFetchIssue = vi.fn();
const mockCheckGhCli = vi.fn().mockReturnValue({ available: true });
vi.mock('../infra/github/issue.js', () => ({
@ -49,13 +42,11 @@ vi.mock('../infra/config/index.js', () => ({
resolveConfigValue: vi.fn(() => undefined),
}));
// Mock execFileSync for git operations
const mockExecFileSync = vi.fn();
vi.mock('node:child_process', () => ({
execFileSync: mockExecFileSync,
}));
// Mock UI
vi.mock('../shared/ui/index.js', () => ({
info: vi.fn(),
error: vi.fn(),
@ -67,7 +58,6 @@ vi.mock('../shared/ui/index.js', () => ({
warn: vi.fn(),
debug: vi.fn(),
}));
// Mock Slack + utils
const mockGetSlackWebhookUrl = vi.fn<() => string | undefined>(() => undefined);
const mockSendSlackNotification = vi.fn<(url: string, message: string) => Promise<void>>();
const mockBuildSlackRunSummary = vi.fn<(params: unknown) => string>(() => 'TAKT Run Summary');
@ -241,7 +231,7 @@ describe('executePipeline', () => {
);
});
it('draftPr: true の場合、createPullRequest に draft: true が渡される', async () => {
it('should pass draft: true to createPullRequest when draftPr is true', async () => {
mockExecuteTask.mockResolvedValueOnce(true);
mockCreatePullRequest.mockReturnValueOnce({ success: true, url: 'https://github.com/test/pr/1' });
@ -261,7 +251,7 @@ describe('executePipeline', () => {
);
});
it('draftPr: false の場合、createPullRequest に draft: false が渡される', async () => {
it('should pass draft: false to createPullRequest when draftPr is false', async () => {
mockExecuteTask.mockResolvedValueOnce(true);
mockCreatePullRequest.mockReturnValueOnce({ success: true, url: 'https://github.com/test/pr/1' });
@ -281,7 +271,7 @@ describe('executePipeline', () => {
);
});
it('should pass baseBranch as base to createPullRequest', async () => {
it('should pass baseBranch as base to createPullRequest when autoPr is true', async () => {
// Given: detectDefaultBranch returns 'develop' (via symbolic-ref)
mockExecFileSync.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === 'symbolic-ref' && args[1] === 'refs/remotes/origin/HEAD') {
@ -303,13 +293,11 @@ describe('executePipeline', () => {
// Then
expect(exitCode).toBe(0);
expect(mockCreatePullRequest).toHaveBeenCalledWith(
'/tmp/test',
expect.objectContaining({
branch: 'fix/my-branch',
base: 'develop',
}),
);
expect(mockCreatePullRequest).toHaveBeenCalledTimes(1);
const prOptions = mockCreatePullRequest.mock.calls[0]?.[1] as { branch?: string; base?: string };
expect(prOptions.branch).toBe('fix/my-branch');
expect(prOptions.base).toBe('develop');
expect(prOptions.base).not.toBeUndefined();
});
it('should use --task when both --task and positional task are provided', async () => {
@ -534,7 +522,13 @@ describe('executePipeline', () => {
});
expect(exitCode).toBe(0);
expect(mockConfirmAndCreateWorktree).toHaveBeenCalledWith('/tmp/test', 'Fix the bug', true, undefined);
expect(mockConfirmAndCreateWorktree).toHaveBeenCalledWith(
'/tmp/test',
'Fix the bug',
true,
undefined,
undefined,
);
expect(mockExecuteTask).toHaveBeenCalledWith({
task: 'Fix the bug',
cwd: '/tmp/test-worktree',
@ -631,6 +625,28 @@ describe('executePipeline', () => {
expect(exitCode).toBe(4);
});
it('should return exit code 4 when worktree baseBranch is unresolved', async () => {
mockConfirmAndCreateWorktree.mockResolvedValueOnce({
execCwd: '/tmp/test-worktree',
isWorktree: true,
branch: 'fix/the-bug',
baseBranch: undefined,
taskSlug: 'fix-the-bug',
});
const exitCode = await executePipeline({
task: 'Fix the bug',
piece: 'default',
autoPr: false,
cwd: '/tmp/test',
createWorktree: true,
});
expect(exitCode).toBe(4);
expect(mockExecuteTask).not.toHaveBeenCalled();
expect(mockCreatePullRequest).not.toHaveBeenCalled();
});
it('should commit in worktree and push via clone→project→origin', async () => {
mockConfirmAndCreateWorktree.mockResolvedValueOnce({
execCwd: '/tmp/test-worktree',
@ -700,6 +716,74 @@ describe('executePipeline', () => {
}),
);
});
it('should use worktree base branch for PR creation', async () => {
mockConfirmAndCreateWorktree.mockResolvedValueOnce({
execCwd: '/tmp/test-worktree',
isWorktree: true,
branch: 'fix/the-bug',
baseBranch: 'release/main',
taskSlug: 'fix-the-bug',
});
mockExecuteTask.mockResolvedValueOnce(true);
mockCreatePullRequest.mockReturnValueOnce({ success: true, url: 'https://github.com/test/pr/1' });
const exitCode = await executePipeline({
task: 'Fix the bug',
piece: 'default',
autoPr: true,
cwd: '/tmp/test',
createWorktree: true,
});
expect(exitCode).toBe(0);
expect(mockCreatePullRequest).toHaveBeenCalledWith(
'/tmp/test',
expect.objectContaining({
branch: 'fix/the-bug',
base: 'release/main',
}),
);
});
it('should pass PR base branch to worktree creation when createWorktree is true', async () => {
mockFetchPrReviewComments.mockReturnValueOnce({
number: 456,
title: 'Fix auth bug',
body: 'PR description',
url: 'https://github.com/org/repo/pull/456',
baseRefName: 'release/main',
headRefName: 'fix/auth-bug',
comments: [{ author: 'reviewer1', body: 'Fix null check' }],
reviews: [],
files: ['src/auth.ts'],
});
mockConfirmAndCreateWorktree.mockResolvedValueOnce({
execCwd: '/tmp/test-worktree',
isWorktree: true,
branch: 'fix/auth-bug',
baseBranch: 'release/main',
taskSlug: 'fix-auth-bug',
});
mockExecuteTask.mockResolvedValueOnce(true);
const exitCode = await executePipeline({
prNumber: 456,
piece: 'default',
autoPr: false,
cwd: '/tmp/test',
createWorktree: true,
});
expect(exitCode).toBe(0);
expect(mockConfirmAndCreateWorktree).toHaveBeenCalledWith(
'/tmp/test',
expect.any(String),
true,
'fix/auth-bug',
'release/main',
);
});
});
it('should return exit code 4 when git commit/push fails', async () => {
@ -934,5 +1018,79 @@ describe('executePipeline', () => {
);
expect(checkoutPrBranch).toBeDefined();
});
it('should pass PR base branch to PR creation when baseRefName is provided', async () => {
// Given: remote default branch is develop, PR base is release/main
mockExecFileSync.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === 'symbolic-ref' && args[1] === 'refs/remotes/origin/HEAD') {
return 'refs/remotes/origin/develop\n';
}
return 'abc1234\n';
});
mockFetchPrReviewComments.mockReturnValueOnce({
number: 456,
title: 'Fix auth bug',
body: 'PR description',
url: 'https://github.com/org/repo/pull/456',
baseRefName: 'release/main',
headRefName: 'fix/auth-bug',
comments: [{ author: 'commenter1', body: 'Update tests' }],
reviews: [{ author: 'reviewer1', body: 'Fix null check' }],
files: ['src/auth.ts'],
});
mockExecuteTask.mockResolvedValueOnce(true);
mockCreatePullRequest.mockReturnValueOnce({ success: true, url: 'https://github.com/org/repo/pull/1' });
// When
const exitCode = await executePipeline({
prNumber: 456,
piece: 'default',
autoPr: true,
cwd: '/tmp/test',
});
// Then
expect(exitCode).toBe(0);
expect(mockCreatePullRequest).toHaveBeenCalledTimes(1);
const prOptions = mockCreatePullRequest.mock.calls[0]?.[1] as { base?: string };
expect(prOptions.base).toBe('release/main');
expect(prOptions.base).not.toBeUndefined();
expect(prOptions.base).not.toBe('develop');
});
it('should resolve default base branch for PR creation when baseRefName is undefined', async () => {
mockExecFileSync.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === 'symbolic-ref' && args[1] === 'refs/remotes/origin/HEAD') {
return 'refs/remotes/origin/develop\n';
}
return 'abc1234\n';
});
mockFetchPrReviewComments.mockReturnValueOnce({
number: 456,
title: 'Fix auth bug',
body: 'PR description',
url: 'https://github.com/org/repo/pull/456',
baseRefName: undefined,
headRefName: 'fix/auth-bug',
comments: [{ author: 'commenter1', body: 'Update tests' }],
reviews: [{ author: 'reviewer1', body: 'Fix null check' }],
files: ['src/auth.ts'],
});
mockExecuteTask.mockResolvedValueOnce(true);
mockCreatePullRequest.mockReturnValueOnce({ success: true, url: 'https://github.com/org/repo/pull/1' });
const exitCode = await executePipeline({
prNumber: 456,
piece: 'default',
autoPr: true,
cwd: '/tmp/test',
});
expect(exitCode).toBe(0);
expect(mockCreatePullRequest).toHaveBeenCalledTimes(1);
const prOptions = mockCreatePullRequest.mock.calls[0]?.[1] as { base?: string };
expect(prOptions.base).toBe('develop');
expect(prOptions.base).not.toBeUndefined();
});
});
});

View File

@ -1,12 +1,9 @@
/**
* Tests for task execution resolution.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { describe, it, expect, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import type { TaskInfo } from '../infra/task/index.js';
import * as infraTask from '../infra/task/index.js';
import { resolveTaskExecution } from '../features/tasks/execute/resolveTask.js';
const tempRoots = new Set<string>();
@ -86,6 +83,235 @@ describe('resolveTaskExecution', () => {
expect(fs.readFileSync(expectedReportOrderPath, 'utf-8')).toBe('# task instruction');
});
it('should pass base_branch to shared clone options when worktree task has base_branch', async () => {
const root = createTempProjectDir();
const taskData = {
task: 'Run task with base branch',
worktree: true,
branch: 'feature/base-branch',
base_branch: 'release/main',
};
const task = createTask({
data: ({
...taskData,
} as unknown) as NonNullable<TaskInfo['data']>,
worktreePath: undefined,
status: 'pending',
});
const mockResolveBaseBranch = vi.spyOn(infraTask, 'resolveBaseBranch').mockReturnValue({
branch: 'release/main',
});
const mockCreateSharedClone = vi.spyOn(infraTask, 'createSharedClone').mockReturnValue({
path: '/tmp/shared-clone',
branch: 'feature/base-branch',
});
const result = await resolveTaskExecution(task, root, 'default');
expect(mockResolveBaseBranch).toHaveBeenCalledWith(root, 'release/main');
expect(mockCreateSharedClone).toHaveBeenCalledWith(
root,
expect.objectContaining({
worktree: true,
branch: 'feature/base-branch',
baseBranch: 'release/main',
}),
);
expect(result.baseBranch).toBe('release/main');
mockCreateSharedClone.mockRestore();
mockResolveBaseBranch.mockRestore();
});
it('should prefer base_branch over legacy baseBranch when both are present', async () => {
const root = createTempProjectDir();
const task = createTask({
slug: 'prefer-base-branch',
data: ({
task: 'Run task with both base branch fields',
worktree: true,
branch: 'feature/base-branch',
base_branch: 'release/main',
baseBranch: 'legacy/main',
} as unknown) as NonNullable<TaskInfo['data']>,
worktreePath: undefined,
status: 'pending',
});
const mockResolveBaseBranch = vi.spyOn(infraTask, 'resolveBaseBranch').mockReturnValue({
branch: 'release/main',
});
const mockCreateSharedClone = vi.spyOn(infraTask, 'createSharedClone').mockReturnValue({
path: '/tmp/shared-clone',
branch: 'feature/base-branch',
});
const result = await resolveTaskExecution(task, root, 'default');
const cloneOptions = mockCreateSharedClone.mock.calls[0]?.[1] as Record<string, unknown> | undefined;
expect(mockResolveBaseBranch).toHaveBeenCalledWith(root, 'release/main');
expect(cloneOptions).toBeDefined();
expect(cloneOptions).toMatchObject({
worktree: true,
branch: 'feature/base-branch',
taskSlug: 'prefer-base-branch',
baseBranch: 'release/main',
});
expect(cloneOptions).not.toMatchObject({ baseBranch: 'legacy/main' });
expect(result.baseBranch).toBe('release/main');
mockCreateSharedClone.mockRestore();
mockResolveBaseBranch.mockRestore();
});
it('should ignore legacy baseBranch field when base_branch is not set', async () => {
const root = createTempProjectDir();
const task = createTask({
slug: 'legacy-base-branch',
data: ({
task: 'Run task with legacy baseBranch',
worktree: true,
branch: 'feature/base-branch',
baseBranch: 'legacy/main',
} as unknown) as NonNullable<TaskInfo['data']>,
worktreePath: undefined,
status: 'pending',
});
const mockResolveBaseBranch = vi.spyOn(infraTask, 'resolveBaseBranch').mockReturnValue({
branch: 'develop',
});
const mockCreateSharedClone = vi.spyOn(infraTask, 'createSharedClone').mockReturnValue({
path: '/tmp/shared-clone',
branch: 'feature/base-branch',
});
const result = await resolveTaskExecution(task, root, 'default');
const cloneOptions = mockCreateSharedClone.mock.calls[0]?.[1] as Record<string, unknown> | undefined;
expect(mockResolveBaseBranch).toHaveBeenCalledWith(root, undefined);
expect(cloneOptions).toBeDefined();
expect(cloneOptions).toMatchObject({
worktree: true,
branch: 'feature/base-branch',
taskSlug: 'legacy-base-branch',
});
expect(cloneOptions).not.toHaveProperty('baseBranch');
expect(result.baseBranch).toBe('develop');
mockCreateSharedClone.mockRestore();
mockResolveBaseBranch.mockRestore();
});
it('should preserve base_branch when reusing an existing worktree path', async () => {
const root = createTempProjectDir();
const worktreePath = path.join(root, 'existing-worktree');
fs.mkdirSync(worktreePath, { recursive: true });
const task = createTask({
data: ({
task: 'Run task with base branch',
worktree: true,
branch: 'feature/base-branch',
base_branch: 'release/main',
} as unknown) as NonNullable<TaskInfo['data']>,
worktreePath,
status: 'pending',
});
const mockResolveBaseBranch = vi.spyOn(infraTask, 'resolveBaseBranch').mockReturnValue({
branch: 'release/main',
});
const mockCreateSharedClone = vi.spyOn(infraTask, 'createSharedClone').mockReturnValue({
path: worktreePath,
branch: 'feature/base-branch',
});
const result = await resolveTaskExecution(task, root, 'default');
expect(result.execCwd).toBe(worktreePath);
expect(result.isWorktree).toBe(true);
expect(result.baseBranch).toBe('release/main');
expect(mockCreateSharedClone).not.toHaveBeenCalled();
mockCreateSharedClone.mockRestore();
mockResolveBaseBranch.mockRestore();
});
it('should prefer base_branch over legacy baseBranch when reusing an existing worktree path', async () => {
const root = createTempProjectDir();
const worktreePath = path.join(root, 'existing-worktree');
fs.mkdirSync(worktreePath, { recursive: true });
const task = createTask({
data: ({
task: 'Run task with both base branch fields',
worktree: true,
branch: 'feature/base-branch',
base_branch: 'release/main',
baseBranch: 'legacy/main',
} as unknown) as NonNullable<TaskInfo['data']>,
worktreePath,
status: 'pending',
});
const mockResolveBaseBranch = vi.spyOn(infraTask, 'resolveBaseBranch').mockReturnValue({
branch: 'release/main',
});
const mockCreateSharedClone = vi.spyOn(infraTask, 'createSharedClone').mockReturnValue({
path: worktreePath,
branch: 'feature/base-branch',
});
const result = await resolveTaskExecution(task, root, 'default');
expect(mockResolveBaseBranch).toHaveBeenCalledWith(root, 'release/main');
expect(mockCreateSharedClone).not.toHaveBeenCalled();
expect(result.execCwd).toBe(worktreePath);
expect(result.isWorktree).toBe(true);
expect(result.baseBranch).toBe('release/main');
mockCreateSharedClone.mockRestore();
mockResolveBaseBranch.mockRestore();
});
it('should ignore legacy baseBranch when reusing an existing worktree path', async () => {
const root = createTempProjectDir();
const worktreePath = path.join(root, 'existing-worktree');
fs.mkdirSync(worktreePath, { recursive: true });
const task = createTask({
data: ({
task: 'Run task with legacy base branch',
worktree: true,
branch: 'feature/base-branch',
baseBranch: 'legacy/main',
} as unknown) as NonNullable<TaskInfo['data']>,
worktreePath,
status: 'pending',
});
const mockResolveBaseBranch = vi.spyOn(infraTask, 'resolveBaseBranch').mockReturnValue({
branch: 'develop',
});
const mockCreateSharedClone = vi.spyOn(infraTask, 'createSharedClone').mockReturnValue({
path: worktreePath,
branch: 'feature/base-branch',
});
const result = await resolveTaskExecution(task, root, 'default');
expect(mockResolveBaseBranch).toHaveBeenCalledWith(root, undefined);
expect(mockCreateSharedClone).not.toHaveBeenCalled();
expect(result.execCwd).toBe(worktreePath);
expect(result.isWorktree).toBe(true);
expect(result.baseBranch).toBe('develop');
mockCreateSharedClone.mockRestore();
mockResolveBaseBranch.mockRestore();
});
it('draft_pr: true が draftPr: true として解決される', async () => {
const root = createTempProjectDir();
const task = createTask({

View File

@ -4,7 +4,8 @@ import * as path from 'node:path';
import { tmpdir } from 'node:os';
import { parse as parseYaml } from 'yaml';
vi.mock('../infra/task/summarize.js', () => ({
vi.mock('../infra/task/summarize.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
summarizeTaskName: vi.fn().mockImplementation((content: string) => {
const slug = content.split('\n')[0]!
.toLowerCase()
@ -36,14 +37,23 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
}),
}));
vi.mock('../infra/task/index.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
getCurrentBranch: vi.fn().mockReturnValue('main'),
branchExists: vi.fn().mockReturnValue(true),
}));
import { success, info } from '../shared/ui/index.js';
import { confirm, promptInput } from '../shared/prompt/index.js';
import { saveTaskFile, saveTaskFromInteractive } from '../features/tasks/add/index.js';
import { getCurrentBranch, branchExists } from '../infra/task/index.js';
const mockSuccess = vi.mocked(success);
const mockInfo = vi.mocked(info);
const mockConfirm = vi.mocked(confirm);
const mockPromptInput = vi.mocked(promptInput);
const mockGetCurrentBranch = vi.mocked(getCurrentBranch);
const mockBranchExists = vi.mocked(branchExists);
let testDir: string;
@ -57,6 +67,8 @@ beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-02-10T04:40:00.000Z'));
testDir = fs.mkdtempSync(path.join(tmpdir(), 'takt-test-save-'));
mockGetCurrentBranch.mockReturnValue('main');
mockBranchExists.mockReturnValue(true);
});
afterEach(() => {
@ -103,7 +115,20 @@ describe('saveTaskFile', () => {
expect(task.task_dir).toBeTypeOf('string');
});
it('draftPr: true が draft_pr: true として保存される', async () => {
it('should persist base_branch when it is provided', async () => {
await saveTaskFile(testDir, 'Task', {
piece: 'review',
issue: 42,
worktree: true,
branch: 'feature/bugfix',
baseBranch: 'release/main',
});
const task = loadTasks(testDir).tasks[0]!;
expect(task.base_branch).toBe('release/main');
});
it('should persist draft_pr when draftPr is true', async () => {
await saveTaskFile(testDir, 'Draft task', {
autoPr: true,
draftPr: true,
@ -209,4 +234,17 @@ describe('saveTaskFromInteractive', () => {
expect(task.worktree).toBe(true);
});
});
it('should save base_branch when current branch is not main/master and user confirms', async () => {
mockGetCurrentBranch.mockReturnValue('feature/custom-base');
mockConfirm.mockResolvedValueOnce(true);
mockPromptInput.mockResolvedValueOnce('');
mockPromptInput.mockResolvedValueOnce('');
mockConfirm.mockResolvedValueOnce(false);
await saveTaskFromInteractive(testDir, 'Task content');
const task = loadTasks(testDir).tasks[0]!;
expect(task.base_branch).toBe('feature/custom-base');
});
});

View File

@ -1,9 +1,3 @@
/**
* Unit tests for task schema validation
*
* Tests TaskRecordSchema cross-field validation rules (status-dependent constraints).
*/
import { describe, it, expect } from 'vitest';
import {
TaskRecordSchema,
@ -85,6 +79,10 @@ describe('TaskExecutionConfigSchema', () => {
it('should reject non-integer issue number', () => {
expect(() => TaskExecutionConfigSchema.parse({ issue: 1.5 })).toThrow();
});
it('should accept base_branch when provided in config', () => {
expect(() => TaskExecutionConfigSchema.parse({ base_branch: 'feature/base' })).not.toThrow();
});
});
describe('TaskFileSchema', () => {
@ -251,4 +249,13 @@ describe('TaskRecordSchema', () => {
expect(() => TaskRecordSchema.parse(record)).toThrow();
});
});
it('should accept base_branch when task record uses config-only fields', () => {
expect(() => TaskRecordSchema.parse({
...makePendingRecord(),
content: undefined,
task_dir: '.takt/tasks/feat-bugfix',
base_branch: 'release/main',
})).not.toThrow();
});
});

View File

@ -0,0 +1,63 @@
import { withProgress } from '../../shared/ui/index.js';
import { formatIssueAsTask, parseIssueNumbers, formatPrReviewAsTask } from '../../infra/github/index.js';
import { getGitProvider } from '../../infra/git/index.js';
import type { PrReviewData } from '../../infra/git/index.js';
import { isDirectTask } from './helpers.js';
export async function resolveIssueInput(
issueOption: number | undefined,
task: string | undefined,
): Promise<{ initialInput: string } | null> {
if (issueOption) {
const ghStatus = getGitProvider().checkCliStatus();
if (!ghStatus.available) {
throw new Error(ghStatus.error);
}
const issue = await withProgress(
'Fetching GitHub Issue...',
(fetchedIssue) => `GitHub Issue fetched: #${fetchedIssue.number} ${fetchedIssue.title}`,
async () => getGitProvider().fetchIssue(issueOption),
);
return { initialInput: formatIssueAsTask(issue) };
}
if (task && isDirectTask(task)) {
const ghStatus = getGitProvider().checkCliStatus();
if (!ghStatus.available) {
throw new Error(ghStatus.error);
}
const tokens = task.trim().split(/\s+/);
const issueNumbers = parseIssueNumbers(tokens);
if (issueNumbers.length === 0) {
throw new Error(`Invalid issue reference: ${task}`);
}
const issues = await withProgress(
'Fetching GitHub Issue...',
(fetchedIssues) => `GitHub Issues fetched: ${fetchedIssues.map((issue) => `#${issue.number}`).join(', ')}`,
async () => issueNumbers.map((n) => getGitProvider().fetchIssue(n)),
);
return { initialInput: issues.map(formatIssueAsTask).join('\n\n---\n\n') };
}
return null;
}
export async function resolvePrInput(
prNumber: number,
): Promise<{ initialInput: string; prBranch: string; baseBranch?: string }> {
const ghStatus = getGitProvider().checkCliStatus();
if (!ghStatus.available) {
throw new Error(ghStatus.error);
}
const prReview = await withProgress(
'Fetching PR review comments...',
(pr: PrReviewData) => `PR fetched: #${pr.number} ${pr.title}`,
async () => getGitProvider().fetchPrReviewComments(prNumber),
);
return {
initialInput: formatPrReviewAsTask(prReview),
prBranch: prReview.headRefName,
baseBranch: prReview.baseRefName,
};
}

View File

@ -1,16 +1,6 @@
/**
* Default action routing
*
* Handles the default (no subcommand) action: task execution,
* pipeline mode, or interactive mode.
*/
import { info, success, error as logError, withProgress } from '../../shared/ui/index.js';
import { info, success, error as logError } from '../../shared/ui/index.js';
import { getErrorMessage } from '../../shared/utils/index.js';
import { getLabel } from '../../shared/i18n/index.js';
import { formatIssueAsTask, parseIssueNumbers, formatPrReviewAsTask } from '../../infra/github/index.js';
import { getGitProvider } from '../../infra/git/index.js';
import type { PrReviewData } from '../../infra/git/index.js';
import { checkoutBranch } from '../../infra/task/index.js';
import { selectAndExecuteTask, determinePiece, saveTaskFromInteractive, createIssueAndSaveTask, promptLabelSelection, type SelectAndExecuteOptions } from '../../features/tasks/index.js';
import { executePipeline } from '../../features/pipeline/index.js';
@ -26,85 +16,9 @@ import {
} from '../../features/interactive/index.js';
import { getPieceDescription, resolveConfigValue, resolveConfigValues, loadPersonaSessions } from '../../infra/config/index.js';
import { program, resolvedCwd, pipelineMode } from './program.js';
import { resolveAgentOverrides, isDirectTask } from './helpers.js';
import { resolveAgentOverrides } from './helpers.js';
import { loadTaskHistory } from './taskHistory.js';
/**
* Resolve issue references from CLI input.
*
* Handles two sources:
* - --issue N option (numeric issue number)
* - Positional argument containing issue references (#N or "#1 #2")
*
* Returns the formatted task text for interactive mode.
* Throws on gh CLI unavailability or fetch failure.
*/
async function resolveIssueInput(
issueOption: number | undefined,
task: string | undefined,
): Promise<{ initialInput: string } | null> {
if (issueOption) {
const ghStatus = getGitProvider().checkCliStatus();
if (!ghStatus.available) {
throw new Error(ghStatus.error);
}
const issue = await withProgress(
'Fetching GitHub Issue...',
(fetchedIssue) => `GitHub Issue fetched: #${fetchedIssue.number} ${fetchedIssue.title}`,
async () => getGitProvider().fetchIssue(issueOption),
);
return { initialInput: formatIssueAsTask(issue) };
}
if (task && isDirectTask(task)) {
const ghStatus = getGitProvider().checkCliStatus();
if (!ghStatus.available) {
throw new Error(ghStatus.error);
}
const tokens = task.trim().split(/\s+/);
const issueNumbers = parseIssueNumbers(tokens);
if (issueNumbers.length === 0) {
throw new Error(`Invalid issue reference: ${task}`);
}
const issues = await withProgress(
'Fetching GitHub Issue...',
(fetchedIssues) => `GitHub Issues fetched: ${fetchedIssues.map((issue) => `#${issue.number}`).join(', ')}`,
async () => issueNumbers.map((n) => getGitProvider().fetchIssue(n)),
);
return { initialInput: issues.map(formatIssueAsTask).join('\n\n---\n\n') };
}
return null;
}
/**
* Resolve PR review comments from `--pr` option.
*
* Fetches review comments and metadata, formats as task text.
* Returns the formatted task text for interactive mode.
* Throws on gh CLI unavailability or fetch failure.
*/
async function resolvePrInput(
prNumber: number,
): Promise<{ initialInput: string; prBranch: string }> {
const ghStatus = getGitProvider().checkCliStatus();
if (!ghStatus.available) {
throw new Error(ghStatus.error);
}
const prReview = await withProgress(
'Fetching PR review comments...',
(pr: PrReviewData) => `PR fetched: #${pr.number} ${pr.title}`,
async () => getGitProvider().fetchPrReviewComments(prNumber),
);
return { initialInput: formatPrReviewAsTask(prReview), prBranch: prReview.headRefName };
}
/**
* Execute default action: handle task execution, pipeline mode, or interactive mode.
* Exported for use in slash-command fallback logic.
*/
import { resolveIssueInput, resolvePrInput } from './routing-inputs.js';
export async function executeDefaultAction(task?: string): Promise<void> {
const opts = program.opts();
if (!pipelineMode && (opts.autoPr === true || opts.draft === true)) {
@ -135,7 +49,6 @@ export async function executeDefaultAction(task?: string): Promise<void> {
piece: opts.piece as string | undefined,
};
// --- Pipeline mode (non-interactive): triggered by --pipeline ---
if (pipelineMode) {
const exitCode = await executePipeline({
issueNumber,
@ -158,9 +71,6 @@ export async function executeDefaultAction(task?: string): Promise<void> {
return;
}
// --- Normal (interactive) mode ---
// Resolve --task option to task text (direct execution, no interactive mode)
const taskFromOption = opts.task as string | undefined;
if (taskFromOption) {
selectOptions.skipTaskList = true;
@ -168,21 +78,21 @@ export async function executeDefaultAction(task?: string): Promise<void> {
return;
}
// Resolve PR review comments (--pr N) before interactive mode
let initialInput: string | undefined = task;
let prBranch: string | undefined;
let prBaseBranch: string | undefined;
if (prNumber) {
try {
const prResult = await resolvePrInput(prNumber);
initialInput = prResult.initialInput;
prBranch = prResult.prBranch;
prBaseBranch = prResult.baseBranch;
} catch (e) {
logError(getErrorMessage(e));
process.exit(1);
}
} else {
// Resolve issue references (--issue N or #N positional arg) before interactive mode
try {
const issueResult = await resolveIssueInput(issueNumber, task);
if (issueResult) {
@ -194,7 +104,6 @@ export async function executeDefaultAction(task?: string): Promise<void> {
}
}
// All paths below go through interactive mode
const globalConfig = resolveConfigValues(resolvedCwd, ['language', 'interactivePreviewMovements', 'provider']);
const lang = resolveLanguage(globalConfig.language);
@ -207,7 +116,6 @@ export async function executeDefaultAction(task?: string): Promise<void> {
const previewCount = globalConfig.interactivePreviewMovements;
const pieceDesc = getPieceDescription(pieceId, resolvedCwd, previewCount);
// Mode selection after piece selection
const selectedMode = await selectInteractiveMode(lang, pieceDesc.interactiveMode);
if (selectedMode === null) {
info(getLabel('interactive.ui.cancelled', lang));
@ -283,7 +191,12 @@ export async function executeDefaultAction(task?: string): Promise<void> {
},
save_task: async ({ task: confirmedTask }) => {
const presetSettings = prBranch
? { worktree: true as const, branch: prBranch, autoPr: false }
? {
worktree: true as const,
branch: prBranch,
autoPr: false,
...(prBaseBranch ? { baseBranch: prBaseBranch } : {}),
}
: undefined;
await saveTaskFromInteractive(resolvedCwd, confirmedTask, pieceId, { presetSettings });
},

View File

@ -1,16 +1,3 @@
/**
* Pipeline orchestration
*
* Thin orchestrator that coordinates pipeline steps:
* 1. Resolve task content
* 2. Prepare execution environment
* 3. Run piece
* 4. Commit & push
* 5. Create PR
*
* Each step is implemented in steps.ts.
*/
import { resolveConfigValues } from '../../infra/config/index.js';
import { info, error, status, blankLine } from '../../shared/ui/index.js';
import { createLogger, getErrorMessage, getSlackWebhookUrl, sendSlackNotification, buildSlackRunSummary } from '../../shared/utils/index.js';
@ -37,8 +24,6 @@ export type { PipelineExecutionOptions };
const log = createLogger('pipeline');
// ---- Pipeline orchestration ----
interface PipelineOutcome {
exitCode: number;
result: PipelineResult;
@ -52,25 +37,28 @@ async function runPipeline(options: PipelineExecutionOptions): Promise<PipelineO
success: false, piece, issueNumber: options.issueNumber, ...overrides,
});
// Step 1: Resolve task content
const taskContent = resolveTaskContent(options);
if (!taskContent) return { exitCode: EXIT_ISSUE_FETCH_FAILED, result: buildResult() };
// Step 2: Prepare execution environment
let context: ExecutionContext;
try {
context = await resolveExecutionContext(cwd, taskContent.task, options, pipelineConfig, taskContent.prBranch);
context = await resolveExecutionContext(
cwd,
taskContent.task,
options,
pipelineConfig,
taskContent.prBranch,
taskContent.prBaseBranch,
);
} catch (err) {
error(`Failed to prepare execution environment: ${getErrorMessage(err)}`);
return { exitCode: EXIT_GIT_OPERATION_FAILED, result: buildResult() };
}
// Step 3: Run piece
log.info('Pipeline piece execution starting', { piece, branch: context.branch, skipGit, issueNumber: options.issueNumber });
const pieceOk = await runPiece(cwd, piece, taskContent.task, context.execCwd, options);
if (!pieceOk) return { exitCode: EXIT_PIECE_FAILED, result: buildResult({ branch: context.branch }) };
// Step 4: Commit & push
if (!skipGit && context.branch) {
const commitMessage = buildCommitMessage(pipelineConfig, taskContent.issue, options.task);
if (!commitAndPush(context.execCwd, cwd, context.branch, commitMessage, context.isWorktree)) {
@ -78,7 +66,6 @@ async function runPipeline(options: PipelineExecutionOptions): Promise<PipelineO
}
}
// Step 5: Create PR
let prUrl: string | undefined;
if (autoPr && !skipGit && context.branch) {
prUrl = submitPullRequest(cwd, context.branch, context.baseBranch, taskContent, piece, pipelineConfig, options);
@ -87,7 +74,6 @@ async function runPipeline(options: PipelineExecutionOptions): Promise<PipelineO
info('--auto-pr is ignored when --skip-git is specified (no push was performed)');
}
// Summary
blankLine();
status('Issue', taskContent.issue ? `#${taskContent.issue.number} "${taskContent.issue.title}"` : 'N/A');
status('Branch', context.branch ?? '(current)');
@ -96,14 +82,6 @@ async function runPipeline(options: PipelineExecutionOptions): Promise<PipelineO
return { exitCode: 0, result: buildResult({ success: true, branch: context.branch, prUrl }) };
}
// ---- Public API ----
/**
* Execute the full pipeline.
*
* Returns a process exit code (0 on success, 2-5 on specific failures).
*/
export async function executePipeline(options: PipelineExecutionOptions): Promise<number> {
const startTime = Date.now();
const runId = generateRunId();
@ -118,8 +96,6 @@ export async function executePipeline(options: PipelineExecutionOptions): Promis
}
}
// ---- Slack notification ----
interface PipelineResult {
success: boolean;
piece: string;
@ -128,7 +104,6 @@ interface PipelineResult {
prUrl?: string;
}
/** Send Slack notification if webhook is configured. Never throws. */
async function notifySlack(runId: string, startTime: number, result: PipelineResult): Promise<void> {
const webhookUrl = getSlackWebhookUrl();
if (!webhookUrl) return;

View File

@ -1,10 +1,3 @@
/**
* Pipeline step implementations
*
* Each function encapsulates one step of the pipeline,
* keeping the orchestrator at a consistent abstraction level.
*/
import { execFileSync } from 'node:child_process';
import { formatIssueAsTask, buildPrBody, formatPrReviewAsTask } from '../../infra/github/index.js';
import { getGitProvider, type Issue } from '../../infra/git/index.js';
@ -14,23 +7,42 @@ import { info, error, success } from '../../shared/ui/index.js';
import { getErrorMessage } from '../../shared/utils/index.js';
import type { PipelineConfig } from '../../core/models/index.js';
// ---- Types ----
export interface TaskContent {
task: string;
issue?: Issue;
/** PR head branch name (set when using --pr) */
prBranch?: string;
prBaseBranch?: string;
}
export interface ExecutionContext {
export interface GitExecutionContext {
execCwd: string;
branch?: string;
baseBranch?: string;
isWorktree: boolean;
branch: string;
baseBranch: string;
}
// ---- Template helpers ----
export interface SkipGitExecutionContext {
execCwd: string;
isWorktree: false;
branch: undefined;
baseBranch: undefined;
}
export type ExecutionContext = GitExecutionContext | SkipGitExecutionContext;
function requireBaseBranch(baseBranch: string | undefined, context: string): string {
if (!baseBranch) {
throw new Error(`Base branch is required (${context})`);
}
return baseBranch;
}
function requireBranch(branch: string | undefined, context: string): string {
if (!branch) {
throw new Error(`Branch is required (${context})`);
}
return branch;
}
function expandTemplate(template: string, vars: Record<string, string>): string {
return template.replace(/\{(\w+)\}/g, (match, key: string) => vars[key] ?? match);
@ -61,6 +73,11 @@ export function buildCommitMessage(
: `takt: ${taskText ?? 'pipeline task'}`;
}
function resolveExecutionBaseBranch(cwd: string, preferredBaseBranch?: string): string {
const { branch } = resolveBaseBranch(cwd, preferredBaseBranch);
return requireBaseBranch(branch, 'execution context');
}
function buildPipelinePrBody(
pipelineConfig: PipelineConfig | undefined,
issue: Issue | undefined,
@ -78,9 +95,6 @@ function buildPipelinePrBody(
return buildPrBody(issue ? [issue] : undefined, report);
}
// ---- Step 1: Resolve task content ----
/** Fetch a GitHub resource with CLI availability check and error handling. */
function fetchGitHubResource<T>(
label: string,
fetch: (provider: ReturnType<typeof getGitProvider>) => T,
@ -109,7 +123,11 @@ export function resolveTaskContent(options: PipelineExecutionOptions): TaskConte
if (!prReview) return undefined;
const task = formatPrReviewAsTask(prReview);
success(`PR #${options.prNumber} fetched: "${prReview.title}"`);
return { task, prBranch: prReview.headRefName };
return {
task,
prBranch: prReview.headRefName,
prBaseBranch: prReview.baseRefName,
};
}
if (options.issueNumber) {
info(`Fetching issue #${options.issueNumber}...`);
@ -129,41 +147,56 @@ export function resolveTaskContent(options: PipelineExecutionOptions): TaskConte
return undefined;
}
// ---- Step 2: Resolve execution context ----
export async function resolveExecutionContext(
cwd: string,
task: string,
options: Pick<PipelineExecutionOptions, 'createWorktree' | 'skipGit' | 'branch' | 'issueNumber'>,
pipelineConfig: PipelineConfig | undefined,
prBranch?: string,
prBaseBranch?: string,
): Promise<ExecutionContext> {
if (options.createWorktree) {
const result = await confirmAndCreateWorktree(cwd, task, options.createWorktree, prBranch);
const result = await confirmAndCreateWorktree(cwd, task, options.createWorktree, prBranch, prBaseBranch);
const branch = requireBranch(result.branch, 'worktree execution');
const baseBranch = requireBaseBranch(result.baseBranch, 'worktree execution');
if (result.isWorktree) {
success(`Worktree created: ${result.execCwd}`);
}
return { execCwd: result.execCwd, branch: result.branch, baseBranch: result.baseBranch, isWorktree: result.isWorktree };
return {
execCwd: result.execCwd,
branch,
baseBranch,
isWorktree: result.isWorktree,
};
}
if (options.skipGit) {
return { execCwd: cwd, isWorktree: false };
return {
execCwd: cwd,
isWorktree: false,
branch: undefined,
baseBranch: undefined,
};
}
if (prBranch) {
info(`Fetching and checking out PR branch: ${prBranch}`);
checkoutBranch(cwd, prBranch);
success(`Checked out PR branch: ${prBranch}`);
return { execCwd: cwd, branch: prBranch, baseBranch: resolveBaseBranch(cwd).branch, isWorktree: false };
const baseBranch = resolveExecutionBaseBranch(cwd, prBaseBranch);
return {
execCwd: cwd,
branch: prBranch,
baseBranch,
isWorktree: false,
};
}
const resolved = resolveBaseBranch(cwd);
const baseBranch = resolveExecutionBaseBranch(cwd);
const branch = options.branch ?? generatePipelineBranchName(pipelineConfig, options.issueNumber);
info(`Creating branch: ${branch}`);
execFileSync('git', ['checkout', '-b', branch], { cwd, stdio: 'pipe' });
success(`Branch created: ${branch}`);
return { execCwd: cwd, branch, baseBranch: resolved.branch, isWorktree: false };
return { execCwd: cwd, branch, baseBranch, isWorktree: false };
}
// ---- Step 3: Run piece ----
export async function runPiece(
projectCwd: string,
piece: string,
@ -192,8 +225,6 @@ export async function runPiece(
return true;
}
// ---- Step 4: Commit & push ----
export function commitAndPush(
execCwd: string,
projectCwd: string,
@ -211,7 +242,6 @@ export function commitAndPush(
}
if (isWorktree) {
// Clone has no origin — push to main project via path, then project pushes to origin
execFileSync('git', ['push', projectCwd, 'HEAD'], { cwd: execCwd, stdio: 'pipe' });
}
@ -225,18 +255,17 @@ export function commitAndPush(
}
}
// ---- Step 5: Submit pull request ----
export function submitPullRequest(
projectCwd: string,
branch: string,
baseBranch: string | undefined,
baseBranch: string,
taskContent: TaskContent,
piece: string,
pipelineConfig: PipelineConfig | undefined,
options: Pick<PipelineExecutionOptions, 'task' | 'repo' | 'draftPr'>,
): string | undefined {
info('Creating pull request...');
const resolvedBaseBranch = requireBaseBranch(baseBranch, 'pull request creation');
const prTitle = taskContent.issue ? `[#${taskContent.issue.number}] ${taskContent.issue.title}` : (options.task ?? 'Pipeline task');
const report = `Piece \`${piece}\` completed successfully.`;
const prBody = buildPipelinePrBody(pipelineConfig, taskContent.issue, report);
@ -245,7 +274,7 @@ export function submitPullRequest(
branch,
title: prTitle,
body: prBody,
base: baseBranch,
base: resolvedBaseBranch,
repo: options.repo,
draft: options.draftPr,
});

View File

@ -7,7 +7,7 @@
import * as path from 'node:path';
import * as fs from 'node:fs';
import { promptInput, confirm, selectOption } from '../../../shared/prompt/index.js';
import { success, info, error, withProgress } from '../../../shared/ui/index.js';
import { info, error, withProgress } from '../../../shared/ui/index.js';
import { getLabel } from '../../../shared/i18n/index.js';
import type { Language } from '../../../core/models/types.js';
import { TaskRunner, type TaskFileData, summarizeTaskName } from '../../../infra/task/index.js';
@ -17,6 +17,7 @@ import { isIssueReference, resolveIssueTask, parseIssueNumbers, formatPrReviewAs
import { getGitProvider, type PrReviewData } from '../../../infra/git/index.js';
import { firstLine } from '../../../infra/task/naming.js';
import { extractTitle, createIssueFromTask } from './issueTask.js';
import { displayTaskCreationResult, promptWorktreeSettings, type WorktreeSettings } from './worktree-settings.js';
export { extractTitle, createIssueFromTask };
const log = createLogger('add-task');
@ -42,7 +43,15 @@ function resolveUniqueTaskSlug(cwd: string, baseSlug: string): string {
export async function saveTaskFile(
cwd: string,
taskContent: string,
options?: { piece?: string; issue?: number; worktree?: boolean | string; branch?: string; autoPr?: boolean; draftPr?: boolean },
options?: {
piece?: string;
issue?: number;
worktree?: boolean | string;
branch?: string;
baseBranch?: string;
autoPr?: boolean;
draftPr?: boolean;
},
): Promise<{ taskName: string; tasksFile: string }> {
const runner = new TaskRunner(cwd);
const slug = await summarizeTaskName(taskContent, { cwd });
@ -56,6 +65,7 @@ export async function saveTaskFile(
const config: Omit<TaskFileData, 'task'> = {
...(options?.worktree !== undefined && { worktree: options.worktree }),
...(options?.branch && { branch: options.branch }),
...(options?.baseBranch && { base_branch: options.baseBranch }),
...(options?.piece && { piece: options.piece }),
...(options?.issue !== undefined && { issue: options.issue }),
...(options?.autoPr !== undefined && { auto_pr: options.autoPr }),
@ -72,34 +82,6 @@ export async function saveTaskFile(
return { taskName: created.name, tasksFile };
}
interface WorktreeSettings {
worktree?: boolean | string;
branch?: string;
autoPr?: boolean;
draftPr?: boolean;
}
function displayTaskCreationResult(
created: { taskName: string; tasksFile: string },
settings: WorktreeSettings,
piece?: string,
): void {
success(`Task created: ${created.taskName}`);
info(` File: ${created.tasksFile}`);
if (settings.worktree) {
info(` Worktree: ${typeof settings.worktree === 'string' ? settings.worktree : 'auto'}`);
}
if (settings.branch) {
info(` Branch: ${settings.branch}`);
}
if (settings.autoPr) {
info(` Auto-PR: yes`);
}
if (settings.draftPr) {
info(` Draft PR: yes`);
}
if (piece) info(` Piece: ${piece}`);
}
/**
* Prompt user to select a label for the GitHub Issue.
@ -126,18 +108,6 @@ export async function promptLabelSelection(lang: Language): Promise<string[]> {
return [selected];
}
async function promptWorktreeSettings(): Promise<WorktreeSettings> {
const customPath = await promptInput('Worktree path (Enter for auto)');
const worktree: boolean | string = customPath || true;
const customBranch = await promptInput('Branch name (Enter for auto)');
const branch = customBranch || undefined;
const autoPr = await confirm('Auto-create PR?', true);
const draftPr = autoPr ? await confirm('Create as draft?', true) : false;
return { worktree, branch, autoPr, draftPr };
}
/**
* Save a task from interactive mode result.
@ -156,7 +126,7 @@ export async function saveTaskFromInteractive(
return;
}
}
const settings = options?.presetSettings ?? await promptWorktreeSettings();
const settings = options?.presetSettings ?? await promptWorktreeSettings(cwd);
const created = await saveTaskFile(cwd, task, { piece, issue: options?.issue, ...settings });
displayTaskCreationResult(created, settings, piece);
}
@ -231,6 +201,7 @@ export async function addTask(
const settings = {
worktree: true,
branch: prReview.headRefName,
baseBranch: prReview.baseRefName,
autoPr: false,
};
const created = await saveTaskFile(cwd, taskContent, { piece, ...settings });
@ -274,7 +245,7 @@ export async function addTask(
return;
}
const settings = await promptWorktreeSettings();
const settings = await promptWorktreeSettings(cwd);
const created = await saveTaskFile(cwd, taskContent, {
piece,

View File

@ -0,0 +1,83 @@
import { confirm, promptInput } from '../../../shared/prompt/index.js';
import { info, success, error } from '../../../shared/ui/index.js';
import { getErrorMessage } from '../../../shared/utils/index.js';
import { getCurrentBranch, branchExists } from '../../../infra/task/index.js';
export interface WorktreeSettings {
worktree?: boolean | string;
branch?: string;
baseBranch?: string;
autoPr?: boolean;
draftPr?: boolean;
}
export function displayTaskCreationResult(
created: { taskName: string; tasksFile: string },
settings: WorktreeSettings,
piece?: string,
): void {
success(`Task created: ${created.taskName}`);
info(` File: ${created.tasksFile}`);
if (settings.worktree) {
info(` Worktree: ${typeof settings.worktree === 'string' ? settings.worktree : 'auto'}`);
}
if (settings.branch) {
info(` Branch: ${settings.branch}`);
}
if (settings.baseBranch) {
info(` Base branch: ${settings.baseBranch}`);
}
if (settings.autoPr) {
info(` Auto-PR: yes`);
}
if (settings.draftPr) {
info(` Draft PR: yes`);
}
if (piece) info(` Piece: ${piece}`);
}
export async function promptWorktreeSettings(cwd: string): Promise<WorktreeSettings> {
let currentBranch: string | undefined;
try {
currentBranch = getCurrentBranch(cwd);
} catch (err) {
error(`Failed to detect current branch: ${getErrorMessage(err)}`);
}
let baseBranch: string | undefined;
if (currentBranch && currentBranch !== 'main' && currentBranch !== 'master') {
const useCurrentAsBase = await confirm(
`現在のブランチ: ${currentBranch}\nBase branch として ${currentBranch} を使いますか?`,
true,
);
if (useCurrentAsBase) {
baseBranch = await resolveExistingBaseBranch(cwd, currentBranch);
}
}
const customPath = await promptInput('Worktree path (Enter for auto)');
const worktree: boolean | string = customPath || true;
const customBranch = await promptInput('Branch name (Enter for auto)');
const branch = customBranch || undefined;
const autoPr = await confirm('Auto-create PR?', true);
const draftPr = autoPr ? await confirm('Create as draft?', true) : false;
return { worktree, branch, baseBranch, autoPr, draftPr };
}
async function resolveExistingBaseBranch(cwd: string, initialBranch: string): Promise<string | undefined> {
let candidate: string | undefined = initialBranch;
while (candidate) {
if (branchExists(cwd, candidate)) {
return candidate;
}
error(`Base branch does not exist: ${candidate}`);
const nextInput = await promptInput('Base branch (Enter for default)');
candidate = nextInput ?? undefined;
}
return undefined;
}

View File

@ -1,11 +1,7 @@
/**
* Resolve execution directory and piece from task data.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { resolvePieceConfigValue } from '../../../infra/config/index.js';
import { type TaskInfo, createSharedClone, summarizeTaskName, detectDefaultBranch } from '../../../infra/task/index.js';
import { type TaskInfo, buildTaskInstruction, createSharedClone, resolveBaseBranch, summarizeTaskName } from '../../../infra/task/index.js';
import { getGitProvider, type Issue } from '../../../infra/git/index.js';
import { withProgress } from '../../../shared/ui/index.js';
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
@ -13,6 +9,15 @@ import { getTaskSlugFromTaskDir } from '../../../shared/utils/taskPaths.js';
const log = createLogger('task');
function resolveTaskDataBaseBranch(taskData: TaskInfo['data']): string | undefined {
return taskData?.base_branch;
}
function resolveTaskBaseBranch(projectDir: string, taskData: TaskInfo['data']): string {
const preferredBaseBranch = resolveTaskDataBaseBranch(taskData);
return resolveBaseBranch(projectDir, preferredBaseBranch).branch;
}
export interface ResolvedTaskExecution {
execCwd: string;
execPiece: string;
@ -31,17 +36,6 @@ export interface ResolvedTaskExecution {
initialIterationOverride?: number;
}
function buildRunTaskDirInstruction(reportDirName: string): string {
const runTaskDir = `.takt/runs/${reportDirName}/context/task`;
const orderFile = `${runTaskDir}/order.md`;
return [
`Implement using only the files in \`${runTaskDir}\`.`,
`Primary spec: \`${orderFile}\`.`,
'Use report files in Report Directory as primary execution history.',
'Do not rely on previous response or conversation summary.',
].join('\n');
}
function stageTaskSpecForExecution(
projectCwd: string,
execCwd: string,
@ -58,7 +52,9 @@ function stageTaskSpecForExecution(
fs.mkdirSync(targetTaskDir, { recursive: true });
fs.copyFileSync(sourceOrderPath, targetOrderPath);
return buildRunTaskDirInstruction(reportDirName);
const runTaskDir = `.takt/runs/${reportDirName}/context/task`;
const orderFile = `${runTaskDir}/order.md`;
return buildTaskInstruction(runTaskDir, orderFile);
}
function throwIfAborted(signal?: AbortSignal): void {
@ -67,10 +63,6 @@ function throwIfAborted(signal?: AbortSignal): void {
}
}
/**
* Resolve a GitHub issue from task data's issue number.
* Returns issue array for buildPrBody, or undefined if no issue or gh CLI unavailable.
*/
export function resolveTaskIssue(issueNumber: number | undefined): Issue[] | undefined {
if (issueNumber === undefined) {
return undefined;
@ -92,11 +84,6 @@ export function resolveTaskIssue(issueNumber: number | undefined): Issue[] | und
}
}
/**
* Resolve execution directory and piece from task data.
* If the task has worktree settings, create a shared clone and use it as cwd.
* Task name is summarized to English by AI for use in branch/clone names.
*/
export async function resolveTaskExecution(
task: TaskInfo,
defaultCwd: string,
@ -117,6 +104,7 @@ export async function resolveTaskExecution(
let branch: string | undefined;
let worktreePath: string | undefined;
let baseBranch: string | undefined;
const preferredBaseBranch = resolveTaskDataBaseBranch(data);
if (task.taskDir) {
const taskSlug = getTaskSlugFromTaskDir(task.taskDir);
if (!taskSlug) {
@ -127,10 +115,9 @@ export async function resolveTaskExecution(
if (data.worktree) {
throwIfAborted(abortSignal);
baseBranch = detectDefaultBranch(defaultCwd);
baseBranch = resolveTaskBaseBranch(defaultCwd, data);
if (task.worktreePath && fs.existsSync(task.worktreePath)) {
// Reuse existing worktree (clone still on disk from previous execution)
execCwd = task.worktreePath;
branch = data.branch;
worktreePath = task.worktreePath;
@ -149,6 +136,7 @@ export async function resolveTaskExecution(
async () => createSharedClone(defaultCwd, {
worktree: data.worktree!,
branch: data.branch,
...(preferredBaseBranch ? { baseBranch: preferredBaseBranch } : {}),
taskSlug,
issueNumber: data.issue,
}),

View File

@ -1,10 +1,3 @@
/**
* Task execution orchestration.
*
* Coordinates piece selection and in-place task execution.
* Extracted from cli.ts to avoid mixing CLI parsing with business logic.
*/
import {
loadPieceByIdentifier,
isPiecePath,
@ -42,6 +35,7 @@ export async function confirmAndCreateWorktree(
task: string,
createWorktreeOverride?: boolean | undefined,
branchOverride?: string,
baseBranchOverride?: string,
): Promise<WorktreeConfirmationResult> {
const useWorktree =
typeof createWorktreeOverride === 'boolean'
@ -52,7 +46,7 @@ export async function confirmAndCreateWorktree(
return { execCwd: cwd, isWorktree: false };
}
const baseBranch = resolveBaseBranch(cwd).branch;
const baseBranch = resolveBaseBranch(cwd, baseBranchOverride).branch;
const taskSlug = await withProgress(
'Generating branch name...',
@ -66,6 +60,7 @@ export async function confirmAndCreateWorktree(
async () => createSharedClone(cwd, {
worktree: true,
taskSlug,
...(baseBranchOverride ? { baseBranch: baseBranchOverride } : {}),
...(branchOverride ? { branch: branchOverride } : {}),
}),
);
@ -73,10 +68,6 @@ export async function confirmAndCreateWorktree(
return { execCwd: result.path, isWorktree: true, branch: result.branch, baseBranch, taskSlug };
}
/**
* Execute a task with piece selection.
* Shared by direct task execution and interactive mode.
*/
export async function selectAndExecuteTask(
cwd: string,
task: string,
@ -90,7 +81,6 @@ export async function selectAndExecuteTask(
return;
}
// execute action always runs in-place (no worktree prompt/creation).
const execCwd = cwd;
log.info('Starting task execution', { piece: pieceIdentifier, worktree: false });
const taskRunner = new TaskRunner(cwd);

View File

@ -1,10 +1,3 @@
/**
* Git provider abstraction types
*
* Defines the GitProvider interface and its supporting types,
* decoupled from any specific provider implementation.
*/
export interface CliStatus {
available: boolean;
error?: string;
@ -24,89 +17,65 @@ export interface ExistingPr {
}
export interface CreatePrOptions {
/** Branch to create PR from */
branch: string;
/** PR title */
title: string;
/** PR body (markdown) */
body: string;
/** Base branch (default: repo default branch) */
base?: string;
/** Repository in owner/repo format (optional, uses current repo if omitted) */
repo?: string;
/** Create PR as draft */
draft?: boolean;
}
export interface CreatePrResult {
success: boolean;
/** PR URL on success */
url?: string;
/** Error message on failure */
error?: string;
}
export interface CommentResult {
success: boolean;
/** Error message on failure */
error?: string;
}
export interface CreateIssueOptions {
/** Issue title */
title: string;
/** Issue body (markdown) */
body: string;
/** Labels to apply */
labels?: string[];
}
export interface CreateIssueResult {
success: boolean;
/** Issue URL on success */
url?: string;
/** Error message on failure */
error?: string;
}
/** PR review comment (conversation or inline) */
export interface PrReviewComment {
author: string;
body: string;
/** File path for inline comments (undefined for conversation comments) */
path?: string;
/** Line number for inline comments */
line?: number;
}
/** PR review data including metadata and review comments */
export interface PrReviewData {
number: number;
title: string;
body: string;
url: string;
/** Branch name of the PR head */
headRefName: string;
/** Conversation comments (non-review) */
baseRefName?: string;
comments: PrReviewComment[];
/** Review comments (from reviews) */
reviews: PrReviewComment[];
/** Changed file paths */
files: string[];
}
export interface GitProvider {
/** Check CLI tool availability and authentication status */
checkCliStatus(): CliStatus;
fetchIssue(issueNumber: number): Issue;
createIssue(options: CreateIssueOptions): CreateIssueResult;
/** Fetch PR review comments and metadata */
fetchPrReviewComments(prNumber: number): PrReviewData;
/** Find an open PR for the given branch. Returns undefined if no PR exists. */
findExistingPr(cwd: string, branch: string): ExistingPr | undefined;
createPullRequest(cwd: string, options: CreatePrOptions): CreatePrResult;

View File

@ -53,7 +53,7 @@ export function commentOnPr(cwd: string, prNumber: number, body: string): Commen
}
/** JSON fields requested from `gh pr view` for review data */
const PR_REVIEW_JSON_FIELDS = 'number,title,body,url,headRefName,comments,reviews,files';
const PR_REVIEW_JSON_FIELDS = 'number,title,body,url,headRefName,baseRefName,comments,reviews,files';
/** Raw shape returned by `gh pr view --json` for review data */
interface GhPrViewReviewResponse {
@ -62,6 +62,7 @@ interface GhPrViewReviewResponse {
body: string;
url: string;
headRefName: string;
baseRefName?: string;
comments: Array<{ author: { login: string }; body: string }>;
reviews: Array<{
author: { login: string };
@ -112,6 +113,7 @@ export function fetchPrReviewComments(prNumber: number): PrReviewData {
body: data.body,
url: data.url,
headRefName: data.headRefName,
baseRefName: data.baseRefName,
comments,
reviews,
files: data.files.map((f) => f.path),

View File

@ -0,0 +1,88 @@
import { execFileSync } from 'node:child_process';
import { createLogger } from '../../shared/utils/index.js';
import { resolveConfigValue } from '../config/index.js';
import { detectDefaultBranch } from './branchList.js';
const log = createLogger('clone');
function gitRefExists(projectDir: string, ref: string): boolean {
try {
execFileSync('git', ['check-ref-format', '--branch', '--', ref], {
cwd: projectDir,
stdio: 'pipe',
});
execFileSync('git', ['rev-parse', '--verify', '--', ref], {
cwd: projectDir,
stdio: 'pipe',
});
return true;
} catch {
return false;
}
}
export function branchExists(projectDir: string, branch: string): boolean {
return gitRefExists(projectDir, branch) || gitRefExists(projectDir, `origin/${branch}`);
}
function resolveConfiguredBaseBranch(projectDir: string, explicitBaseBranch?: string): string | undefined {
if (explicitBaseBranch !== undefined) {
const normalized = explicitBaseBranch.trim();
if (normalized.length === 0) {
throw new Error('Base branch override must not be empty.');
}
return normalized;
}
return resolveConfigValue(projectDir, 'baseBranch');
}
function assertValidBranchRef(projectDir: string, ref: string): void {
try {
execFileSync('git', ['check-ref-format', '--branch', '--', ref], {
cwd: projectDir,
stdio: 'pipe',
});
} catch {
throw new Error(`Invalid base branch: ${ref}`);
}
}
export function resolveBaseBranch(
projectDir: string,
explicitBaseBranch?: string,
): { branch: string; fetchedCommit?: string } {
const configBaseBranch = resolveConfiguredBaseBranch(projectDir, explicitBaseBranch);
const autoFetch = resolveConfigValue(projectDir, 'autoFetch');
const baseBranch = configBaseBranch ?? detectDefaultBranch(projectDir);
if (explicitBaseBranch !== undefined) {
assertValidBranchRef(projectDir, baseBranch);
}
if (explicitBaseBranch !== undefined && !branchExists(projectDir, baseBranch)) {
throw new Error(`Base branch does not exist: ${baseBranch}`);
}
if (!autoFetch) {
return { branch: baseBranch };
}
try {
execFileSync('git', ['fetch', 'origin'], {
cwd: projectDir,
stdio: 'pipe',
});
const fetchedCommit = execFileSync(
'git', ['rev-parse', `origin/${baseBranch}`],
{ cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' },
).trim();
log.info('Fetched remote and resolved base branch', { baseBranch, fetchedCommit });
return { branch: baseBranch, fetchedCommit };
} catch (err) {
log.info('Failed to fetch from remote, continuing with local state', { baseBranch, error: String(err) });
return { branch: baseBranch };
}
}

View File

@ -0,0 +1,116 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { execFileSync } from 'node:child_process';
import { createLogger } from '../../shared/utils/index.js';
import { loadProjectConfig } from '../config/index.js';
const log = createLogger('clone');
export function resolveCloneSubmoduleOptions(projectDir: string): { args: string[]; label: string; targets: string } {
const config = loadProjectConfig(projectDir);
const resolvedSubmodules = config.submodules ?? (config.withSubmodules === true ? 'all' : undefined);
if (resolvedSubmodules === 'all') {
return {
args: ['--recurse-submodules'],
label: 'with submodule',
targets: 'all',
};
}
if (Array.isArray(resolvedSubmodules) && resolvedSubmodules.length > 0) {
return {
args: resolvedSubmodules.map((submodulePath) => `--recurse-submodules=${submodulePath}`),
label: 'with submodule',
targets: resolvedSubmodules.join(', '),
};
}
return {
args: [],
label: 'without submodule',
targets: 'none',
};
}
function resolveMainRepo(projectDir: string): string {
const gitPath = path.join(projectDir, '.git');
try {
const stats = fs.statSync(gitPath);
if (stats.isFile()) {
const content = fs.readFileSync(gitPath, 'utf-8');
const match = content.match(/^gitdir:\s*(.+)$/m);
if (match && match[1]) {
const worktreePath = match[1].trim();
const gitDir = path.resolve(worktreePath, '..', '..');
const mainRepoPath = path.dirname(gitDir);
log.info('Detected worktree, using main repo', { worktree: projectDir, mainRepo: mainRepoPath });
return mainRepoPath;
}
}
} catch (err) {
log.debug('Failed to resolve main repo, using projectDir as-is', { error: String(err) });
}
return projectDir;
}
export function cloneAndIsolate(projectDir: string, clonePath: string, branch?: string): void {
const referenceRepo = resolveMainRepo(projectDir);
const cloneSubmoduleOptions = resolveCloneSubmoduleOptions(projectDir);
fs.mkdirSync(path.dirname(clonePath), { recursive: true });
const branchArgs = branch ? ['--branch', branch] : [];
const commonArgs: string[] = [
...cloneSubmoduleOptions.args,
...branchArgs,
projectDir,
clonePath,
];
const referenceCloneArgs = ['clone', '--reference', referenceRepo, '--dissociate', ...commonArgs];
const fallbackCloneArgs = ['clone', ...commonArgs];
try {
execFileSync('git', referenceCloneArgs, {
cwd: projectDir,
stdio: 'pipe',
});
} catch (err) {
const stderr = ((err as { stderr?: Buffer }).stderr ?? Buffer.alloc(0)).toString();
if (stderr.includes('reference repository is shallow')) {
log.info('Reference repository is shallow, retrying clone without --reference', { referenceRepo });
try { fs.rmSync(clonePath, { recursive: true, force: true }); } catch (e) { log.debug('Failed to cleanup partial clone before retry', { clonePath, error: String(e) }); }
execFileSync('git', fallbackCloneArgs, {
cwd: projectDir,
stdio: 'pipe',
});
} else {
throw err;
}
}
execFileSync('git', ['remote', 'remove', 'origin'], {
cwd: clonePath,
stdio: 'pipe',
});
for (const key of ['user.name', 'user.email']) {
try {
const value = execFileSync('git', ['config', '--local', key], {
cwd: projectDir,
stdio: 'pipe',
}).toString().trim();
if (value) {
execFileSync('git', ['config', key, value], {
cwd: clonePath,
stdio: 'pipe',
});
}
} catch (err) {
log.debug('Local git config not found', { key, error: String(err) });
}
}
}

View File

@ -0,0 +1,45 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { createLogger } from '../../shared/utils/index.js';
const log = createLogger('clone');
const CLONE_META_DIR = 'clone-meta';
function encodeBranchName(branch: string): string {
return branch.replace(/\//g, '--');
}
export function getCloneMetaPath(projectDir: string, branch: string): string {
return path.join(projectDir, '.takt', CLONE_META_DIR, `${encodeBranchName(branch)}.json`);
}
export function saveCloneMeta(projectDir: string, branch: string, clonePath: string): void {
const filePath = getCloneMetaPath(projectDir, branch);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify({ branch, clonePath }));
log.info('Clone meta saved', { branch, clonePath });
}
export function removeCloneMeta(projectDir: string, branch: string): void {
const filePath = getCloneMetaPath(projectDir, branch);
if (!fs.existsSync(filePath)) {
return;
}
fs.unlinkSync(filePath);
log.info('Clone meta removed', { branch });
}
export function loadCloneMeta(projectDir: string, branch: string): { clonePath: string } | null {
const filePath = getCloneMetaPath(projectDir, branch);
if (!fs.existsSync(filePath)) {
return null;
}
try {
const raw = fs.readFileSync(filePath, 'utf-8');
return JSON.parse(raw) as { clonePath: string };
} catch (err) {
log.debug('Failed to load clone meta', { branch, error: String(err) });
return null;
}
}

View File

@ -1,69 +1,23 @@
/**
* Git clone lifecycle management
*
* Creates, removes, and tracks git clones for task isolation.
* Uses `git clone --reference --dissociate` so each clone has a fully
* independent .git directory, then removes the origin remote to prevent
* Claude Code SDK from traversing back to the main repository.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { execFileSync } from 'node:child_process';
import { createLogger } from '../../shared/utils/index.js';
import { loadProjectConfig, resolveConfigValue } from '../config/index.js';
import { detectDefaultBranch } from './branchList.js';
import { resolveConfigValue } from '../config/index.js';
import type { WorktreeOptions, WorktreeResult } from './types.js';
import { branchExists, resolveBaseBranch as resolveBaseBranchInternal } from './clone-base-branch.js';
import { cloneAndIsolate, resolveCloneSubmoduleOptions } from './clone-exec.js';
import { loadCloneMeta, removeCloneMeta as removeCloneMetaFile, saveCloneMeta as saveCloneMetaFile } from './clone-meta.js';
export type { WorktreeOptions, WorktreeResult };
export { branchExists } from './clone-base-branch.js';
const log = createLogger('clone');
const CLONE_META_DIR = 'clone-meta';
function resolveCloneSubmoduleOptions(projectDir: string): { args: string[]; label: string; targets: string } {
const config = loadProjectConfig(projectDir);
const resolvedSubmodules = config.submodules ?? (config.withSubmodules === true ? 'all' : undefined);
if (resolvedSubmodules === 'all') {
return {
args: ['--recurse-submodules'],
label: 'with submodule',
targets: 'all',
};
}
if (Array.isArray(resolvedSubmodules) && resolvedSubmodules.length > 0) {
return {
args: resolvedSubmodules.map((submodulePath) => `--recurse-submodules=${submodulePath}`),
label: 'with submodule',
targets: resolvedSubmodules.join(', '),
};
}
return {
args: [],
label: 'without submodule',
targets: 'none',
};
}
/**
* Manages git clone lifecycle for task isolation.
*
* Handles creation, removal, and metadata tracking of clones
* used for parallel task execution.
*/
export class CloneManager {
private static generateTimestamp(): string {
return new Date().toISOString().replace(/[-:.]/g, '').slice(0, 13);
}
/**
* Resolve the base directory for clones from global config.
* Returns the configured worktree_dir (resolved to absolute), or
* the default 'takt-worktrees' (plural).
*/
private static resolveCloneBaseDir(projectDir: string): string {
const worktreeDir = resolveConfigValue(projectDir, 'worktreeDir');
if (worktreeDir) {
@ -74,7 +28,6 @@ export class CloneManager {
return path.join(projectDir, '..', 'takt-worktrees');
}
/** Resolve the clone path based on options and global config */
private static resolveClonePath(projectDir: string, options: WorktreeOptions): string {
const timestamp = CloneManager.generateTimestamp();
const slug = options.taskSlug;
@ -97,7 +50,6 @@ export class CloneManager {
return path.join(CloneManager.resolveCloneBaseDir(projectDir), dirName);
}
/** Resolve branch name from options */
private static resolveBranchName(options: WorktreeOptions): string {
if (options.branch) {
return options.branch;
@ -113,178 +65,16 @@ export class CloneManager {
return slug ? `takt/${timestamp}-${slug}` : `takt/${timestamp}`;
}
private static branchExists(projectDir: string, branch: string): boolean {
// Local branch
try {
execFileSync('git', ['rev-parse', '--verify', branch], {
cwd: projectDir,
stdio: 'pipe',
});
return true;
} catch {
// not found locally — fall through to remote check
}
// Remote tracking branch
try {
execFileSync('git', ['rev-parse', '--verify', `origin/${branch}`], {
cwd: projectDir,
stdio: 'pipe',
});
return true;
} catch {
return false;
}
static resolveBaseBranch(
projectDir: string,
explicitBaseBranch?: string,
): { branch: string; fetchedCommit?: string } {
return resolveBaseBranchInternal(projectDir, explicitBaseBranch);
}
/**
* Resolve the main repository path (handles git worktree case).
* If projectDir is a worktree, returns the main repo path.
* Otherwise, returns projectDir as-is.
*/
private static resolveMainRepo(projectDir: string): string {
const gitPath = path.join(projectDir, '.git');
try {
const stats = fs.statSync(gitPath);
if (stats.isFile()) {
const content = fs.readFileSync(gitPath, 'utf-8');
const match = content.match(/^gitdir:\s*(.+)$/m);
if (match && match[1]) {
const worktreePath = match[1].trim();
const gitDir = path.resolve(worktreePath, '..', '..');
const mainRepoPath = path.dirname(gitDir);
log.info('Detected worktree, using main repo', { worktree: projectDir, mainRepo: mainRepoPath });
return mainRepoPath;
}
}
} catch (err) {
log.debug('Failed to resolve main repo, using projectDir as-is', { error: String(err) });
}
return projectDir;
}
/**
* Resolve the base branch for cloning and optionally fetch from remote.
*
* When `auto_fetch` config is true:
* 1. Runs `git fetch origin` (without modifying local branches)
* 2. Resolves base branch from config `base_branch` remote default branch fallback
* 3. Returns the branch name and the fetched commit hash of `origin/<baseBranch>`
*
* When `auto_fetch` is false (default):
* Returns only the branch name (config `base_branch` remote default branch fallback)
*
* Any failure (network, no remote, etc.) is non-fatal.
*/
static resolveBaseBranch(projectDir: string): { branch: string; fetchedCommit?: string } {
const configBaseBranch = resolveConfigValue(projectDir, 'baseBranch');
const autoFetch = resolveConfigValue(projectDir, 'autoFetch');
// Determine base branch: config base_branch → remote default branch
const baseBranch = configBaseBranch ?? detectDefaultBranch(projectDir);
if (!autoFetch) {
return { branch: baseBranch };
}
try {
// Fetch only — do not modify any local branch refs
execFileSync('git', ['fetch', 'origin'], {
cwd: projectDir,
stdio: 'pipe',
});
// Get the latest commit hash from the remote-tracking ref
const fetchedCommit = execFileSync(
'git', ['rev-parse', `origin/${baseBranch}`],
{ cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' },
).trim();
log.info('Fetched remote and resolved base branch', { baseBranch, fetchedCommit });
return { branch: baseBranch, fetchedCommit };
} catch (err) {
// Network errors, no remote, no tracking ref — all non-fatal
log.info('Failed to fetch from remote, continuing with local state', { baseBranch, error: String(err) });
return { branch: baseBranch };
}
}
/** Clone a repository and remove origin to isolate from the main repo.
* When `branch` is specified, `--branch` is passed to `git clone` so the
* branch is checked out as a local branch *before* origin is removed.
* Without this, non-default branches are lost when `git remote remove origin`
* deletes the remote-tracking refs.
*/
private static cloneAndIsolate(projectDir: string, clonePath: string, branch?: string): void {
const referenceRepo = CloneManager.resolveMainRepo(projectDir);
const cloneSubmoduleOptions = resolveCloneSubmoduleOptions(projectDir);
fs.mkdirSync(path.dirname(clonePath), { recursive: true });
const commonArgs: string[] = [...cloneSubmoduleOptions.args];
if (branch) {
commonArgs.push('--branch', branch);
}
commonArgs.push(projectDir, clonePath);
const referenceCloneArgs = ['clone', '--reference', referenceRepo, '--dissociate', ...commonArgs];
const fallbackCloneArgs = ['clone', ...commonArgs];
try {
execFileSync('git', referenceCloneArgs, {
cwd: projectDir,
stdio: 'pipe',
});
} catch (err) {
const stderr = ((err as { stderr?: Buffer }).stderr ?? Buffer.alloc(0)).toString();
if (stderr.includes('reference repository is shallow')) {
log.info('Reference repository is shallow, retrying clone without --reference', { referenceRepo });
try { fs.rmSync(clonePath, { recursive: true, force: true }); } catch (e) { log.debug('Failed to cleanup partial clone before retry', { clonePath, error: String(e) }); }
execFileSync('git', fallbackCloneArgs, {
cwd: projectDir,
stdio: 'pipe',
});
} else {
throw err;
}
}
execFileSync('git', ['remote', 'remove', 'origin'], {
cwd: clonePath,
stdio: 'pipe',
});
// Propagate local git user config from source repo to clone
for (const key of ['user.name', 'user.email']) {
try {
const value = execFileSync('git', ['config', '--local', key], {
cwd: projectDir,
stdio: 'pipe',
}).toString().trim();
if (value) {
execFileSync('git', ['config', key, value], {
cwd: clonePath,
stdio: 'pipe',
});
}
} catch {
// not set locally — skip
}
}
}
private static encodeBranchName(branch: string): string {
return branch.replace(/\//g, '--');
}
private static getCloneMetaPath(projectDir: string, branch: string): string {
return path.join(projectDir, '.takt', CLONE_META_DIR, `${CloneManager.encodeBranchName(branch)}.json`);
}
/** Create a git clone for a task */
createSharedClone(projectDir: string, options: WorktreeOptions): WorktreeResult {
const { branch: baseBranch, fetchedCommit } = CloneManager.resolveBaseBranch(projectDir);
const requestedBaseBranch = options.baseBranch;
const { branch: baseBranch, fetchedCommit } = CloneManager.resolveBaseBranch(projectDir, requestedBaseBranch);
const clonePath = CloneManager.resolveClonePath(projectDir, options);
const branch = CloneManager.resolveBranchName(options);
@ -295,12 +85,10 @@ export class CloneManager {
{ path: clonePath, branch }
);
if (CloneManager.branchExists(projectDir, branch)) {
CloneManager.cloneAndIsolate(projectDir, clonePath, branch);
if (branchExists(projectDir, branch)) {
cloneAndIsolate(projectDir, clonePath, branch);
} else {
// Clone from the base branch so the task starts from latest state
CloneManager.cloneAndIsolate(projectDir, clonePath, baseBranch);
// If we fetched a newer commit from remote, reset to it
cloneAndIsolate(projectDir, clonePath, baseBranch);
if (fetchedCommit) {
execFileSync('git', ['reset', '--hard', fetchedCommit], { cwd: clonePath, stdio: 'pipe' });
}
@ -313,9 +101,7 @@ export class CloneManager {
return { path: clonePath, branch };
}
/** Create a temporary clone for an existing branch */
createTempCloneForBranch(projectDir: string, branch: string): WorktreeResult {
// fetch の副作用(リモートの最新状態への同期)のために呼び出す
CloneManager.resolveBaseBranch(projectDir);
const timestamp = CloneManager.generateTimestamp();
@ -323,7 +109,7 @@ export class CloneManager {
log.info('Creating temp clone for branch', { path: clonePath, branch });
CloneManager.cloneAndIsolate(projectDir, clonePath, branch);
cloneAndIsolate(projectDir, clonePath, branch);
this.saveCloneMeta(projectDir, branch, clonePath);
log.info('Temp clone created', { path: clonePath, branch });
@ -331,7 +117,6 @@ export class CloneManager {
return { path: clonePath, branch };
}
/** Remove a clone directory */
removeClone(clonePath: string): void {
log.info('Removing clone', { path: clonePath });
try {
@ -342,30 +127,20 @@ export class CloneManager {
}
}
/** Save clone metadata (branch → clonePath mapping) */
saveCloneMeta(projectDir: string, branch: string, clonePath: string): void {
const filePath = CloneManager.getCloneMetaPath(projectDir, branch);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify({ branch, clonePath }));
log.info('Clone meta saved', { branch, clonePath });
saveCloneMetaFile(projectDir, branch, clonePath);
}
/** Remove clone metadata for a branch */
removeCloneMeta(projectDir: string, branch: string): void {
try {
fs.unlinkSync(CloneManager.getCloneMetaPath(projectDir, branch));
log.info('Clone meta removed', { branch });
} catch {
// File may not exist — ignore
}
removeCloneMetaFile(projectDir, branch);
}
/** Clean up an orphaned clone directory associated with a branch */
cleanupOrphanedClone(projectDir: string, branch: string): void {
try {
const raw = fs.readFileSync(CloneManager.getCloneMetaPath(projectDir, branch), 'utf-8');
const meta = JSON.parse(raw) as { clonePath: string };
// Validate clonePath is within the expected clone base directory to prevent path traversal.
const meta = loadCloneMeta(projectDir, branch);
if (!meta) {
this.removeCloneMeta(projectDir, branch);
return;
}
const cloneBaseDir = path.resolve(CloneManager.resolveCloneBaseDir(projectDir));
const resolvedClonePath = path.resolve(meta.clonePath);
if (!resolvedClonePath.startsWith(cloneBaseDir + path.sep)) {
@ -380,15 +155,10 @@ export class CloneManager {
this.removeClone(resolvedClonePath);
log.info('Orphaned clone cleaned up', { branch, clonePath: resolvedClonePath });
}
} catch {
// No metadata or parse error — nothing to clean up
}
this.removeCloneMeta(projectDir, branch);
}
}
// ---- Module-level functions ----
const defaultManager = new CloneManager();
export function createSharedClone(projectDir: string, options: WorktreeOptions): WorktreeResult {
@ -415,6 +185,9 @@ export function cleanupOrphanedClone(projectDir: string, branch: string): void {
defaultManager.cleanupOrphanedClone(projectDir, branch);
}
export function resolveBaseBranch(projectDir: string): { branch: string; fetchedCommit?: string } {
return CloneManager.resolveBaseBranch(projectDir);
export function resolveBaseBranch(
projectDir: string,
explicitBaseBranch?: string,
): { branch: string; fetchedCommit?: string } {
return CloneManager.resolveBaseBranch(projectDir, explicitBaseBranch);
}

View File

@ -45,6 +45,7 @@ export {
removeCloneMeta,
cleanupOrphanedClone,
resolveBaseBranch,
branchExists,
} from './clone.js';
export {
detectDefaultBranch,
@ -56,6 +57,7 @@ export {
buildListItems,
} from './branchList.js';
export { stageAndCommit, getCurrentBranch, pushBranch, checkoutBranch } from './git.js';
export { buildTaskInstruction } from './instruction.js';
export { autoCommitAndPush, type AutoCommitResult } from './autoCommit.js';
export { summarizeTaskName } from './summarize.js';
export { TaskWatcher, type TaskWatcherOptions } from './watcher.js';

View File

@ -0,0 +1,8 @@
export function buildTaskInstruction(taskDir: string, orderFile: string): string {
return [
`Implement using only the files in \`${taskDir}\`.`,
`Primary spec: \`${orderFile}\`.`,
'Use report files in Report Directory as primary execution history.',
'Do not rely on previous response or conversation summary.',
].join('\n');
}

View File

@ -1,6 +1,7 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { TaskFileSchema, type TaskFileData, type TaskRecord } from './schema.js';
import { buildTaskInstruction } from './instruction.js';
import { firstLine } from './naming.js';
import type { TaskInfo, TaskListItem } from './types.js';
@ -12,17 +13,6 @@ function toDisplayPath(projectDir: string, targetPath: string): string {
return relativePath;
}
function buildTaskDirInstruction(projectDir: string, taskDirPath: string, orderFilePath: string): string {
const displayTaskDir = toDisplayPath(projectDir, taskDirPath);
const displayOrderFile = toDisplayPath(projectDir, orderFilePath);
return [
`Implement using only the files in \`${displayTaskDir}\`.`,
`Primary spec: \`${displayOrderFile}\`.`,
'Use report files in Report Directory as primary execution history.',
'Do not rely on previous response or conversation summary.',
].join('\n');
}
export function resolveTaskContent(projectDir: string, task: TaskRecord): string {
if (task.content) {
return task.content;
@ -33,7 +23,10 @@ export function resolveTaskContent(projectDir: string, task: TaskRecord): string
if (!fs.existsSync(orderFilePath)) {
throw new Error(`Task spec file is missing: ${orderFilePath}`);
}
return buildTaskDirInstruction(projectDir, taskDirPath, orderFilePath);
return buildTaskInstruction(
toDisplayPath(projectDir, taskDirPath),
toDisplayPath(projectDir, orderFilePath),
);
}
if (!task.content_file) {
throw new Error(`Task content is missing: ${task.name}`);
@ -50,6 +43,7 @@ function buildTaskFileData(task: TaskRecord, content: string): TaskFileData {
task: content,
worktree: task.worktree,
branch: task.branch,
base_branch: task.base_branch,
piece: task.piece,
issue: task.issue,
start_movement: task.start_movement,

View File

@ -12,6 +12,7 @@ import { isValidTaskDir } from '../../shared/utils/taskPaths.js';
export const TaskExecutionConfigSchema = z.object({
worktree: z.union([z.boolean(), z.string()]).optional(),
branch: z.string().optional(),
base_branch: z.string().optional(),
piece: z.string().optional(),
issue: z.number().int().positive().optional(),
start_movement: z.string().optional(),

View File

@ -1,11 +1,6 @@
/**
* Task module type definitions
*/
import type { TaskFileData } from './schema.js';
import type { TaskFailure, TaskStatus } from './schema.js';
/** タスク情報 */
export interface TaskInfo {
filePath: string;
name: string;
@ -18,7 +13,6 @@ export interface TaskInfo {
data: TaskFileData | null;
}
/** タスク実行結果 */
export interface TaskResult {
task: TaskInfo;
success: boolean;
@ -34,49 +28,37 @@ export interface TaskResult {
}
export interface WorktreeOptions {
/** worktree setting: true = auto path, string = custom path */
worktree: boolean | string;
/** Branch name (optional, auto-generated if omitted) */
branch?: string;
/** Task slug for auto-generated paths/branches */
baseBranch?: string;
taskSlug: string;
/** GitHub Issue number (optional, for formatting branch/path) */
issueNumber?: number;
}
export interface WorktreeResult {
/** Absolute path to the clone */
path: string;
/** Branch name used */
branch: string;
}
/** Branch info from `git branch --list` */
export interface BranchInfo {
branch: string;
commit: string;
worktreePath?: string; // Path to worktree directory (for worktree-sessions branches)
worktreePath?: string;
}
/** Branch with list metadata */
export interface BranchListItem {
info: BranchInfo;
filesChanged: number;
taskSlug: string;
/** Original task instruction extracted from first commit message */
originalInstruction: string;
}
export interface SummarizeOptions {
/** Working directory for Claude execution */
cwd: string;
/** Model to use (optional, defaults to config or haiku) */
model?: string;
/** Use LLM for summarization. Defaults to config.branchNameStrategy === 'ai'. If false, uses romanization. */
useLLM?: boolean;
}
/** pending/failedタスクのリストアイテム */
export interface TaskListItem {
kind: 'pending' | 'running' | 'completed' | 'failed' | 'exceeded';
name: string;