Skip to content

[Bug]: Merged-worktree cleanup retains worktrees after squash merges #14742

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 — 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:

  1. Enable Delete merged worktrees. Disable inactivity, unchanged-worktree and deleted-thread cleanup so another rule does not mask the result.
  2. 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.
  3. Squash-merge the PR, retaining the local feature branch and its original head.
  4. 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

  1. juliusmarminge commented on Oct 2, 2026

    @juliusmarminge
    Member

    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 || worktreeOnMerge branch in apps/server/src/storageCleanup.ts fetches refs/remotes/<primary>/<default> and runs merge-base --is-ancestor. A non-zero result returns right away, before branchPullRequest runs, so the merged pull request is never checked. The Git side checks out too. After git merge --squash, git diff --quiet feature main exits 0 but git merge-base --is-ancestor feature main exits 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:

    • the pull request is merged
    • its head branch is that branch
    • its base is the project's default branch
    • its head SHA equals the checkout's current HEAD

    The 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:

    • It applies only to Delete merged worktrees. The unchanged-worktree rule stays an ancestry check against the default branch, with no new target resolver or setting. That broader change is the separate problem fix(server): clean up worktrees after squash merges #14651 bundled in.
    • Pull requests merged into a release branch or a stack parent don't qualify.
    • A later local commit, a different head branch, a closed-unmerged pull request, a stale snapshot, a missing head SHA, or a failed lookup all fall back to the current ancestry result. One catch: branchPullRequest (via toStatusPr) doesn't carry a head SHA today, and the GitHub state: all list omits headRefOid, so the read needs to include it.
    • All the existing clean-checkout, idle, ownership, ignored-file, and final policy and HEAD checks stay, and branches and thread history are kept.
    • Update the storage guide sentence that points squash merges to the inactivity rule. Tests should cover an exact-head removal, a later commit, a non-default base, a missing head SHA, a failed lookup, and unchanged-only cleanup still ignoring squash evidence.

    If you open a replacement PR, linking it from #14651 would help connect the two.

  2. added
    bugSomething is broken or behaving incorrectly.
    via-triageFiled through npx t3 triage
    on Oct 2, 2026
  3. added 2 commits that reference this issue on Oct 2, 2026
    e593305
    9135e95
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

    bugSomething is broken or behaving incorrectly.via-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions