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
This commit is contained in:
nrs 2026-02-22 20:32:36 +09:00 committed by GitHub
parent a5e2badc0b
commit f557db0908
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 246 additions and 18 deletions

View File

@ -27,8 +27,10 @@ vi.mock('../infra/github/pr.js', () => ({
})); }));
const mockExecuteTask = vi.fn(); const mockExecuteTask = vi.fn();
const mockConfirmAndCreateWorktree = vi.fn();
vi.mock('../features/tasks/index.js', () => ({ vi.mock('../features/tasks/index.js', () => ({
executeTask: mockExecuteTask, executeTask: mockExecuteTask,
confirmAndCreateWorktree: mockConfirmAndCreateWorktree,
})); }));
const mockResolveConfigValues = vi.fn(); const mockResolveConfigValues = vi.fn();
@ -485,4 +487,192 @@ describe('executePipeline', () => {
expect(exitCode).toBe(3); 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',
}),
);
});
});
}); });

View File

@ -111,6 +111,7 @@ export async function executeDefaultAction(task?: string): Promise<void> {
cwd: resolvedCwd, cwd: resolvedCwd,
provider: agentOverrides?.provider, provider: agentOverrides?.provider,
model: agentOverrides?.model, model: agentOverrides?.model,
createWorktree: createWorktreeOverride,
}); });
if (exitCode !== 0) { if (exitCode !== 0) {

View File

@ -20,7 +20,7 @@ import {
type GitHubIssue, type GitHubIssue,
} from '../../infra/github/index.js'; } from '../../infra/github/index.js';
import { stageAndCommit, resolveBaseBranch } from '../../infra/task/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 { resolveConfigValues } from '../../infra/config/index.js';
import { info, error, success, status, blankLine } from '../../shared/ui/index.js'; import { info, error, success, status, blankLine } from '../../shared/ui/index.js';
import { createLogger, getErrorMessage } from '../../shared/utils/index.js'; import { createLogger, getErrorMessage } from '../../shared/utils/index.js';
@ -99,6 +99,43 @@ function buildPipelinePrBody(
return buildPrBody(issue ? [issue] : undefined, report); 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<PipelineExecutionOptions, 'createWorktree' | 'skipGit' | 'branch' | 'issueNumber'>,
pipelineConfig: PipelineConfig | undefined,
): Promise<ExecutionContext> {
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. * Execute the full pipeline.
* *
@ -134,22 +171,15 @@ export async function executePipeline(options: PipelineExecutionOptions): Promis
return EXIT_ISSUE_FETCH_FAILED; return EXIT_ISSUE_FETCH_FAILED;
} }
// --- Step 2: Sync & create branch (skip if --skip-git) --- // --- Step 2: Prepare execution environment ---
let branch: string | undefined; let context: ExecutionContext;
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 { try {
createBranch(cwd, branch); context = await resolveExecutionContext(cwd, task, options, pipelineConfig);
success(`Branch created: ${branch}`);
} catch (err) { } catch (err) {
error(`Failed to create branch: ${getErrorMessage(err)}`); error(`Failed to prepare execution environment: ${getErrorMessage(err)}`);
return EXIT_GIT_OPERATION_FAILED; return EXIT_GIT_OPERATION_FAILED;
} }
} const { execCwd, branch, baseBranch, isWorktree } = context;
// --- Step 3: Run piece --- // --- Step 3: Run piece ---
info(`Running piece: ${piece}`); info(`Running piece: ${piece}`);
@ -161,7 +191,7 @@ export async function executePipeline(options: PipelineExecutionOptions): Promis
const taskSuccess = await executeTask({ const taskSuccess = await executeTask({
task, task,
cwd, cwd: execCwd,
pieceIdentifier: piece, pieceIdentifier: piece,
projectCwd: cwd, projectCwd: cwd,
agentOverrides, agentOverrides,
@ -179,13 +209,18 @@ export async function executePipeline(options: PipelineExecutionOptions): Promis
info('Committing changes...'); info('Committing changes...');
try { try {
const commitHash = stageAndCommit(cwd, commitMessage); const commitHash = stageAndCommit(execCwd, commitMessage);
if (commitHash) { if (commitHash) {
success(`Changes committed: ${commitHash}`); success(`Changes committed: ${commitHash}`);
} else { } else {
info('No changes to commit'); 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}...`); info(`Pushing to origin/${branch}...`);
pushBranch(cwd, branch); pushBranch(cwd, branch);
success(`Pushed to origin/${branch}`); success(`Pushed to origin/${branch}`);

View File

@ -121,6 +121,8 @@ export interface PipelineExecutionOptions {
cwd: string; cwd: string;
provider?: ProviderType; provider?: ProviderType;
model?: string; model?: string;
/** Whether to create worktree for task execution */
createWorktree?: boolean | undefined;
} }
export interface WorktreeConfirmationResult { export interface WorktreeConfirmationResult {