diff --git a/src/main/ipc/workspace-cleanup-broad-scan.test.ts b/src/main/ipc/workspace-cleanup-broad-scan.test.ts index ab65f6faa22..a5d19d26f7e 100644 --- a/src/main/ipc/workspace-cleanup-broad-scan.test.ts +++ b/src/main/ipc/workspace-cleanup-broad-scan.test.ts @@ -132,6 +132,16 @@ function makeStore(repos: Repo[] = [REPO], allMeta: Record } as unknown as Store } +/** `repo-1::/repo-old` is in the shared fixture map, so pinning it needs a getWorktreeMeta override. */ +function makePinnedStore(): Store { + const pinned = makeWorktreeMeta({ isPinned: true, lastActivityAt: NOW - 40 * DAY_MS }) + return { + ...makeStore(), + getWorktreeMeta: (worktreeId: string) => + worktreeId === 'repo-1::/repo-old' ? pinned : META_BY_WORKTREE_ID[worktreeId] + } as Store +} + describe('workspace cleanup broad scan opt-in', () => { beforeEach(() => { vi.useFakeTimers() @@ -230,6 +240,34 @@ describe('workspace cleanup broad scan opt-in', () => { ) }) + it('skips git for a pinned workspace on a broad scan, which is the cost saving', async () => { + const store = makePinnedStore() + + await scanWorkspaceCleanup(store, { includeAllWorkspaces: true }) + + expect(getStatusMock).not.toHaveBeenCalledWith( + expect.stringContaining('/repo-old'), + expect.anything() + ) + }) + + it('reads git for a pinned workspace on a targeted preflight, which decides force-removal', async () => { + // Why this matters: `pinned` is not a queue blocker, so a pinned idle workspace is + // hand-selectable today, and removal forces whenever git is unknown. Skipping the + // forced read meant it could be force-deleted with no evidence ever obtained. + const worktreeId = 'repo-1::/repo-old' + + const result = await scanWorkspaceCleanup(makePinnedStore(), { worktreeIds: [worktreeId] }) + + expect(getStatusMock).toHaveBeenCalledWith( + expect.stringContaining('/repo-old'), + expect.anything() + ) + const pinned = result.candidates.find((candidate) => candidate.worktreeId === worktreeId) + expect(pinned?.blockers).toContain('pinned') + expect(pinned?.git.checkedAt).not.toBeNull() + }) + it('keeps the legacy suggestion-only projection when the flag is absent', async () => { const result = await scanWorkspaceCleanup(makeStore()) diff --git a/src/main/ipc/workspace-cleanup-candidate.ts b/src/main/ipc/workspace-cleanup-candidate.ts index 58c56316acc..72f89161690 100644 --- a/src/main/ipc/workspace-cleanup-candidate.ts +++ b/src/main/ipc/workspace-cleanup-candidate.ts @@ -192,11 +192,16 @@ function shouldReadWorkspaceCleanupGitEvidence(args: { if ((skipGit && !forceGitCheck) || repoIsFolder || worktree.isMainWorktree) { return false } - if ( - blockers.includes('pinned') || - blockers.includes('main-worktree') || - blockers.includes('folder-repo') - ) { + // Why pinned sits with the cost skips and not the refusals: a pinned workspace is + // still queueable (`pinned` is not a queue blocker), so it can reach removal -- and + // removal forces whenever git is unknown. Skipping its git read on a broad scan is a + // fair saving; skipping it on the confirm-time forced read meant deleting with no + // evidence ever obtained. main-worktree and folder-repo stay unconditional: those are + // refused before removal, so reading git for them is pure cost. + if (blockers.includes('pinned') && !forceGitCheck) { + return false + } + if (blockers.includes('main-worktree') || blockers.includes('folder-repo')) { return false }