From f557db0908b0a75d3477e3701c362683886f5f32 Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Sun, 22 Feb 2026 20:32:36 +0900 Subject: [PATCH] feat: support --create-worktree in pipeline mode (#361) Pipeline mode previously ignored the --create-worktree option. Now when --create-worktree yes is specified with --pipeline, a worktree is created and the agent executes in the isolated directory. - Add createWorktree field to PipelineExecutionOptions - Pass createWorktreeOverride from routing to executePipeline - Use confirmAndCreateWorktree when createWorktree is true - Execute task in worktree directory (execCwd) instead of project cwd --- src/__tests__/pipelineExecution.test.ts | 190 ++++++++++++++++++++++++ src/app/cli/routing.ts | 1 + src/features/pipeline/execute.ts | 71 ++++++--- src/features/tasks/execute/types.ts | 2 + 4 files changed, 246 insertions(+), 18 deletions(-) diff --git a/src/__tests__/pipelineExecution.test.ts b/src/__tests__/pipelineExecution.test.ts index fba1452..6209ff8 100644 --- a/src/__tests__/pipelineExecution.test.ts +++ b/src/__tests__/pipelineExecution.test.ts @@ -27,8 +27,10 @@ vi.mock('../infra/github/pr.js', () => ({ })); const mockExecuteTask = vi.fn(); +const mockConfirmAndCreateWorktree = vi.fn(); vi.mock('../features/tasks/index.js', () => ({ executeTask: mockExecuteTask, + confirmAndCreateWorktree: mockConfirmAndCreateWorktree, })); const mockResolveConfigValues = vi.fn(); @@ -485,4 +487,192 @@ describe('executePipeline', () => { expect(exitCode).toBe(3); }); }); + + describe('--create-worktree', () => { + it('should create worktree and execute task in worktree directory when createWorktree is true', async () => { + mockConfirmAndCreateWorktree.mockResolvedValueOnce({ + execCwd: '/tmp/test-worktree', + isWorktree: true, + branch: 'fix/the-bug', + baseBranch: 'main', + taskSlug: 'fix-the-bug', + }); + mockExecuteTask.mockResolvedValueOnce(true); + + const exitCode = await executePipeline({ + task: 'Fix the bug', + piece: 'default', + autoPr: false, + cwd: '/tmp/test', + createWorktree: true, + }); + + expect(exitCode).toBe(0); + expect(mockConfirmAndCreateWorktree).toHaveBeenCalledWith('/tmp/test', 'Fix the bug', true); + expect(mockExecuteTask).toHaveBeenCalledWith({ + task: 'Fix the bug', + cwd: '/tmp/test-worktree', + pieceIdentifier: 'default', + projectCwd: '/tmp/test', + agentOverrides: undefined, + }); + }); + + it('should not create worktree when createWorktree is false', async () => { + mockExecuteTask.mockResolvedValueOnce(true); + + const exitCode = await executePipeline({ + task: 'Fix the bug', + piece: 'default', + autoPr: false, + cwd: '/tmp/test', + createWorktree: false, + }); + + expect(exitCode).toBe(0); + expect(mockConfirmAndCreateWorktree).not.toHaveBeenCalled(); + expect(mockExecuteTask).toHaveBeenCalledWith({ + task: 'Fix the bug', + cwd: '/tmp/test', + pieceIdentifier: 'default', + projectCwd: '/tmp/test', + agentOverrides: undefined, + }); + }); + + it('should use original cwd when createWorktree is undefined', async () => { + mockExecuteTask.mockResolvedValueOnce(true); + + const exitCode = await executePipeline({ + task: 'Fix the bug', + piece: 'default', + autoPr: false, + cwd: '/tmp/test', + }); + + expect(exitCode).toBe(0); + expect(mockConfirmAndCreateWorktree).not.toHaveBeenCalled(); + expect(mockExecuteTask).toHaveBeenCalledWith({ + task: 'Fix the bug', + cwd: '/tmp/test', + pieceIdentifier: 'default', + projectCwd: '/tmp/test', + agentOverrides: undefined, + }); + }); + + it('should pass provider/model overrides when worktree is created', async () => { + mockConfirmAndCreateWorktree.mockResolvedValueOnce({ + execCwd: '/tmp/test-worktree', + isWorktree: true, + branch: 'fix/the-bug', + baseBranch: 'main', + taskSlug: 'fix-the-bug', + }); + mockExecuteTask.mockResolvedValueOnce(true); + + const exitCode = await executePipeline({ + task: 'Fix the bug', + piece: 'default', + autoPr: false, + cwd: '/tmp/test', + createWorktree: true, + provider: 'codex', + model: 'codex-model', + }); + + expect(exitCode).toBe(0); + expect(mockExecuteTask).toHaveBeenCalledWith({ + task: 'Fix the bug', + cwd: '/tmp/test-worktree', + pieceIdentifier: 'default', + projectCwd: '/tmp/test', + agentOverrides: { provider: 'codex', model: 'codex-model' }, + }); + }); + + 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); + }); + + it('should commit in worktree and push via clone→project→origin', async () => { + mockConfirmAndCreateWorktree.mockResolvedValueOnce({ + execCwd: '/tmp/test-worktree', + isWorktree: true, + branch: 'fix/the-bug', + baseBranch: 'main', + taskSlug: 'fix-the-bug', + }); + mockExecuteTask.mockResolvedValueOnce(true); + + const exitCode = await executePipeline({ + task: 'Fix the bug', + piece: 'default', + autoPr: false, + cwd: '/tmp/test', + createWorktree: true, + }); + + expect(exitCode).toBe(0); + + // Commit should happen in worktree (execCwd), not project cwd + const addCall = mockExecFileSync.mock.calls.find( + (call: unknown[]) => call[0] === 'git' && (call[1] as string[])[0] === 'add', + ); + expect(addCall).toBeDefined(); + expect((addCall![2] as { cwd: string }).cwd).toBe('/tmp/test-worktree'); + + // Clone→project push: git push /tmp/test HEAD from worktree + const pushToProjectCall = mockExecFileSync.mock.calls.find( + (call: unknown[]) => + call[0] === 'git' && + (call[1] as string[])[0] === 'push' && + (call[1] as string[])[1] === '/tmp/test', + ); + expect(pushToProjectCall).toBeDefined(); + expect((pushToProjectCall![2] as { cwd: string }).cwd).toBe('/tmp/test-worktree'); + + // Project→origin push + 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', + }); + 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: 'main', + }), + ); + }); + }); }); diff --git a/src/app/cli/routing.ts b/src/app/cli/routing.ts index b9b0836..fb47a8c 100644 --- a/src/app/cli/routing.ts +++ b/src/app/cli/routing.ts @@ -111,6 +111,7 @@ export async function executeDefaultAction(task?: string): Promise { cwd: resolvedCwd, provider: agentOverrides?.provider, model: agentOverrides?.model, + createWorktree: createWorktreeOverride, }); if (exitCode !== 0) { diff --git a/src/features/pipeline/execute.ts b/src/features/pipeline/execute.ts index 038c000..19b68ad 100644 --- a/src/features/pipeline/execute.ts +++ b/src/features/pipeline/execute.ts @@ -20,7 +20,7 @@ import { type GitHubIssue, } from '../../infra/github/index.js'; import { stageAndCommit, resolveBaseBranch } from '../../infra/task/index.js'; -import { executeTask, type TaskExecutionOptions, type PipelineExecutionOptions } from '../tasks/index.js'; +import { executeTask, confirmAndCreateWorktree, type TaskExecutionOptions, type PipelineExecutionOptions } from '../tasks/index.js'; import { resolveConfigValues } from '../../infra/config/index.js'; import { info, error, success, status, blankLine } from '../../shared/ui/index.js'; import { createLogger, getErrorMessage } from '../../shared/utils/index.js'; @@ -99,6 +99,43 @@ function buildPipelinePrBody( return buildPrBody(issue ? [issue] : undefined, report); } +interface ExecutionContext { + execCwd: string; + branch?: string; + baseBranch?: string; + isWorktree: boolean; +} + +/** + * Resolve the execution environment for the pipeline. + * Creates a worktree, a branch, or uses the current directory as-is. + */ +async function resolveExecutionContext( + cwd: string, + task: string, + options: Pick, + pipelineConfig: PipelineConfig | undefined, +): Promise { + if (options.createWorktree) { + const result = await confirmAndCreateWorktree(cwd, task, options.createWorktree); + if (result.isWorktree) { + success(`Worktree created: ${result.execCwd}`); + } + return { execCwd: result.execCwd, branch: result.branch, baseBranch: result.baseBranch, isWorktree: result.isWorktree }; + } + + if (options.skipGit) { + return { execCwd: cwd, isWorktree: false }; + } + + const resolved = resolveBaseBranch(cwd); + const branch = options.branch ?? generatePipelineBranchName(pipelineConfig, options.issueNumber); + info(`Creating branch: ${branch}`); + createBranch(cwd, branch); + success(`Branch created: ${branch}`); + return { execCwd: cwd, branch, baseBranch: resolved.branch, isWorktree: false }; +} + /** * Execute the full pipeline. * @@ -134,22 +171,15 @@ export async function executePipeline(options: PipelineExecutionOptions): Promis return EXIT_ISSUE_FETCH_FAILED; } - // --- Step 2: Sync & create branch (skip if --skip-git) --- - let branch: string | undefined; - let baseBranch: string | undefined; - if (!skipGit) { - const resolved = resolveBaseBranch(cwd); - baseBranch = resolved.branch; - branch = options.branch ?? generatePipelineBranchName(pipelineConfig, options.issueNumber); - info(`Creating branch: ${branch}`); - try { - createBranch(cwd, branch); - success(`Branch created: ${branch}`); - } catch (err) { - error(`Failed to create branch: ${getErrorMessage(err)}`); - return EXIT_GIT_OPERATION_FAILED; - } + // --- Step 2: Prepare execution environment --- + let context: ExecutionContext; + try { + context = await resolveExecutionContext(cwd, task, options, pipelineConfig); + } catch (err) { + error(`Failed to prepare execution environment: ${getErrorMessage(err)}`); + return EXIT_GIT_OPERATION_FAILED; } + const { execCwd, branch, baseBranch, isWorktree } = context; // --- Step 3: Run piece --- info(`Running piece: ${piece}`); @@ -161,7 +191,7 @@ export async function executePipeline(options: PipelineExecutionOptions): Promis const taskSuccess = await executeTask({ task, - cwd, + cwd: execCwd, pieceIdentifier: piece, projectCwd: cwd, agentOverrides, @@ -179,13 +209,18 @@ export async function executePipeline(options: PipelineExecutionOptions): Promis info('Committing changes...'); try { - const commitHash = stageAndCommit(cwd, commitMessage); + const commitHash = stageAndCommit(execCwd, commitMessage); if (commitHash) { success(`Changes committed: ${commitHash}`); } else { info('No changes to commit'); } + if (isWorktree) { + // Clone has no origin — push to main project via path, then project pushes to origin + execFileSync('git', ['push', cwd, 'HEAD'], { cwd: execCwd, stdio: 'pipe' }); + } + info(`Pushing to origin/${branch}...`); pushBranch(cwd, branch); success(`Pushed to origin/${branch}`); diff --git a/src/features/tasks/execute/types.ts b/src/features/tasks/execute/types.ts index 6bf92f4..751ca30 100644 --- a/src/features/tasks/execute/types.ts +++ b/src/features/tasks/execute/types.ts @@ -121,6 +121,8 @@ export interface PipelineExecutionOptions { cwd: string; provider?: ProviderType; model?: string; + /** Whether to create worktree for task execution */ + createWorktree?: boolean | undefined; } export interface WorktreeConfirmationResult {