Repository navigation
[Bug]: Merged-worktree cleanup retains worktrees after squash merges #14742
Description
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 — automatic worktree cleanup, shared by all clients.
Context
The merged-worktree cleanup rule retains a clean worktree after its PR is squash-merged because its original head is not an ancestor of the default branch. This is a known intentional limitation, not a regression: the original implementation, #11598, explicitly retained squash/rebase merges without ancestry proof, and the current guide documents it.
This issue requests triage of the focused squash-cleanup repair identified in #14651's closure. That PR separately changed ancestry to use a PR target instead of the project's default branch; that separate change is outside this report.
Steps to reproduce
T3 conditions:
- Enable Delete merged worktrees. Disable inactivity, unchanged-worktree and deleted-thread cleanup so another rule does not mask the result.
- Use a T3-managed feature worktree with a PR targeting the repository's default branch. Keep the checkout clean, idle and exclusively owned by its thread, with no active session/terminal or protected ignored files.
- Squash-merge the PR, retaining the local feature branch and its original head.
- Allow the normal cleanup sweep to evaluate the worktree.
The Git condition can be reproduced independently, without a GitHub repository or T3 server:
scratch_dir=$(mktemp -d)
git init -b main "$scratch_dir"
cd "$scratch_dir"
git config user.name 'Cleanup repro'
git config user.email 'cleanup-repro@example.invalid'
printf 'base\n' > file
git add file
git commit -m base
git switch -c feature
printf 'feature\n' >> file
git commit -am feature
git switch main
git merge --squash feature
git commit -m 'Squash feature'
git diff --quiet feature main
printf 'tree difference exit: %s\n' "$?"
git merge-base --is-ancestor feature main
printf 'ancestry exit: %s\n' "$?"Observed in an isolated Git reproduction: tree difference exit 0 (identical trees), ancestry exit 1 (feature head is not an ancestor).
Expected behavior / requested triage decision
An otherwise eligible worktree whose current exact head was the head of a freshly confirmed merged PR should be eligible under the merged rule after a squash merge.
Please establish whether that evidence is sufficient and the accepted scope before a replacement implementation. The minimal reproduction merges into the default branch. In particular, should a linked upstream PR in a fork-only checkout qualify, and should a PR merged into a release branch or an unmerged stack parent qualify, or should the first version be limited to the default target?
Keep the separate unchanged-worktree rule's default-branch meaning and existing ancestry fallback. No broader PR-target resolver or new cleanup setting is requested here.
Actual behavior
The cleanup gate fetches the primary remote's default branch, then requires the checkout head to be its ancestor. A squash merge normally fails that test, so evaluation returns before querying whether the PR merged. The worktree remains unless another cleanup rule qualifies it.
The requested change must preserve the current clean-checkout, activity, ownership, ignored-file and final policy/HEAD checks. Later local commits, a different PR head branch, stale merged state, missing head evidence or lookup failures must not satisfy a new exact-head path. Branches and thread history should remain as they do today.
Impact
Minor bug or occasional failure: completed squash-merged worktrees retain disk usage unless another rule removes them. This is a limitation of the merged rule, not data loss.
Version or commit
Upstream main at 99e08526e5ec84f294940cba5929841518c52fec.
Environment and verification limits
Linux, isolated local Git repository plus source tracing against the commit above. The Git ancestry failure was reproduced; an integrated T3 cleanup run and a live GitHub squash merge were not performed for this report. The T3 outcome above is established by the source gate, not a claimed application test.
Workaround
The existing guide recommends inactivity-based cleanup for squash merges. That is a separate policy with different timing, so it does not make the merged rule itself effective.
Activity
juliusmarminge commentedon Oct 2, 2026
Note
Grok responding on behalf of Julius.
Triage
Thanks for splitting this out so cleanly, @tris203. I confirmed the behavior on
main(99e08526). It's the limitation that shipped with merged PR #11598 and is described in the storage guide, not a regression: merge cleanup only counts a worktree as merged when its head is an ancestor of the primary remote's default branch, so after a squash merge the worktree is kept.Where it happens
The
worktreeUnchanged || worktreeOnMergebranch inapps/server/src/storageCleanup.tsfetchesrefs/remotes/<primary>/<default>and runsmerge-base --is-ancestor. A non-zero result returns right away, beforebranchPullRequestruns, so the merged pull request is never checked. The Git side checks out too. Aftergit merge --squash,git diff --quiet feature mainexits 0 butgit merge-base --is-ancestor feature mainexits 1.A focused fix that fits the scope requested on #14651
Treat an otherwise eligible worktree as merged when a fresh pull-request read for its branch meets all of these:
mergedHEADThe squash commit has a different SHA, so tree equality isn't the proof. This check also covers rebase merges where the local head is still the pull request's head.
What it should leave alone:
branchPullRequest(viatoStatusPr) doesn't carry a head SHA today, and the GitHubstate: alllist omitsheadRefOid, so the read needs to include it.HEADchecks stay, and branches and thread history are kept.If you open a replacement PR, linking it from #14651 would help connect the two.
fix(server): merged-worktree cleanup removes worktrees after squash m…
fix(server): merged-worktree cleanup removes worktrees after squash m…