From 290d085f5e614750fad2275c2a662fe99af3e5be Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Tue, 3 Mar 2026 19:37:07 +0900 Subject: [PATCH] takt: implement-task-base-branch (#455) --- src/__tests__/addTask.test.ts | 82 ++++- src/__tests__/cli-routing-pr-resolve.test.ts | 44 ++- src/__tests__/clone.test.ts | 101 +++++- src/__tests__/github-pr.test.ts | 13 +- src/__tests__/pipelineExecution.test.ts | 338 +++++++++++++----- src/__tests__/resolveTask.test.ts | 236 +++++++++++- src/__tests__/saveTaskFile.test.ts | 42 ++- src/__tests__/task-schema.test.ts | 19 +- src/app/cli/routing-inputs.ts | 63 ++++ src/app/cli/routing.ts | 109 +----- src/features/pipeline/execute.ts | 41 +-- src/features/pipeline/steps.ts | 99 +++-- src/features/tasks/add/index.ts | 59 +-- src/features/tasks/add/worktree-settings.ts | 83 +++++ src/features/tasks/execute/resolveTask.ts | 44 +-- .../tasks/execute/selectAndExecute.ts | 28 +- src/infra/git/types.ts | 33 +- src/infra/github/pr.ts | 4 +- src/infra/task/clone-base-branch.ts | 88 +++++ src/infra/task/clone-exec.ts | 116 ++++++ src/infra/task/clone-meta.ts | 45 +++ src/infra/task/clone.ts | 309 +++------------- src/infra/task/index.ts | 2 + src/infra/task/instruction.ts | 8 + src/infra/task/mapper.ts | 18 +- src/infra/task/schema.ts | 1 + src/infra/task/types.ts | 22 +- 27 files changed, 1328 insertions(+), 719 deletions(-) create mode 100644 src/app/cli/routing-inputs.ts create mode 100644 src/features/tasks/add/worktree-settings.ts create mode 100644 src/infra/task/clone-base-branch.ts create mode 100644 src/infra/task/clone-exec.ts create mode 100644 src/infra/task/clone-meta.ts create mode 100644 src/infra/task/instruction.ts diff --git a/src/__tests__/addTask.test.ts b/src/__tests__/addTask.test.ts index d5867d5..5db62af 100644 --- a/src/__tests__/addTask.test.ts +++ b/src/__tests__/addTask.test.ts @@ -40,6 +40,11 @@ vi.mock('../features/tasks/execute/selectAndExecute.js', () => ({ vi.mock('../infra/task/index.js', async (importOriginal) => ({ ...(await importOriginal>()), 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 { +function createMockPrReview(overrides: Partial = {}): PrReviewData { return { number: 456, title: 'Fix auth bug', @@ -107,7 +116,7 @@ function createMockPrReview(overrides: Partial = {}): 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); diff --git a/src/__tests__/cli-routing-pr-resolve.test.ts b/src/__tests__/cli-routing-pr-resolve.test.ts index 499bd8b..218822c 100644 --- a/src/__tests__/cli-routing-pr-resolve.test.ts +++ b/src/__tests__/cli-routing-pr-resolve.test.ts @@ -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 { +function createMockPrReview(overrides: Partial = {}): 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; diff --git a/src/__tests__/clone.test.ts b/src/__tests__/clone.test.ts index 172ad96..970f1d4 100644 --- a/src/__tests__/clone.test.ts +++ b/src/__tests__/clone.test.ts @@ -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 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); diff --git a/src/__tests__/github-pr.test.ts b/src/__tests__/github-pr.test.ts index b930c86..a595839 100644 --- a/src/__tests__/github-pr.test.ts +++ b/src/__tests__/github-pr.test.ts @@ -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([ diff --git a/src/__tests__/pipelineExecution.test.ts b/src/__tests__/pipelineExecution.test.ts index 4955b11..2d2b2c9 100644 --- a/src/__tests__/pipelineExecution.test.ts +++ b/src/__tests__/pipelineExecution.test.ts @@ -1,17 +1,10 @@ -/** - * 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', () => ({ - fetchIssue: mockFetchIssue, - formatIssueAsTask: vi.fn((issue: { title: string; body: string; number: number }) => +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockFetchIssue = vi.fn(); +const mockCheckGhCli = vi.fn().mockReturnValue({ available: true }); +vi.mock('../infra/github/issue.js', () => ({ + fetchIssue: mockFetchIssue, + formatIssueAsTask: vi.fn((issue: { title: string; body: string; number: number }) => `## GitHub Issue #${issue.number}: ${issue.title}\n\n${issue.body}` ), checkGhCli: mockCheckGhCli, @@ -44,34 +37,31 @@ vi.mock('../features/tasks/index.js', () => ({ })); const mockResolveConfigValues = vi.fn(); -vi.mock('../infra/config/index.js', () => ({ - resolveConfigValues: mockResolveConfigValues, - 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(), - success: vi.fn(), - status: vi.fn(), - blankLine: vi.fn(), - header: vi.fn(), - section: vi.fn(), - 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>(); -const mockBuildSlackRunSummary = vi.fn<(params: unknown) => string>(() => 'TAKT Run Summary'); -vi.mock('../shared/utils/index.js', async (importOriginal) => ({ +vi.mock('../infra/config/index.js', () => ({ + resolveConfigValues: mockResolveConfigValues, + resolveConfigValue: vi.fn(() => undefined), +})); + +const mockExecFileSync = vi.fn(); +vi.mock('node:child_process', () => ({ + execFileSync: mockExecFileSync, +})); + +vi.mock('../shared/ui/index.js', () => ({ + info: vi.fn(), + error: vi.fn(), + success: vi.fn(), + status: vi.fn(), + blankLine: vi.fn(), + header: vi.fn(), + section: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), +})); +const mockGetSlackWebhookUrl = vi.fn<() => string | undefined>(() => undefined); +const mockSendSlackNotification = vi.fn<(url: string, message: string) => Promise>(); +const mockBuildSlackRunSummary = vi.fn<(params: unknown) => string>(() => 'TAKT Run Summary'); +vi.mock('../shared/utils/index.js', async (importOriginal) => ({ ...(await importOriginal>()), createLogger: () => ({ info: vi.fn(), @@ -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') { @@ -301,16 +291,14 @@ describe('executePipeline', () => { cwd: '/tmp/test', }); - // Then - expect(exitCode).toBe(0); - expect(mockCreatePullRequest).toHaveBeenCalledWith( - '/tmp/test', - expect.objectContaining({ - branch: 'fix/my-branch', - base: 'develop', - }), - ); - }); + // Then + expect(exitCode).toBe(0); + 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 () => { mockExecuteTask.mockResolvedValueOnce(true); @@ -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', @@ -617,19 +611,41 @@ describe('executePipeline', () => { }); }); - it('should return exit code 4 when worktree creation fails', async () => { - mockConfirmAndCreateWorktree.mockRejectedValueOnce(new Error('Failed to create worktree')); - - const exitCode = await executePipeline({ + it('should return exit code 4 when worktree creation fails', async () => { + mockConfirmAndCreateWorktree.mockRejectedValueOnce(new Error('Failed to create worktree')); + + const exitCode = await executePipeline({ task: 'Fix the bug', piece: 'default', autoPr: false, cwd: '/tmp/test', createWorktree: true, }); - - expect(exitCode).toBe(4); - }); + + 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({ @@ -672,14 +688,14 @@ describe('executePipeline', () => { expect(mockPushBranch).toHaveBeenCalledWith('/tmp/test', 'fix/the-bug'); }); - it('should create PR from project cwd when worktree is used with --auto-pr', async () => { - mockConfirmAndCreateWorktree.mockResolvedValueOnce({ - execCwd: '/tmp/test-worktree', - isWorktree: true, - branch: 'fix/the-bug', - baseBranch: 'main', - taskSlug: 'fix-the-bug', - }); + it('should create PR from project cwd when worktree is used with --auto-pr', async () => { + mockConfirmAndCreateWorktree.mockResolvedValueOnce({ + execCwd: '/tmp/test-worktree', + isWorktree: true, + branch: 'fix/the-bug', + baseBranch: 'main', + taskSlug: 'fix-the-bug', + }); mockExecuteTask.mockResolvedValueOnce(true); mockCreatePullRequest.mockReturnValueOnce({ success: true, url: 'https://github.com/test/pr/1' }); @@ -692,15 +708,83 @@ describe('executePipeline', () => { }); expect(exitCode).toBe(0); - expect(mockCreatePullRequest).toHaveBeenCalledWith( - '/tmp/test', - expect.objectContaining({ - branch: 'fix/the-bug', - base: 'main', - }), - ); - }); - }); + expect(mockCreatePullRequest).toHaveBeenCalledWith( + '/tmp/test', + expect.objectContaining({ + branch: 'fix/the-bug', + base: 'main', + }), + ); + }); + + 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 () => { mockExecuteTask.mockResolvedValueOnce(true); @@ -902,11 +986,11 @@ describe('executePipeline', () => { expect(exitCode).toBe(2); }); - it('should checkout PR branch instead of creating new branch', async () => { - mockFetchPrReviewComments.mockReturnValueOnce({ - number: 456, - title: 'Fix auth bug', - body: 'PR description', + it('should checkout PR branch instead of creating new branch', async () => { + mockFetchPrReviewComments.mockReturnValueOnce({ + number: 456, + title: 'Fix auth bug', + body: 'PR description', url: 'https://github.com/org/repo/pull/456', headRefName: 'fix/auth-bug', comments: [{ author: 'reviewer1', body: 'Fix this' }], @@ -931,8 +1015,82 @@ describe('executePipeline', () => { // Should checkout existing PR branch const checkoutPrBranch = mockExecFileSync.mock.calls.find( (call: unknown[]) => call[0] === 'git' && (call[1] as string[])[0] === 'checkout' && (call[1] as string[])[1] === 'fix/auth-bug', - ); - expect(checkoutPrBranch).toBeDefined(); - }); - }); -}); + ); + 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(); + }); + }); +}); diff --git a/src/__tests__/resolveTask.test.ts b/src/__tests__/resolveTask.test.ts index 3f075e4..0f46f08 100644 --- a/src/__tests__/resolveTask.test.ts +++ b/src/__tests__/resolveTask.test.ts @@ -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(); @@ -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, + 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, + 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 | 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, + 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 | 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, + 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, + 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, + 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({ diff --git a/src/__tests__/saveTaskFile.test.ts b/src/__tests__/saveTaskFile.test.ts index 1b2b3a9..5481ba3 100644 --- a/src/__tests__/saveTaskFile.test.ts +++ b/src/__tests__/saveTaskFile.test.ts @@ -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>()), 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>()), + 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'); + }); }); diff --git a/src/__tests__/task-schema.test.ts b/src/__tests__/task-schema.test.ts index dc2550c..6d9750f 100644 --- a/src/__tests__/task-schema.test.ts +++ b/src/__tests__/task-schema.test.ts @@ -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(); + }); }); diff --git a/src/app/cli/routing-inputs.ts b/src/app/cli/routing-inputs.ts new file mode 100644 index 0000000..1e3f780 --- /dev/null +++ b/src/app/cli/routing-inputs.ts @@ -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, + }; +} diff --git a/src/app/cli/routing.ts b/src/app/cli/routing.ts index f0f6196..e902e30 100644 --- a/src/app/cli/routing.ts +++ b/src/app/cli/routing.ts @@ -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 { const opts = program.opts(); if (!pipelineMode && (opts.autoPr === true || opts.draft === true)) { @@ -135,7 +49,6 @@ export async function executeDefaultAction(task?: string): Promise { 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 { 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 { 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 { } } - // 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 { 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 { }, 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 }); }, diff --git a/src/features/pipeline/execute.ts b/src/features/pipeline/execute.ts index ce9e98b..b9825bd 100644 --- a/src/features/pipeline/execute.ts +++ b/src/features/pipeline/execute.ts @@ -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 { 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 { const webhookUrl = getSlackWebhookUrl(); if (!webhookUrl) return; diff --git a/src/features/pipeline/steps.ts b/src/features/pipeline/steps.ts index 5ee73a5..d836828 100644 --- a/src/features/pipeline/steps.ts +++ b/src/features/pipeline/steps.ts @@ -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 { 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( label: string, fetch: (provider: ReturnType) => 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, pipelineConfig: PipelineConfig | undefined, prBranch?: string, + prBaseBranch?: string, ): Promise { 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, ): 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, }); diff --git a/src/features/tasks/add/index.ts b/src/features/tasks/add/index.ts index 1c1cccc..47c0316 100644 --- a/src/features/tasks/add/index.ts +++ b/src/features/tasks/add/index.ts @@ -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 = { ...(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 { return [selected]; } -async function promptWorktreeSettings(): Promise { - 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, diff --git a/src/features/tasks/add/worktree-settings.ts b/src/features/tasks/add/worktree-settings.ts new file mode 100644 index 0000000..6965897 --- /dev/null +++ b/src/features/tasks/add/worktree-settings.ts @@ -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 { + 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 { + 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; +} diff --git a/src/features/tasks/execute/resolveTask.ts b/src/features/tasks/execute/resolveTask.ts index 1541def..9449629 100644 --- a/src/features/tasks/execute/resolveTask.ts +++ b/src/features/tasks/execute/resolveTask.ts @@ -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, }), diff --git a/src/features/tasks/execute/selectAndExecute.ts b/src/features/tasks/execute/selectAndExecute.ts index 6cfbec5..9c03f60 100644 --- a/src/features/tasks/execute/selectAndExecute.ts +++ b/src/features/tasks/execute/selectAndExecute.ts @@ -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 { 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...', @@ -63,20 +57,17 @@ export async function confirmAndCreateWorktree( const result = await withProgress( 'Creating clone...', (cloneResult) => `Clone created: ${cloneResult.path} (branch: ${cloneResult.branch})`, - async () => createSharedClone(cwd, { - worktree: true, - taskSlug, - ...(branchOverride ? { branch: branchOverride } : {}), - }), - ); + async () => createSharedClone(cwd, { + worktree: true, + taskSlug, + ...(baseBranchOverride ? { baseBranch: baseBranchOverride } : {}), + ...(branchOverride ? { branch: branchOverride } : {}), + }), + ); 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); diff --git a/src/infra/git/types.ts b/src/infra/git/types.ts index 36ab861..6cf3a0a 100644 --- a/src/infra/git/types.ts +++ b/src/infra/git/types.ts @@ -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; diff --git a/src/infra/github/pr.ts b/src/infra/github/pr.ts index 5876e37..151e941 100644 --- a/src/infra/github/pr.ts +++ b/src/infra/github/pr.ts @@ -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), diff --git a/src/infra/task/clone-base-branch.ts b/src/infra/task/clone-base-branch.ts new file mode 100644 index 0000000..f94030e --- /dev/null +++ b/src/infra/task/clone-base-branch.ts @@ -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 }; + } +} diff --git a/src/infra/task/clone-exec.ts b/src/infra/task/clone-exec.ts new file mode 100644 index 0000000..16f9e48 --- /dev/null +++ b/src/infra/task/clone-exec.ts @@ -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) }); + } + } +} diff --git a/src/infra/task/clone-meta.ts b/src/infra/task/clone-meta.ts new file mode 100644 index 0000000..0badf52 --- /dev/null +++ b/src/infra/task/clone-meta.ts @@ -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; + } +} diff --git a/src/infra/task/clone.ts b/src/infra/task/clone.ts index 89f3207..f7bd9b0 100644 --- a/src/infra/task/clone.ts +++ b/src/infra/task/clone.ts @@ -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/` - * - * 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,53 +127,38 @@ 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 cloneBaseDir = path.resolve(CloneManager.resolveCloneBaseDir(projectDir)); - const resolvedClonePath = path.resolve(meta.clonePath); - if (!resolvedClonePath.startsWith(cloneBaseDir + path.sep)) { - log.error('Refusing to remove clone outside of clone base directory', { - branch, - clonePath: meta.clonePath, - cloneBaseDir, - }); - return; - } - if (fs.existsSync(resolvedClonePath)) { - this.removeClone(resolvedClonePath); - log.info('Orphaned clone cleaned up', { branch, clonePath: resolvedClonePath }); - } - } catch { - // No metadata or parse error — nothing to clean up + 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)) { + log.error('Refusing to remove clone outside of clone base directory', { + branch, + clonePath: meta.clonePath, + cloneBaseDir, + }); + return; + } + if (fs.existsSync(resolvedClonePath)) { + this.removeClone(resolvedClonePath); + log.info('Orphaned clone cleaned up', { branch, clonePath: resolvedClonePath }); } 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); } diff --git a/src/infra/task/index.ts b/src/infra/task/index.ts index 49960d5..1c3b3e4 100644 --- a/src/infra/task/index.ts +++ b/src/infra/task/index.ts @@ -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'; diff --git a/src/infra/task/instruction.ts b/src/infra/task/instruction.ts new file mode 100644 index 0000000..c6bdceb --- /dev/null +++ b/src/infra/task/instruction.ts @@ -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'); +} diff --git a/src/infra/task/mapper.ts b/src/infra/task/mapper.ts index babc38f..76c4906 100644 --- a/src/infra/task/mapper.ts +++ b/src/infra/task/mapper.ts @@ -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, diff --git a/src/infra/task/schema.ts b/src/infra/task/schema.ts index 985e0dc..0575827 100644 --- a/src/infra/task/schema.ts +++ b/src/infra/task/schema.ts @@ -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(), diff --git a/src/infra/task/types.ts b/src/infra/task/types.ts index 4f165bc..dd83d77 100644 --- a/src/infra/task/types.ts +++ b/src/infra/task/types.ts @@ -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;