Skip to content
Open
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
54 changes: 54 additions & 0 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
22 changes: 20 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,9 @@ program
let effectiveCwd = cwd;
let worktreePath: string | null = null;
let worktreeCleanup: (() => void) | null = null;
let getOrchestratorState:
| (() => ReturnType<Orchestrator["getState"]>)
| null = null;

const currentBranch = getCurrentBranch(cwd);
const onGnhfBranch = currentBranch.startsWith("gnhf/");
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -980,6 +997,7 @@ program
...(options.push ? { push: true } : {}),
},
);
getOrchestratorState = () => orchestrator.getState();
let shutdownSignal: NodeJS.Signals | null = null;
let forceShutdownRequested = false;

Expand Down
Loading