Skip to content

Commit 73d9240

Browse files
authored
fix(isolation): complete reports false success when worktree remains on disk (fixes #964) (#1034)
* fix(isolation): complete reports false success when worktree remains on disk (fixes #964) Three changes to prevent ghost worktrees: 1. isolationCompleteCommand now checks result.worktreeRemoved — if the worktree was not actually removed (partial failure), it reports 'Partial' with warnings and counts as failed, not completed. Previously only skippedReason was checked; a destroy that returned successfully but with worktreeRemoved=false would still print 'Completed'. 2. WorktreeProvider.destroy() now runs 'git worktree prune' after removal to clean up stale worktree references that git may keep even after the directory is removed. 3. WorktreeProvider.destroy() adds post-removal verification: after git worktree remove, it checks 'git worktree list --porcelain' to confirm the worktree is actually unregistered. If still registered, worktreeRemoved is set back to false with a descriptive warning. * fix: address CodeRabbit review — ghost worktree prune, partial cleanup callers, accurate messages * test: add regression test for Partial branch in isolation complete Exercises the !result.worktreeRemoved path (without skippedReason) that was flagged as uncovered by CodeRabbit review.
1 parent 28b2582 commit 73d9240

5 files changed

Lines changed: 296 additions & 25 deletions

File tree

packages/cli/src/commands/isolation.test.ts

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ mock.module('@archon/core/db/workflows', () => ({
3636
getActiveWorkflowRunByPath: mockGetActiveWorkflowRunByPath,
3737
}));
3838

39-
const mockRemoveEnvironment = mock(() => Promise.resolve());
39+
const mockRemoveEnvironment = mock(() =>
40+
Promise.resolve({ worktreeRemoved: true, branchDeleted: true, warnings: [] })
41+
);
4042
const mockCleanupMergedWorktrees = mock(() => Promise.resolve({ removed: [], skipped: [] }));
4143

4244
mock.module('@archon/core/services/cleanup-service', () => ({
@@ -136,7 +138,11 @@ describe('isolationCompleteCommand', () => {
136138

137139
it('completes a branch when env is found and all checks pass', async () => {
138140
mockFindActiveByBranchName.mockResolvedValueOnce(mockEnv);
139-
mockRemoveEnvironment.mockResolvedValueOnce(undefined);
141+
mockRemoveEnvironment.mockResolvedValueOnce({
142+
worktreeRemoved: true,
143+
branchDeleted: true,
144+
warnings: [],
145+
});
140146

141147
await isolationCompleteCommand(['feature-branch'], { force: false, deleteRemote: true });
142148

@@ -309,7 +315,11 @@ describe('isolationCompleteCommand', () => {
309315

310316
it('skips PR check with warning when gh CLI is not available', async () => {
311317
mockFindActiveByBranchName.mockResolvedValueOnce(mockEnv);
312-
mockRemoveEnvironment.mockResolvedValueOnce(undefined);
318+
mockRemoveEnvironment.mockResolvedValueOnce({
319+
worktreeRemoved: true,
320+
branchDeleted: true,
321+
warnings: [],
322+
});
313323
mockExecFileAsync.mockImplementation((cmd: string) => {
314324
if (cmd === 'gh') {
315325
const err = Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' });
@@ -335,7 +345,11 @@ describe('isolationCompleteCommand', () => {
335345
id: 'run-abc',
336346
workflow_name: 'implement',
337347
});
338-
mockRemoveEnvironment.mockResolvedValueOnce(undefined);
348+
mockRemoveEnvironment.mockResolvedValueOnce({
349+
worktreeRemoved: true,
350+
branchDeleted: true,
351+
warnings: [],
352+
});
339353

340354
await isolationCompleteCommand(['dirty-branch'], { force: true, deleteRemote: true });
341355

@@ -368,7 +382,7 @@ describe('isolationCompleteCommand', () => {
368382
.mockResolvedValueOnce(null) // not found: branch-2
369383
.mockResolvedValueOnce(mockEnv); // found: branch-3 (will fail)
370384
mockRemoveEnvironment
371-
.mockResolvedValueOnce(undefined) // branch-1 succeeds
385+
.mockResolvedValueOnce({ worktreeRemoved: true, branchDeleted: true, warnings: [] }) // branch-1 succeeds
372386
.mockRejectedValueOnce(new Error('some error')); // branch-3 fails
373387

374388
await isolationCompleteCommand(['branch-1', 'branch-2', 'branch-3'], {
@@ -378,6 +392,59 @@ describe('isolationCompleteCommand', () => {
378392

379393
expect(consoleLogSpy).toHaveBeenCalledWith('\nComplete: 1 completed, 1 failed, 1 not found');
380394
});
395+
it('counts as failed when removeEnvironment returns skippedReason (ghost worktree)', async () => {
396+
mockFindActiveByBranchName.mockResolvedValueOnce(mockEnv);
397+
mockRemoveEnvironment.mockResolvedValueOnce({
398+
worktreeRemoved: false,
399+
branchDeleted: false,
400+
skippedReason: 'has uncommitted changes',
401+
warnings: [],
402+
});
403+
404+
await isolationCompleteCommand(['ghost-branch'], { force: true, deleteRemote: true });
405+
406+
expect(consoleErrorSpy).toHaveBeenCalledWith(
407+
' Blocked: ghost-branch — has uncommitted changes'
408+
);
409+
expect(consoleErrorSpy).toHaveBeenCalledWith(' Use --force to override.');
410+
expect(consoleLogSpy).toHaveBeenCalledWith('\nComplete: 0 completed, 1 failed, 0 not found');
411+
});
412+
413+
it('counts as failed when removeEnvironment returns partial (worktree not removed, branch deleted)', async () => {
414+
mockFindActiveByBranchName.mockResolvedValueOnce(mockEnv);
415+
mockRemoveEnvironment.mockResolvedValueOnce({
416+
worktreeRemoved: false,
417+
branchDeleted: true,
418+
warnings: ['Some warning'],
419+
skippedReason: undefined,
420+
});
421+
422+
await isolationCompleteCommand(['partial-branch'], { force: true, deleteRemote: true });
423+
424+
expect(consoleErrorSpy).toHaveBeenCalledWith(
425+
' Partial: partial-branch — worktree was not removed from disk (branch deleted, DB updated)'
426+
);
427+
expect(consoleErrorSpy).toHaveBeenCalledWith(' ⚠ Some warning');
428+
expect(consoleLogSpy).toHaveBeenCalledWith('\nComplete: 0 completed, 1 failed, 0 not found');
429+
});
430+
431+
it('surfaces warnings from removeEnvironment result', async () => {
432+
mockFindActiveByBranchName.mockResolvedValueOnce(mockEnv);
433+
mockRemoveEnvironment.mockResolvedValueOnce({
434+
worktreeRemoved: true,
435+
branchDeleted: false,
436+
warnings: ["Cannot delete branch 'feature-branch': checked out elsewhere"],
437+
});
438+
439+
await isolationCompleteCommand(['feature-branch'], { force: true, deleteRemote: true });
440+
441+
expect(consoleWarnSpy).toHaveBeenCalledWith(
442+
" Warning: Cannot delete branch 'feature-branch': checked out elsewhere"
443+
);
444+
// Should still count as completed since worktree was removed
445+
expect(consoleLogSpy).toHaveBeenCalledWith(' Completed: feature-branch');
446+
expect(consoleLogSpy).toHaveBeenCalledWith('\nComplete: 1 completed, 0 failed, 0 not found');
447+
});
381448
});
382449

383450
describe('isolationCleanupMergedCommand', () => {

packages/cli/src/commands/isolation.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ import {
1313
getDefaultBranch,
1414
} from '@archon/git';
1515
import { getIsolationProvider } from '@archon/isolation';
16-
import { removeEnvironment } from '@archon/core/services/cleanup-service';
16+
import {
17+
removeEnvironment,
18+
type RemoveEnvironmentResult,
19+
} from '@archon/core/services/cleanup-service';
1720
import {
1821
listEnvironments,
1922
cleanupMergedEnvironments,
@@ -298,12 +301,37 @@ export async function isolationCompleteCommand(
298301
}
299302

300303
try {
301-
await removeEnvironment(env.id, {
304+
const result: RemoveEnvironmentResult = await removeEnvironment(env.id, {
302305
force: options.force,
303306
deleteRemoteBranch: options.deleteRemote ?? true,
304307
});
305-
console.log(` Completed: ${branch}`);
306-
completed++;
308+
309+
// Surface warnings from partial cleanup
310+
for (const warning of result.warnings) {
311+
console.warn(` Warning: ${warning}`);
312+
}
313+
314+
if (result.skippedReason) {
315+
console.error(` Blocked: ${branch}${result.skippedReason}`);
316+
if (result.skippedReason === 'has uncommitted changes') {
317+
console.error(' Use --force to override.');
318+
}
319+
failed++;
320+
} else if (!result.worktreeRemoved) {
321+
const parts: string[] = [];
322+
if (result.branchDeleted) parts.push('branch deleted');
323+
parts.push('DB updated');
324+
console.error(
325+
` Partial: ${branch} — worktree was not removed from disk (${parts.join(', ')})`
326+
);
327+
for (const warning of result.warnings) {
328+
console.error(` ⚠ ${warning}`);
329+
}
330+
failed++;
331+
} else {
332+
console.log(` Completed: ${branch}`);
333+
completed++;
334+
}
307335
} catch (error) {
308336
const err = error as Error;
309337
getLog().warn({ err, branch, envId: env.id }, 'isolation.complete_failed');

packages/core/src/services/cleanup-service.test.ts

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ describe('cleanup-service', () => {
153153

154154
// worktreeExists returns false (default)
155155

156-
await removeEnvironment(envId);
156+
const result = await removeEnvironment(envId);
157157

158158
// Should call destroy with branchName and canonicalRepoPath for cleanup
159159
expect(mockDestroy).toHaveBeenCalledWith('/path/that/does/not/exist', {
@@ -163,6 +163,9 @@ describe('cleanup-service', () => {
163163
});
164164
// Should mark as destroyed
165165
expect(mockUpdateStatus).toHaveBeenCalledWith(envId, 'destroyed');
166+
// Should return success result
167+
expect(result.worktreeRemoved).toBe(true);
168+
expect(result.skippedReason).toBeUndefined();
166169
});
167170

168171
test('handles git worktree remove failure for missing path', async () => {
@@ -316,6 +319,86 @@ describe('cleanup-service', () => {
316319
});
317320
});
318321

322+
test('returns skippedReason when worktree has uncommitted changes without force', async () => {
323+
const envId = 'env-uncommitted';
324+
325+
mockGetById.mockResolvedValueOnce({
326+
id: envId,
327+
codebase_id: 'codebase-123',
328+
workflow_type: 'issue',
329+
workflow_id: '42',
330+
provider: 'worktree',
331+
working_path: '/workspace/worktrees/issue-42',
332+
branch_name: 'issue-42',
333+
status: 'active',
334+
created_at: new Date(),
335+
created_by_platform: 'github',
336+
metadata: {},
337+
});
338+
339+
mockGetCodebase.mockResolvedValueOnce({
340+
id: 'codebase-123',
341+
name: 'test-repo',
342+
default_cwd: '/workspace/repo',
343+
});
344+
345+
// worktreeExists returns true (path exists)
346+
mockWorktreeExists.mockResolvedValueOnce(true);
347+
// hasUncommittedChanges returns true
348+
mockHasUncommittedChanges.mockResolvedValueOnce(true);
349+
350+
const result = await removeEnvironment(envId);
351+
352+
// Should NOT call destroy or mark as destroyed
353+
expect(mockDestroy).not.toHaveBeenCalled();
354+
expect(mockUpdateStatus).not.toHaveBeenCalled();
355+
// Should return skipped result
356+
expect(result.worktreeRemoved).toBe(false);
357+
expect(result.branchDeleted).toBe(false);
358+
expect(result.skippedReason).toBe('has uncommitted changes');
359+
});
360+
361+
test('returns warnings from partial destroy', async () => {
362+
const envId = 'env-partial';
363+
364+
mockGetById.mockResolvedValueOnce({
365+
id: envId,
366+
codebase_id: 'codebase-123',
367+
workflow_type: 'issue',
368+
workflow_id: '42',
369+
provider: 'worktree',
370+
working_path: '/workspace/worktrees/issue-42',
371+
branch_name: 'issue-42',
372+
status: 'active',
373+
created_at: new Date(),
374+
created_by_platform: 'github',
375+
metadata: {},
376+
});
377+
378+
mockGetCodebase.mockResolvedValueOnce({
379+
id: 'codebase-123',
380+
name: 'test-repo',
381+
default_cwd: '/workspace/repo',
382+
});
383+
384+
// worktreeExists returns false (default)
385+
386+
mockDestroy.mockResolvedValueOnce({
387+
worktreeRemoved: true,
388+
branchDeleted: false,
389+
remoteBranchDeleted: null,
390+
directoryClean: true,
391+
warnings: ["Cannot delete branch 'issue-42': checked out elsewhere"],
392+
});
393+
394+
const result = await removeEnvironment(envId);
395+
396+
expect(result.worktreeRemoved).toBe(true);
397+
expect(result.branchDeleted).toBe(false);
398+
expect(result.warnings).toEqual(["Cannot delete branch 'issue-42': checked out elsewhere"]);
399+
expect(result.skippedReason).toBeUndefined();
400+
});
401+
319402
test('re-throws non-directory errors from provider.destroy', async () => {
320403
const envId = 'env-real-error';
321404

0 commit comments

Comments
 (0)