Skip to content

[Bug]: Worktree cleanup never removes a worktree whose branch was renamed, switched, or detached #17813

Open

Description

@tris203

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

Storage cleanup enabled with Remove after N inactive days (also applies to the merged and unchanged rules).

A. Branch renamed or switched inside the worktree

  1. Start a thread in a new worktree and let the first turn finish. The thread records the branch, for example t3code/fix-thing.
  2. In that worktree (the agent or a terminal), run git branch -m fix/thing, or git switch -c fix/thing.
  3. Commit, leave the worktree clean, close its terminals, and let the thread go idle past the inactivity window.
  4. Run cleanup from Settings → Storage.

B. Detached HEAD

  1. Start a thread in a new worktree as above.
  2. Detach HEAD in the worktree, for example git switch --detach origin/main. A colocated Jujutsu repository does this without the user asking: when jj exports refs and the T3 branch is gone, the worktree's HEAD reflog shows export from jj and HEAD is left as a bare commit.
  3. Leave the worktree clean and idle past the inactivity window, then run cleanup.

The steps are derived from the source and from the state of the affected worktrees; I have not replayed them on a fresh dev server.

Expected behavior

The worktree is evaluated against the configured rules. A clean, idle worktree whose commits are all reachable from a branch or remote is removed once it is old enough, and the thread's recorded branch follows a rename or switch made inside its own worktree.

Actual behavior

The worktree is kept on every sweep with the reason "Repository or branch changed.", and it never becomes eligible.

cleanWorktrees compares the checked-out branch with the thread's recorded branch before any rule runs:

const status = yield* git.statusDetailsLocal(worktreePath);
if (!status.isRepo || status.branch !== thread.branch)
return keep("repository or branch changed");

Two things make that permanent:

  • The recorded branch is never reconciled for worktree threads. thread.branch is written at launch, by the one-time temporary-branch rename, by the branch picker, and by the git action. The client's mismatch handling (resolveLocalCheckoutBranchMismatch) returns null whenever the thread has a worktree. After a turn, RunFinalizationService sees the mismatch and returns, which also skips that thread's pull request refresh:
    if (local.refName === null || local.isDefaultRef) return;
    const thread = yield* projections.getThreadShell(threadId);
    if (!thread || thread.branch !== local.refName) return;
  • Detached HEAD reports branch: null, which can never equal the recorded branch.

The check is not purely a bug: removal relies on ProviderTurnStartService recreating the checkout from thread.branch on resume, so removing a worktree that sits on another branch would resume the thread on the wrong one. The gap is that nothing brings the record back in line, so the protection never lifts. The check has no test in storageCleanup.test.ts and is not mentioned in docs/user/project-settings.md.

Observed on one machine: 10 single-thread worktrees kept this way across two projects. 7 were on a detached HEAD (6 detached by jj export, with the recorded branch no longer existing locally or on the remote; 1 left detached after a rebase). 3 were on a renamed or newly created branch. In all 10, git rev-list --count HEAD --not --branches --remotes was 0, so removal would have orphaned no commits, and 8 had no uncommitted changes.

Impact

Minor bug or occasional failure

Version or commit

0.0.46-nightly.20261010.2922; source read at main @ f7664fd

Environment

Linux, desktop server. One affected project is a colocated Jujutsu repository.

Workaround

Remove the worktree manually, or switch it back to the branch the thread recorded if that branch still exists.

Related

A possible direction, for maintainers to judge: record the worktree's current named branch on the thread when a run finishes, and for a detached HEAD allow removal only when every commit reachable from HEAD is also reachable from a branch, remote, or tag. What a resumed thread should do when its recorded branch no longer exists is an open question.

Activity

  1. jesseroldan-sb commented on Oct 28, 2026

    @jesseroldan-sb

    Hit this hard on Windows. Our delegated-task worktrees were created as t3/<id>, and the agents doing the work then switched to their own feature branches (reviewers detached). So StorageCleanup.cleanWorktrees kept every one with "repository or branch changed", even past worktreeAfterDays (8) and on merge/delete. In 11 days that piled up 506 worktrees (~165 GB, 196 with their own node_modules) and filled a 952 GB drive.

    Related, from the trace logs: 132 leftover folders with no .git file make each sweep throw NotFound on stat .git instead of being skipped.

    Our fix was setting Worktree branch naming to custom instructions, so T3 creates the exact branch our agents need. They no longer switch branches, so cleanup works again. The underlying bug still bites anyone whose agent changes branches; #22805 looks like the right fix, and we're happy to test it.

  2. Eduardo-Orsi commented on Nov 3, 2026

    @Eduardo-Orsi

    Same here on 0.1.6-nightly.20261030.3940 (macOS). The agent moves off the t3/* branch in nearly every thread, so on 2026-11-03 18 of my 20 remaining worktrees could never be cleaned. Most had merged PRs, and they were taking up most of about 39 GB. The only one removed automatically had kept its original branch. Threads with subagents also hit "a subagent thread's branch differs from this worktree's branch", so a fix needs to update subagent threads too, not just the parent.

  3. VDrozd-Cyclis commented on Nov 4, 2026

    @VDrozd-Cyclis

    Note

    Written by Claude Opus 5.5 on behalf of Vincent

    Same bug here, with data on the cases #28508 deliberately skips. On my machine, those skipped cases are all of the mismatches.

    6 of 52 worktrees are in this state, using about 10 GB of real disk. Cleanup is set to remove worktrees on merge, 1 day after settling, or after 14 days inactive.

    Case Worktrees How it happened Unpushed commits
    Shared by 12 to 22 threads 3 A coordinator and its subagents moved the worktree on to other branches. One cycled through many, one per pull request. 0
    Detached HEAD 2 One ended on origin/main after finishing. The other ended on its pull request's head. 0 and 1
    Back on t3/<hex> 1 Switched back to the temporary name after the first-turn rename. 0

    I matched these by comparing each thread's recorded branch with the worktree's HEAD and reflog, then reading the guard. I didn't read T3's cleanup report for them. The detached worktree with one unpushed commit is the case the guard should protect: its pull request was closed unmerged, and no branch contains that commit.

    These agents are already told to stay on T3's branch, and they still move. The custom branch naming workaround above doesn't help either, because a shared worktree moves through several branches.

    The #22805 closure asked for maintainer agreement on adopting branch switches and on propagating branch changes across shared worktrees, so these are the open questions:

    1. Branch switches. Should a one-thread worktree adopt a git switch, not only a rename?
    2. Shared worktrees. Should T3 update thread.branch for every thread sharing the worktree at the end of a run? Or should it leave the records alone and let cleanup decide per worktree? The second is smaller, but a resumed thread could land on an older branch.
    3. Detached HEAD. Should cleanup remove a clean, idle worktree when git rev-list --count HEAD --not --branches --remotes is 0, and keep it otherwise? That would keep the one commit above and release the other detached worktree.
    4. t3/<hex> after a rename. Should that switch be adopted too, once the first-turn rename has finished?

    My preference is updating every sharing thread, plus the reachability check for detached HEAD. That keeps the protection for unpushed work and lets the rest go. I'm happy to test a build against these worktrees and report what changes.

  4. tskimmett commented on Nov 5, 2026

    @tskimmett

    Is there some way some people aren't hitting this problem? Seems like a high severity issue.

  5. rieckt commented on Nov 7, 2026

    @rieckt

    Current-main update: #31599 merged on November 5 and added WorktreeBranchSync, which reconciles saved branch metadata for threads sharing a worktree when the server observes a named-branch change. It includes archived threads and rechecks the current branch before dispatch (source at 0f4a1307). This addresses the named-branch mismatch when that status event is observed; I have not verified it in a running client.

    Detached HEAD remains outside that fix: refName === null returns immediately, and cleanup still checks the recorded branch against symbolic-ref HEAD (cleanup guard). Removing that guard alone would leave resume/recreation using the old saved branch, so I have not proposed bypassing it. This narrows which parts of this report still need a fix.

    Investigated with GPT-6.1 Sol via Codex in T3 Code.

  6. rianradeck commented on Nov 10, 2026

    @rianradeck

    Another data point, from a setup that integrates through a branch other than the default one.

    Version: server 0.1.8-nightly.20261105.4234 (Linux). I have not checked whether that build already includes #31599.

    Setup: Storage cleanup has Delete merged worktrees and Delete worktrees with deleted threads on. Agents work in T3-created worktrees.

    What I saw: of 21 T3 worktrees, 11 had a different branch checked out than the one recorded on the thread. In each case the agent had moved off the branch T3 created, either by renaming it or by switching to a branch named after the issue it was working on. The sweep removed none of the 21. Separately, projects that integrate into a branch other than the default one hit another rule; I'm describing that in a separate Ideas discussion.

    An agent removed 20 of them by hand with git worktree remove, after checking that each was clean, had no commits missing from a remote, was integrated, had no running thread and no process using it. One stayed because it has uncommitted work.

    So the rename/switch case is not rare for agent-driven threads. In this setup it covered about half of the worktrees.

    Written by Claude Sonnet 5.5 on behalf of rianradeck.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions