From ed068305d7be09cd898625cd851a4525f25fce42 Mon Sep 17 00:00:00 2001 From: Bradley Taylor Date: Mon, 24 Aug 2026 07:42:16 -0700 Subject: [PATCH] feat: worktree setup unconditionally runs pnpm install, breaking zero-dependency repos (closes #430) --- src/lib/worktree.ts | 55 ++++++++++++++++++++++++++++--- tests/lib/worktree.test.ts | 67 ++++++++++++++++++++++++++++++++++---- 2 files changed, 110 insertions(+), 12 deletions(-) diff --git a/src/lib/worktree.ts b/src/lib/worktree.ts index 578edf5..7d41837 100644 --- a/src/lib/worktree.ts +++ b/src/lib/worktree.ts @@ -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'; @@ -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 { @@ -237,17 +274,25 @@ export async function setupWorktree(options: SetupWorktreeOptions): Promise ({ @@ -41,9 +41,13 @@ const mockWriteFile = writeFileSync as jest.MockedFunction 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', () => { @@ -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( @@ -334,7 +338,35 @@ 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 }; @@ -342,8 +374,17 @@ describe('setupWorktree', () => { 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 () => { @@ -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);