Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 50 additions & 5 deletions src/lib/worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
import { existsSync, mkdirSync, symlinkSync, readlinkSync, unlinkSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs';
import { join, resolve, basename, relative, isAbsolute } from 'node:path';
import { exec, shellQuote } from './shell.js';
import type { ExecResult } from './shell.js';
import { log } from './logger.js';
import { detectPackageManager } from './init-scan.js';
import { isWaitingFeedbackStatus } from './session-state.js';
import type { SessionStatus } from './session.js';

Expand Down Expand Up @@ -51,6 +53,41 @@ export type CleanupWorktreeResult = {
reason?: string;
};

export type WorktreePreconditionPhase = 'dependency-install';

export type WorktreeCommandFailure = ExecResult & {
command: string;
};

/** A setup prerequisite failed before the worktree could enter the build stage. */
export class WorktreePreconditionError extends Error {
readonly phase: WorktreePreconditionPhase = 'dependency-install';
readonly command: string;
readonly exitCode: number;
readonly stdout: string;
readonly stderr: string;
readonly attempts: readonly WorktreeCommandFailure[];

constructor(attempts: WorktreeCommandFailure[]) {
const failure = attempts.at(-1);
if (!failure) {
throw new Error('WorktreePreconditionError requires at least one failed command');
}
const detail = failure.stderr || failure.stdout || 'no command output';
super(
`Dependency installation pre-condition failed at "${failure.command}" `
+ `(exit ${failure.exitCode}): ${detail}. `
+ 'Fix the dependency metadata or install command, or set skip_install: true when installation is intentionally unnecessary.',
);
this.name = 'WorktreePreconditionError';
this.command = failure.command;
this.exitCode = failure.exitCode;
this.stdout = failure.stdout;
this.stderr = failure.stderr;
this.attempts = attempts;
}
}

const ENV_FILES = ['.env', '.env.local', '.env.development', '.env.development.local'];

function isInsidePath(parent: string, child: string): boolean {
Expand Down Expand Up @@ -237,17 +274,25 @@ export async function setupWorktree(options: SetupWorktreeOptions): Promise<Work
// Set COMPOSE_PROJECT_NAME so Docker doesn't use "issue-N" as project name
ensureComposeProjectName(worktreePath, projectDir);

// Install dependencies unless skipped
if (!skipInstall) {
// Install dependencies only for projects with recognized package metadata.
const hasPackageMetadata = detectPackageManager(worktreePath) !== 'unknown';
if (!skipInstall && hasPackageMetadata) {
log.info('Installing dependencies in worktree...');
const installResult = exec('pnpm install --frozen-lockfile', { cwd: worktreePath });
const frozenCommand = 'pnpm install --frozen-lockfile';
const installResult = exec(frozenCommand, { cwd: worktreePath });
if (installResult.exitCode !== 0) {
// Fall back to regular install
const fallback = exec('pnpm install', { cwd: worktreePath });
const fallbackCommand = 'pnpm install';
const fallback = exec(fallbackCommand, { cwd: worktreePath });
if (fallback.exitCode !== 0) {
throw new Error(`Dependency installation failed (exit ${fallback.exitCode})`);
throw new WorktreePreconditionError([
{ command: frozenCommand, ...installResult },
{ command: fallbackCommand, ...fallback },
]);
}
}
} else if (!skipInstall) {
log.info('Skipping dependency installation: no package.json or recognized lockfile found at worktree root');
}

// Run custom setup command (e.g., Python venv, Ruby bundler, Go modules)
Expand Down
67 changes: 60 additions & 7 deletions tests/lib/worktree.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { setupWorktree, cleanupWorktree } from '../../src/lib/worktree';
import { WorktreePreconditionError, setupWorktree, cleanupWorktree } from '../../src/lib/worktree';

// Mock dependencies
jest.mock('../../src/lib/shell', () => ({
Expand Down Expand Up @@ -41,9 +41,13 @@ const mockWriteFile = writeFileSync as jest.MockedFunction<typeof writeFileSync>

beforeEach(() => {
jest.clearAllMocks();
// Default: commands succeed, paths don't exist
// Default: commands succeed and the synthetic worktree is a Node project.
mockExec.mockReturnValue({ stdout: '', stderr: '', exitCode: 0 });
mockExists.mockReturnValue(false);
mockExists.mockImplementation((path) => (
typeof path === 'string'
&& path.includes('/.worktrees/')
&& path.endsWith('/package.json')
));
});

describe('setupWorktree', () => {
Expand Down Expand Up @@ -325,7 +329,7 @@ describe('setupWorktree', () => {
);
});

test('runs pnpm install unless skipInstall is true', async () => {
test('runs pnpm install when package.json exists at the worktree root', async () => {
await setupWorktree(baseOptions);

expect(mockExec).toHaveBeenCalledWith(
Expand All @@ -334,16 +338,53 @@ describe('setupWorktree', () => {
);
});

test('fails when both dependency installation attempts fail', async () => {
test('runs pnpm install when a recognized lockfile exists without package.json', async () => {
mockExists.mockImplementation((path) => (
typeof path === 'string'
&& path.endsWith('/pnpm-lock.yaml')
));

await setupWorktree(baseOptions);

expect(mockExec).toHaveBeenCalledWith(
'pnpm install --frozen-lockfile',
expect.anything(),
);
});

test('skips pnpm install when no package metadata exists at the worktree root', async () => {
mockExists.mockReturnValue(false);

await setupWorktree(baseOptions);

const installCalls = mockExec.mock.calls.filter(
([command]) => typeof command === 'string' && command.includes('pnpm install'),
);
expect(installCalls).toHaveLength(0);
expect(log.info).toHaveBeenCalledWith(
'Skipping dependency installation: no package.json or recognized lockfile found at worktree root',
);
});

test('exposes a typed precondition error when both dependency installation attempts fail', async () => {
mockExec.mockImplementation((cmd: string) => {
if (cmd.includes('pnpm install')) {
return { stdout: '', stderr: 'dependency error', exitCode: 1 };
}
return { stdout: '', stderr: '', exitCode: 0 };
});

await expect(setupWorktree(baseOptions))
.rejects.toThrow('Dependency installation failed (exit 1)');
const error = await setupWorktree(baseOptions).catch((cause: unknown) => cause);

expect(error).toBeInstanceOf(WorktreePreconditionError);
expect(error).toMatchObject({
name: 'WorktreePreconditionError',
phase: 'dependency-install',
command: 'pnpm install',
exitCode: 1,
stderr: 'dependency error',
});
expect((error as Error).message).toContain('Fix the dependency metadata or install command');
});

test('runs setup command after dependency install', async () => {
Expand All @@ -355,6 +396,18 @@ describe('setupWorktree', () => {
);
});

test('runs setup command after automatically skipping dependency installation', async () => {
mockExists.mockReturnValue(false);

await setupWorktree({ ...baseOptions, setupCommand: 'python -m venv .venv' });

expect(mockExec).not.toHaveBeenCalledWith('pnpm install --frozen-lockfile', expect.anything());
expect(mockExec).toHaveBeenCalledWith(
'python -m venv .venv',
expect.objectContaining({ cwd: expect.stringContaining('issue-42') }),
);
});

test('skips setup command when not configured', async () => {
await setupWorktree(baseOptions);

Expand Down