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:
parent
a5e2badc0b
commit
f557db0908
@ -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',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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}`);
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user