Skip to content

Stale worktree startup sweep destroys git-ignored content (no --ignored), disagreeing with the daemon reaper's guard on the same sink #12758

Description

@yiliang114

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:

  1. 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).
  2. Companion case with only node_modules/x present → assert removed === 1, so the disposable-roots allowlist stays pinned.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

category/coreCore engine and logicpriority/P2Medium - Moderately impactful, noticeable problemscope/gitGit integration featurestype/bugSomething isn't working as expected

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions