Worktree cleanup: three remaining data-loss paths after v2.1.76-77 fixes

Resolved 💬 4 comments Opened Mar 18, 2026 by userFRM Closed Apr 15, 2026

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/main at 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: createWorktree returns headCommit: readWorktreeHead(path) for resumed worktrees. Since readWorktreeHead returns the current HEAD SHA (which includes previous agent commits), hasWorktreeChanges computes rev-list HEAD..HEAD = 0 and auto-deletes the worktree.
  • Bug 2: cleanupStaleAgentWorktrees checks activeWorktreePath === fullPath but activeWorktreePath is only set for the user's interactive worktree, not for agent worktrees.
  • Bug 3: git status --porcelain -uno returns empty for worktrees that have untracked files, and rev-list HEAD --not --remotes returns 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)

View original on GitHub ↗

This issue has 4 comments on GitHub. Read the full discussion on GitHub ↗