Repository navigation
Merged-worktree cleanup retains upstream-merged branches in fork checkouts until the fork is synced #14843
Description
Area
apps/server — automatic worktree cleanup in fork-based repositories.
Problem and requested decision
With origin pointing to a fork and upstream pointing to the source repository, Delete merged worktrees checks the fork's default branch. A feature merged normally into upstream's default branch is retained while the fork's default branch is behind, even when both repositories use main and the thread has a linked upstream PR marked merged.
This is a separately reproduced consequence of the current primary-remote policy, not a claim of regression or prior approval to change that policy. Please triage whether cleanup should recognize the upstream PR target in this workflow and establish the required proof before implementation.
Reproduction
Investigated upstream main at 54084ae1e6c32809db040e4fa571c80fdf2d8ae4.
- Create an upstream repository with default branch
main, fork it, and clone the fork. Setoriginto the fork and addupstreamfor the source repository. - Set
remote.upstream.gh-resolved=base, matching an upstream-directed GitHub CLI workflow. Both remote HEAD refs point tomain. - Create a T3-managed
featureworktree, commit a file, and pushfeatureto the fork with tracking set toorigin/feature. - Merge that feature into upstream
mainwith an ordinary--no-ffmerge. Do not update the fork'smain. Fetch both remotes so this is not stale local tracking data. - Evaluate a clean, idle, exclusively owned worktree with a linked merged upstream PR, no live provider session/terminal/background work, and no protected ignored files. Enable only Delete merged worktrees; disable inactivity, unchanged-worktree, and deleted-thread cleanup.
- Run cleanup. The worktree is retained.
- Control: repeat with the fork's
mainsynchronized to upstream after the merge. Cleanup reaches the fresh merged-PR lookup and removes the worktree.
These steps were executed in a deterministic service-level reproduction using local bare repositories; no hosted PR was created. The production cleanup service and Git driver were used without modification. The thread snapshot, linked PR metadata, idle resources, and merged-PR lookup response were fixtures. This isolates cleanup eligibility from GitHub discovery/authentication.
Observed evidence
| Observation | Fork main behind |
Fork main synchronized |
|---|---|---|
HEAD is ancestor of upstream/main |
Yes | Yes |
HEAD is ancestor of origin/main |
No | Yes |
| Selected primary remote | origin |
origin |
| Branch fetched by cleanup | origin/main |
origin/main |
| Fresh PR lookup calls | 0 | 1 |
| Worktree retained | Yes | No |
| Actual worktree removals | 0 | 1 |
The ordinary merge preserves ancestry, so squash/rebase merging cannot explain this result. Both repositories have the same default branch name, so this is repository selection rather than non-default branch selection.
The attached reproduction bundle contains the executable test, instructions, two observation JSON files, and logs. The characterization run passes 2/2 tests. Enabling the proposed expectation that both upstream-merged worktrees can be removed produces 1 failure / 1 pass: only the unsynchronized-fork case fails. The tests await the cleanup drain rather than sleeping.
Evidence bundle: fork-cleanup-evidence.zip
Source trace
- Primary remote selection prefers
originwhenever it exists. It does not consultremote.upstream.gh-resolvedfor this decision. - Cleanup eligibility fetches that remote's default branch and returns immediately when
HEADis not its ancestor, beforebranchPullRequest(..., { refresh: true })runs. - Current documentation describes the remote-default-branch requirement. The issue is whether fork workflows should gain an upstream-aware proof, not whether the code follows its current rule.
Why Automatically pull does not resolve this
When local main tracks upstream/main, Automatically pull can keep the local default-branch checkout current with the parent repository. It runs git pull --ff-only against the branch's configured tracking branch; it does not push those commits to the fork or run gh repo sync.
Cleanup makes a separate choice: it fetches and checks origin/main whenever origin exists. It does not use local main or follow local main's tracking remote. Consequently, local main and upstream/main can contain the merged feature while the fork's origin/main still does not, and this cleanup gate still rejects the worktree. Fetching origin/main refreshes knowledge of the fork; it does not synchronize the fork with its parent.
This interaction is source-derived, not an additional executed scenario in the attached reproduction. The existing test establishes the fork-versus-upstream ancestry difference but does not exercise the Automatically pull setting or update local main from upstream.
Sources at the investigated commit: automatic-pull eligibility and dispatch, tracking-branch pull, and the cleanup eligibility code linked above.
Requested scope
Consider otherwise eligible worktrees whose PR merged normally into the upstream repository's default branch, without requiring users to synchronize their fork first. Please establish how cleanup must verify target-repository identity, current target ancestry, and correspondence to the checked-out branch/commit.
This request does not propose treating a cached merged badge as sufficient evidence, switching all Git operations to upstream, changing the unchanged-worktree rule, weakening clean/idle/ownership/ignored-file guards, or including squash/rebase merges and non-default targets. Failed or ambiguous target resolution should retain the checkout.
Related issues
- [Bug]: Merged-worktree cleanup retains worktrees after squash merges #14742 covers squash/rebase merge evidence. Its body mentions a fork-only checkout, but its triaged scope leaves remote/target resolution unchanged. This reproduction uses an ordinary merge.
- Support merged-worktree cleanup for PRs targeting release branches or stack parents #14758 covers release branches and stack parents. Its triage explicitly excludes a new target-repository resolver; this report uses
mainin both repositories and isolates cross-repository selection. - [Bug]: Fork PR creation always fails — head selector is computed against
origininstead of gh's resolved base repo #7382 concerns fork PR creation/head selectors, a separate stage. This reproduction provides merged PR metadata and shows cleanup returns before consulting its fresh PR lookup.
Environment and limitations
Linux x86_64; Git 2.43.0; Node 24.3.0; Vite+ 1.0.0. Node is below the declared ^24.13.1 engine; both focused runs completed, but this has not been rechecked on a supported Node version. No browser/device reproduction or live GitHub API validation was performed. No production files or live T3 state were changed.
Prepared with GPT-6-Astra through the Codex harness in T3 Code.
Activity
juliusmarminge commentedon Oct 2, 2026
Note
Grok responding on behalf of Julius.
Triage
Thanks for keeping this separate from the squash and release-branch reports, @tris203, and for the deterministic repro bundle. I confirmed the behavior on current
main(ce96e4c3). The cleanup path hasn't changed since the commit you investigated (54084ae1). This is the documented primary-remote rule working as written, not a regression, so I'm tracking it as an enhancement.Where it happens:
resolvePrimaryRemoteName(apps/server/src/vcs/GitVcsDriverCore.ts) returnsoriginwhenever it exists and doesn't readremote.*.gh-resolved. Inapps/server/src/storageCleanup.ts, cleanup fetchesrefs/remotes/<primary>/<default>and runsmerge-base --is-ancestor. WhenHEADisn't an ancestor, it returns beforebranchPullRequest(..., { refresh: true })runs. Pull-request lookup uses a different rule:selectGitHubBaseRepository(apps/server/src/sourceControl/GitHubCli.ts) honorsgh repo set-default, then prefersupstream,github, andoriginin that order. So a fresh lookup would read the parent repo, but cleanup never reaches it while the fork's default branch is behind. You're right that Automatically pull doesn't close the gap. It fast-forwards localmainfrom its tracking branch but never updatesorigin/main, and cleanup doesn't look at localmain.A possible narrow scope, pending maintainer approval: extend only Delete merged worktrees, and only after ancestry against the primary remote's default branch has already failed.
resolvePrimaryRemoteNamewould stay as it is, and a remote namedupstreamor agh-resolvedmark wouldn't count as the target on its own. On top of the existing idle, ownership, clean-checkout, ignored-file, and finalHEADchecks, all of these would need to hold:merged, and its head ref is that branch. A cached merged badge isn't enough.refs/remotes/<remote>/HEADand equals the PR's base ref. Cleanup fetches only that branch, and the worktreeHEADmust be an ancestor of the fetched tip. A stale tracking ref or localmaindoesn't count.Anything failed or ambiguous keeps the checkout: a failed lookup, an open or closed-unmerged PR, an unparseable URL, a missing remote
HEAD, a non-default base, a failed fetch, or aHEADthat isn't an ancestor. The unchanged-worktree rule stays a primary-remote check. Squash and rebase merges stay with #14742, release branches and stack parents stay with #14758, and this doesn't add a general target-repository resolver.If this goes ahead, the storage section in
docs/user/project-settings.mdneeds to describe the second proof. Tests should cover:Please wait for a maintainer go-ahead here before opening a PR. In the meantime, syncing the fork's default branch is what lets the current rule clean these up.