Skip to content

Merged-worktree cleanup retains upstream-merged branches in fork checkouts until the fork is synced #14843

Open

Description

@tris203

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.

  1. Create an upstream repository with default branch main, fork it, and clone the fork. Set origin to the fork and add upstream for the source repository.
  2. Set remote.upstream.gh-resolved=base, matching an upstream-directed GitHub CLI workflow. Both remote HEAD refs point to main.
  3. Create a T3-managed feature worktree, commit a file, and push feature to the fork with tracking set to origin/feature.
  4. Merge that feature into upstream main with an ordinary --no-ff merge. Do not update the fork's main. Fetch both remotes so this is not stale local tracking data.
  5. 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.
  6. Run cleanup. The worktree is retained.
  7. Control: repeat with the fork's main synchronized 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 origin whenever it exists. It does not consult remote.upstream.gh-resolved for this decision.
  • Cleanup eligibility fetches that remote's default branch and returns immediately when HEAD is not its ancestor, before branchPullRequest(..., { 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

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

  1. juliusmarminge commented on Oct 2, 2026

    @juliusmarminge
    Member

    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) returns origin whenever it exists and doesn't read remote.*.gh-resolved. In apps/server/src/storageCleanup.ts, cleanup fetches refs/remotes/<primary>/<default> and runs merge-base --is-ancestor. When HEAD isn't an ancestor, it returns before branchPullRequest(..., { refresh: true }) runs. Pull-request lookup uses a different rule: selectGitHubBaseRepository (apps/server/src/sourceControl/GitHubCli.ts) honors gh repo set-default, then prefers upstream, github, and origin in 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 local main from its tracking branch but never updates origin/main, and cleanup doesn't look at local main.

    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. resolvePrimaryRemoteName would stay as it is, and a remote named upstream or a gh-resolved mark wouldn't count as the target on its own. On top of the existing idle, ownership, clean-checkout, ignored-file, and final HEAD checks, all of these would need to hold:

    • A fresh pull-request read for the worktree branch says merged, and its head ref is that branch. A cached merged badge isn't enough.
    • The PR URL parses to one repository, and exactly one configured remote other than the primary has a fetch URL that normalizes to it. Zero or several matches keep the worktree.
    • That remote's default branch resolves from refs/remotes/<remote>/HEAD and equals the PR's base ref. Cleanup fetches only that branch, and the worktree HEAD must be an ancestor of the fetched tip. A stale tracking ref or local main doesn'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 a HEAD that 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.md needs to describe the second proof. Tests should cover:

    • an ordinary merge into the parent while the fork is behind
    • an already-synced fork still being removed by the current path
    • a later local commit
    • a squash onto the parent being kept
    • a non-default target being kept
    • an open or closed-unmerged PR
    • a failed lookup
    • an unparseable URL, or zero or several matching remotes
    • unchanged-only cleanup still ignoring this evidence

    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.

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

    enhancementRequested improvement or new capability.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