[Bug]: Storage cleanup deletes git worktrees nested inside a thread worktree, including uncommitted work#23866

opened12h agobyKkieron

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server

Steps to reproduce

  1. Use a project whose root is a git repo that gitignores a folder where another repo lives, for example /backend/ holding a separate backend clone. This is a common "workspace repo" layout: agent instructions and skills sit at the root, and the product repos are gitignored inside it.
  2. Start a thread in a new worktree.
  3. Inside that thread worktree, have the agent create a worktree of the other repo at the ignored path (git -C <root>/backend worktree add <thread-worktree>/backend -b fix/x), and leave an uncommitted file in it.
  4. Leave the thread idle, with storage cleanup set to worktreeUnchanged: true (or delete the thread with worktreeOnDelete), and run storage cleanup.

A git-only repro of the underlying removal:

sh
1git init -q umb && printf '/backend/\n' > umb/.gitignore && git -C umb add . && git -C umb commit -qm base
2git init -q be && touch be/f && git -C be add . && git -C be commit -qm base
3git -C umb worktree add -q ../task -b task/x
4git -C be worktree add -q ../task/backend -b fix/y
5echo wip > task/backend/wip.txt
6git -C task status --porcelain # empty: backend/ is ignored
7git -C umb worktree remove ../task # succeeds; task/backend/wip.txt is gone
8git -C be worktree list # task/backend is now "prunable"

Expected behavior

Cleanup keeps a worktree that contains another git worktree or repository, the same way it already keeps one that contains a project checkout.

Actual behavior

The nested worktree is deleted along with its uncommitted files, and the other repo is left with a stale (prunable) worktree entry. Its commits survive, because they're stored in the other repo.

Impact

Uncommitted work is lost silently. An umbrella worktree never gets commits of its own, so with worktreeUnchanged on it is cleaned up as soon as the thread is idle and its session has ended.

Cause (in apps/server/src/storageCleanup.ts):

  • localChangesReason relies on git status, which doesn't report ignored paths.
  • containsProjectRoot only protects checkouts of registered T3 projects.
  • removeLinkedWorktree then runs git worktree remove, which deletes ignored nested repos recursively.
  • With worktreeKeepWhen: "tracked-changes", the git clean -ffdx before removal also deletes nested repos (the double -f).

worktreeKeepWhen: "any-local-files" avoids it, but it isn't the default.

Suggested fix: before removing a worktree, refuse if it contains any .git file or folder other than its own at the root, or any path registered in another repo's git worktree list.

Version or commit

0.1.2-nightly.20261022.3541 (source read at 2c8bdf61)

Environment

Linux, git 2.53.0, Node 22.23.2, Claude Code 2.1.308

Logs or stack traces

None; the git-only repro above shows the removal.

Workaround

Keep other repos' worktrees outside T3's worktrees folder, or set worktreeKeepWhen: "any-local-files" for the project.

Synced from GitHub

Hi Kieron,

Was the first sign a backend worktree showing up as prunable in git worktree list?

Every single step here looks routine on its own. git status comes back empty because backend/ is ignored, worktreeUnchanged reads that as nothing to lose, and then git worktree remove, or the git clean -ffdx on the tracked-changes path, takes the nested repo and its uncommitted files with it, all without one warning. Nobody would flag a cleanup like that as risky, which is exactly why the loss is silent and hard to explain after the fact. That kind of quiet deletion is what I spend most of my time on now, being able to trace back what a job saw and why it decided a folder was safe to drop. Refusing whenever there is any .git entry below the root or a path in another repo's worktree list sounds like the right guard to me. How long had umbrella worktrees been getting cleaned up before you dug into storageCleanup.ts?

Sam