Worktree cleanup: three remaining data-loss paths after v2.1.76-77 fixes
Description
Agent worktree cleanup has three remaining data-loss paths in v2.1.78, despite the v2.1.76-77 fixes that correctly added rev-list checking to hasWorktreeChanges. Found via strings extraction from the compiled binary's embedded JS bundle, confirmed with standalone reproduction scripts.
Bug 1: When a worktree already exists (agent retry/resume), createWorktree reads the worktree's current HEAD as the cleanup baseline via readWorktreeHead. If the resumed agent makes no new commits beyond what the previous agent already committed, rev-list HEAD..HEAD = 0 and the worktree is auto-deleted — destroying the first agent's committed work. This is fundamentally a single-agent resume problem, not a concurrency issue.
Bug 2: cleanupStaleAgentWorktrees only excludes the user's interactive worktree session. Agent worktrees live in a local variable inside Agent.call and have no global registry, leaving them exposed to concurrent stale cleanup from other sessions. (Inferred from code analysis; not directly reproduced, as it requires concurrent session timing that is difficult to script reliably.)
Bug 3: Stale cleanup uses git status --porcelain -uno, which hides untracked files. In repos with a remote, both -uno (empty) and rev-list HEAD --not --remotes (empty for a branch created from origin/main) return nothing — allowing deletion of worktrees containing unadded agent output files.
Expected Behavior
- Resumed worktrees should compare against the original base commit (e.g.,
origin/mainat creation time), not the worktree's current HEAD. - Active agent worktrees should be excluded from stale cleanup, regardless of mtime.
- Stale cleanup should detect untracked files before deleting a worktree.
Actual Behavior
- Bug 1:
createWorktreereturnsheadCommit: readWorktreeHead(path)for resumed worktrees. SincereadWorktreeHeadreturns the current HEAD SHA (which includes previous agent commits),hasWorktreeChangescomputesrev-list HEAD..HEAD = 0and auto-deletes the worktree. - Bug 2:
cleanupStaleAgentWorktreeschecksactiveWorktreePath === fullPathbutactiveWorktreePathis only set for the user's interactive worktree, not for agent worktrees. - Bug 3:
git status --porcelain -unoreturns empty for worktrees that have untracked files, andrev-list HEAD --not --remotesreturns empty when the worktree branch was created from a commit that already exists on the remote.
Steps to Reproduce
Bug 1 — Resumed worktree baseline drift (verified, data loss confirmed)
#!/bin/bash
set -euo pipefail
REPO=$(mktemp -d) && cd "$REPO"
git init -b main && git commit --allow-empty -m "initial commit"
# First agent creates worktree and commits work
mkdir -p .claude/worktrees
git worktree add .claude/worktrees/agent-abc12345 -b worktree-agent-abc12345 2>/dev/null
cd .claude/worktrees/agent-abc12345
echo "critical deliverable" > output.txt
git add output.txt && git commit -m "agent: completed task" --no-gpg-sign
cd "$REPO"
# Simulate readWorktreeHead reading current HEAD (the bug — used as headCommit)
RESUMED_HEAD=$(git -C .claude/worktrees/agent-abc12345 rev-parse HEAD)
# hasWorktreeChanges: rev-list HEAD..HEAD = 0, status = clean → false → DELETE
REVCOUNT=$(git -C .claude/worktrees/agent-abc12345 rev-list --count "${RESUMED_HEAD}..HEAD")
echo "rev-list ${RESUMED_HEAD}..HEAD = $REVCOUNT" # prints 0
git worktree remove --force .claude/worktrees/agent-abc12345
git branch -D worktree-agent-abc12345 2>/dev/null
# Committed work is gone
git log --oneline --all | grep -q "agent: completed task" && echo "PASS" || echo "FAIL: DATA LOSS"
rm -rf "$REPO"
Output:
rev-list a8341af..HEAD = 0
FAIL: DATA LOSS
Bug 3 — Untracked files invisible to stale cleanup (verified)
#!/bin/bash
set -euo pipefail
REMOTE=$(mktemp -d) && git init --bare -b main "$REMOTE" >/dev/null 2>&1
REPO=$(mktemp -d) && cd "$REPO"
git init -b main && git remote add origin "$REMOTE"
git commit --allow-empty -m "initial commit" && git push -u origin main 2>/dev/null
mkdir -p .claude/worktrees
git worktree add .claude/worktrees/agent-xyz99999 -b worktree-agent-xyz99999 2>/dev/null
echo "analysis results" > .claude/worktrees/agent-xyz99999/results.json
echo "With -uno: '$(git -C .claude/worktrees/agent-xyz99999 status --porcelain -uno)'"
echo "Without: '$(git -C .claude/worktrees/agent-xyz99999 status --porcelain)'"
echo "rev-list: '$(git -C .claude/worktrees/agent-xyz99999 rev-list --max-count=1 HEAD --not --remotes)'"
rm -rf "$REPO" "$REMOTE"
Output:
With -uno: '' ← invisible to stale cleanup
Without: '?? results.json' ← file exists
rev-list: '' ← no local-only commits
Both guards return empty → worktree deleted → results.json lost.
Root Cause Analysis (decompiled from v2.1.78)
Bug 1: createWorktree — baseline drift on resume
// When worktree already exists:
let existingCommit = await readWorktreeHead(worktreePath);
if (existingCommit) {
return {
worktreePath,
worktreeBranch: branchName,
headCommit: existingCommit, // ← BUG: this is current HEAD, not original base
existed: true
};
}
// For NEW worktrees, headCommit correctly = origin/main SHA
readWorktreeHead resolves the worktree's current branch HEAD SHA, which includes previous agent commits.
hasWorktreeChanges then runs rev-list --count ${headCommit}..HEAD. Since headCommit == HEAD, this is always 0 for resumed worktrees with no new commits.
Bug 2: cleanupStaleAgentWorktrees — no agent registry
let activeWorktreePath = currentWorktreeSession?.worktreePath;
// currentWorktreeSession is set by createWorktreeForSession (interactive -w flag)
// It is NOT set by createAgentWorktree (Agent tool with isolation: "worktree")
// Agent worktrees are stored in a local variable inside Agent.call
Bug 3: cleanupStaleAgentWorktrees — -uno flag
git(["--no-optional-locks", "status", "--porcelain", "-uno"], { cwd: worktreePath })
// ^^^^ suppresses untracked files
Suggested Fixes
These are suggestions based on the decompiled analysis. Open to alternative approaches — happy to discuss or submit a PR if any of these directions look right.
Bug 1: Persist the original base commit
Store the base commit in git's own worktree metadata directory to avoid polluting the working tree:
async function createWorktree(gitRoot, name, options) {
let worktreePath = path.join(worktreesDir(gitRoot), name);
let existingCommit = await readWorktreeHead(worktreePath);
if (existingCommit) {
// Read the persisted ORIGINAL base, not current HEAD
let baseCommit;
let metaDir = path.join(gitRoot, '.git', 'worktrees', branchNameFor(name));
try { baseCommit = (await fs.readFile(path.join(metaDir, 'claude-base-commit'), 'utf-8')).trim(); }
catch { baseCommit = existingCommit; } // fallback for pre-fix worktrees
return { worktreePath, worktreeBranch: branchNameFor(name), headCommit: baseCommit, existed: true };
}
// ... create new worktree, compute baseCommitSha ...
// Persist base commit in git's worktree metadata:
let metaDir = path.join(gitRoot, '.git', 'worktrees', branchNameFor(name));
await fs.writeFile(path.join(metaDir, 'claude-base-commit'), baseCommitSha, 'utf-8');
return { worktreePath, worktreeBranch, headCommit: baseCommitSha, existed: false };
}
Bug 2: Lock file for active agents
// createAgentWorktree: write lock with PID + timestamp
await fs.writeFile(path.join(worktreePath, '.claude-agent-lock'),
JSON.stringify({ pid: process.pid, started: Date.now() }), 'utf-8');
// cleanupStaleAgentWorktrees: check lock before evaluating
try {
let lock = JSON.parse(await fs.readFile(path.join(fullPath, '.claude-agent-lock'), 'utf-8'));
let staleMs = 24 * 60 * 60 * 1000; // 24h safety timeout for PID recycling
if (Date.now() - lock.started < staleMs) {
try { process.kill(lock.pid, 0); continue; } catch {} // process alive → skip
}
// Lock older than 24h or process dead → treat as stale, proceed with normal checks
} catch {} // no lock → proceed
// Agent cleanup (IH): remove lock before hasWorktreeChanges check
try { await fs.unlink(path.join(worktreePath, '.claude-agent-lock')); } catch {}
Bug 3: Include untracked files
- git(["--no-optional-locks", "status", "--porcelain", "-uno"], { cwd: worktreePath })
+ git(["--no-optional-locks", "status", "--porcelain", "-unormal"], { cwd: worktreePath })
Performance note: -unormal scans the working tree for untracked files, which can be slow in large repos. However, agent worktrees are typically small fresh checkouts, so the impact should be bounded.
Is This a Regression?
Partial. Bug 1 has likely existed since worktree resume was introduced. Bugs 2-3 are edge cases in the stale cleanup added in v2.1.76. The v2.1.76-77 fixes correctly addressed the primary hasWorktreeChanges check; these edge cases remain.
Workaround
Add to CLAUDE.md in affected projects:
When working in a worktree (isolation: "worktree"), push your branch before
reporting completion: git push -u origin $(git branch --show-current)
This ensures rev-list HEAD --not --remotes always detects the work, defending against all three bugs.
Environment
- Claude Code version: 2.1.78
- Platform: Anthropic API (Claude Max subscription)
- OS: Linux (Ubuntu, kernel 6.8.0-106-generic, x86-64)
- Terminal: zsh in GNOME Terminal
- Model: claude-opus-4-6 (1M context)
Related Issues
- #27753 — Worktree auto-deleted on exit when work is committed (open/stale; the primary check was fixed in v2.1.76-77, but the resume baseline issue remains)
- #29110 — Spawned agents: bypassPermissions ineffective, worktree data loss (open; broader scope covering multiple agent issues)
- #26725 — Stale worktrees never cleaned up (open; the opposite problem, partially addressed by v2.1.76 stale cleanup)
- #33837 — Agent worktrees created in /tmp cause data loss on reboot (open; separate path involving temp directory placement)
This issue has 4 comments on GitHub. Read the full discussion on GitHub ↗