What happens
cleanupStaleAgentWorktrees (packages/core/src/services/worktreeCleanup.ts) gates removeUserWorktree(slug, { deleteBranch: true }) — i.e. git worktree remove <path> --force (gitWorktreeService.ts:1285) plus an fs.rm(recursive, force) fallback — on hasUncommittedChanges, which runs:
git --no-optional-locks status --porcelain --untracked-files=normal
No --ignored. Git does not report ignored paths in that mode, so a worktree whose only content is ignored reads as clean and is then destroyed unrecoverably. This runs unattended at every CLI startup.
The same product already has the opposite policy on the same sink
checkoutHasWork in packages/cli/src/serve/server/worktree-orphan-cleanup.ts:75-104 guards the daemon's orphan reaper over the same removeUserWorktree(...) sink and runs --untracked-files=normal --ignored=matching, counting every ignored entry as work unless its first path segment is in DISPOSABLE_IGNORED_ROOTS = new Set(['node_modules', 'dist', 'coverage']) (line 61), with .qwen-session exempt by name. The rationale is in that constant's own JSDoc (worktree-orphan-cleanup.ts:54-60): "every other ignored entry (agent artifacts like .qwen/pr-drafts/) still counts as work".
Two reapers in one product disagree, and the startup one is the destructive side.
Reproduction (measured during review of PR #12739, real git, A/B against the merge-base copy of worktreeCleanup.ts)
Fixture: repo whose .gitignore contains secret.env; createUserWorktree('agent-aabbccd'); write only secret.env (AWS_KEY=x); age the directory past the 30-day cutoff.
Second fixture: .gitignore = .qwen/*, only .qwen/pr-drafts/feature.md present → removed 1 on both arms. Control (only ignored node_modules/x/i.js) → removed 1 on both arms, which is the disposable-roots case any fix must keep reaping.
Realistic content in that state: .env with credentials, .qwen/pr-drafts/feature.md (this repo's own .gitignore has .qwen/*), .qwen/investigations/, a kept *.log.
Why this is a separate issue and not part of #12739
#12739 fixes the untracked-files half of the same defect (#12735) and strictly narrows the destroyed set; the ignored-path blindness predates it. The correct fix is not local to core:
- The two guards sit on one sink and must not drift again, so the predicate belongs in one place (e.g. exported from
gitWorktreeService.ts) with checkoutHasWork delegating to it. That crosses packages/core and packages/cli.
worktree-orphan-cleanup.ts currently has no dedicated test file (grep -rln "worktree-orphan-cleanup" packages/cli/src --include=*.test.ts → no matches), so the delegation needs new coverage on the cli side too.
- It changes what an unattended startup sweep preserves, trading data loss for disk growth — a product call, not a mechanical one.
#12739 only corrected its new code comment and the docs row so neither documents the blindness as complete protection.
Constraints for whoever implements it
- Keep the allowlist identical to
DISPOSABLE_IGNORED_ROOTS (node_modules, dist, coverage). A divergent core-side copy re-creates the disagreement between the two guards on the same sink; adding --ignored=matching with no exemption pins every worktree where an agent ran npm install or a build.
- Exempt
.qwen-session by name, mirroring the daemon guard's if (entry === WORKTREE_SESSION_FILE) return false;.
- Ignore-rule-free listings are large:
packages/core/src/skills/bundled/review/DESIGN.md:471 measured 3 957 paths on a healthy review worktree of this repo once ignore rules are not applied. The guard must not become blind to ignore rules wholesale, and the added output volume is worth bounding.
- Any added argv token must stay after the
status subcommand and must not be caller-derived (packages/core/src/utils/load-simple-git.ts:41-49).
- Keep the fail-closed direction: a read error preserves the worktree.
Acceptance
In packages/core/src/services/worktreeCleanup.test.ts:
- Commit a
.gitignore containing secret.env, createUserWorktree('agent-aabbccd'), write only secret.env, age past the cutoff → assert removed === 0 and fs.existsSync(path.join(wtPath, 'secret.env')). Red today (removed === 1, file gone).
- Companion case with only
node_modules/x present → assert removed === 1, so the disposable-roots allowlist stays pinned.
What happens
cleanupStaleAgentWorktrees(packages/core/src/services/worktreeCleanup.ts) gatesremoveUserWorktree(slug, { deleteBranch: true })— i.e.git worktree remove <path> --force(gitWorktreeService.ts:1285) plus anfs.rm(recursive, force)fallback — onhasUncommittedChanges, which runs:No
--ignored. Git does not report ignored paths in that mode, so a worktree whose only content is ignored reads as clean and is then destroyed unrecoverably. This runs unattended at every CLI startup.The same product already has the opposite policy on the same sink
checkoutHasWorkinpackages/cli/src/serve/server/worktree-orphan-cleanup.ts:75-104guards the daemon's orphan reaper over the sameremoveUserWorktree(...)sink and runs--untracked-files=normal --ignored=matching, counting every ignored entry as work unless its first path segment is inDISPOSABLE_IGNORED_ROOTS = new Set(['node_modules', 'dist', 'coverage'])(line 61), with.qwen-sessionexempt by name. The rationale is in that constant's own JSDoc (worktree-orphan-cleanup.ts:54-60): "every other ignored entry (agent artifacts like.qwen/pr-drafts/) still counts as work".Two reapers in one product disagree, and the startup one is the destructive side.
Reproduction (measured during review of PR #12739, real git, A/B against the merge-base copy of
worktreeCleanup.ts)Fixture: repo whose
.gitignorecontainssecret.env;createUserWorktree('agent-aabbccd'); write onlysecret.env(AWS_KEY=x); age the directory past the 30-day cutoff.git status --porcelain --untracked-files=normalinside the worktree → emptyhasUncommittedChanges→falseremoved 1,secretFileSurvives false(identical on the base arm — not a regression introduced by fix(core): keep stale-worktree sweep from deleting untracked files #12739)Second fixture:
.gitignore=.qwen/*, only.qwen/pr-drafts/feature.mdpresent →removed 1on both arms. Control (only ignorednode_modules/x/i.js) →removed 1on both arms, which is the disposable-roots case any fix must keep reaping.Realistic content in that state:
.envwith credentials,.qwen/pr-drafts/feature.md(this repo's own.gitignorehas.qwen/*),.qwen/investigations/, a kept*.log.Why this is a separate issue and not part of #12739
#12739 fixes the untracked-files half of the same defect (#12735) and strictly narrows the destroyed set; the ignored-path blindness predates it. The correct fix is not local to core:
gitWorktreeService.ts) withcheckoutHasWorkdelegating to it. That crossespackages/coreandpackages/cli.worktree-orphan-cleanup.tscurrently has no dedicated test file (grep -rln "worktree-orphan-cleanup" packages/cli/src --include=*.test.ts→ no matches), so the delegation needs new coverage on the cli side too.#12739 only corrected its new code comment and the docs row so neither documents the blindness as complete protection.
Constraints for whoever implements it
DISPOSABLE_IGNORED_ROOTS(node_modules,dist,coverage). A divergent core-side copy re-creates the disagreement between the two guards on the same sink; adding--ignored=matchingwith no exemption pins every worktree where an agent rannpm installor a build..qwen-sessionby name, mirroring the daemon guard'sif (entry === WORKTREE_SESSION_FILE) return false;.packages/core/src/skills/bundled/review/DESIGN.md:471measured 3 957 paths on a healthy review worktree of this repo once ignore rules are not applied. The guard must not become blind to ignore rules wholesale, and the added output volume is worth bounding.statussubcommand and must not be caller-derived (packages/core/src/utils/load-simple-git.ts:41-49).Acceptance
In
packages/core/src/services/worktreeCleanup.test.ts:.gitignorecontainingsecret.env,createUserWorktree('agent-aabbccd'), write onlysecret.env, age past the cutoff → assertremoved === 0andfs.existsSync(path.join(wtPath, 'secret.env')). Red today (removed === 1, file gone).node_modules/xpresent → assertremoved === 1, so the disposable-roots allowlist stays pinned.