diff --git a/src/cli.test.ts b/src/cli.test.ts index c7dd8cf5..9cc2127c 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -3214,6 +3214,60 @@ describe("cli", () => { expect(removeWorktree).not.toHaveBeenCalled(); }); + it("does not remove a worktree with commits when exit handler fires before preservation block", async () => { + const removeWorktree = vi.fn(); + const exitHandlers: (() => void)[] = []; + const processOnSpy = vi.spyOn(process, "on"); + processOnSpy.mockImplementation(((event: string, handler: () => void) => { + if (event === "exit") { + exitHandlers.push(handler); + } + return process; + }) as typeof process.on); + + try { + await runCliWithMocks( + ["ship it", "--worktree"], + { + agent: "claude", + agentPathOverride: {}, + agentArgsOverride: {}, + acpRegistryOverrides: {}, + maxConsecutiveFailures: 3, + preventSleep: false, + }, + { + removeWorktree, + orchestratorGetState: vi.fn(() => ({ + status: "completed" as const, + gracefulStopRequested: false, + currentIteration: 2, + totalInputTokens: 0, + totalOutputTokens: 0, + commitCount: 3, + iterations: [], + successCount: 2, + failCount: 0, + consecutiveFailures: 0, + startTime: new Date("2026-01-01T00:00:00Z"), + waitingUntil: null, + lastMessage: null, + })), + }, + ); + + // Simulate what happens on force-shutdown: exit handlers fire + // before the normal preservation block nulls out worktreeCleanup + for (const handler of exitHandlers) { + handler(); + } + + expect(removeWorktree).not.toHaveBeenCalled(); + } finally { + processOnSpy.mockRestore(); + } + }); + it("resumes a preserved suffixed worktree instead of creating another one", async () => { const tempDir = mkdtempSync(join(tmpdir(), "gnhf-cli-worktree-resume-")); const repoRoot = join(tempDir, "repo"); diff --git a/src/cli.ts b/src/cli.ts index 4089d1d3..3daa9384 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -695,6 +695,9 @@ program let effectiveCwd = cwd; let worktreePath: string | null = null; let worktreeCleanup: (() => void) | null = null; + let getOrchestratorState: + | (() => ReturnType) + | null = null; const currentBranch = getCurrentBranch(cwd); const onGnhfBranch = currentBranch.startsWith("gnhf/"); @@ -765,11 +768,25 @@ program // Ensure worktree cleanup runs even if die() or process.exit() is // called before reaching the normal cleanup block (e.g. orchestrator // crash to .catch to die to process.exit(1)). + // However, preserve worktrees that already have commits — the + // normal preservation block (worktreeCleanup = null) may not have + // run yet when force-shutdown or timeout triggers process.exit(). const exitCleanup = worktreeCleanup; process.on("exit", () => { - if (worktreeCleanup === exitCleanup) { - exitCleanup(); + if (worktreeCleanup !== exitCleanup) return; + try { + const state = getOrchestratorState?.(); + if ( + state && + (state.commitCount > 0 || state.hasPendingCommitFailure) + ) { + return; + } + } catch { + // Orchestrator not yet created or already torn down — safe to + // clean up since no iteration could have committed anything. } + exitCleanup(); }); } } else if (options.currentBranch) { @@ -980,6 +997,7 @@ program ...(options.push ? { push: true } : {}), }, ); + getOrchestratorState = () => orchestrator.getState(); let shutdownSignal: NodeJS.Signals | null = null; let forceShutdownRequested = false;