Skip to content

fix(git): drop a terminal PR once its branch moves past it - #9443

Open
dakixr wants to merge 5 commits into
pingdotgg:mainfrom
dakixr:fix/terminal-pr-on-reused-branch
Open

dakixr wants to merge 5 commits into
pingdotgg:mainfrom
dakixr:fix/terminal-pr-on-reused-branch

Conversation

@dakixr

@dakixr dakixr commented Sep 3, 2026

Copy link
Copy Markdown

Refs #4970.

A long-lived branch keeps matching a merged pull request by name forever. Release develop into main and every later thread on develop is stamped with that same historical number, and settles against its merged state.

There are two halves to it: the lookup that finds the stale match, and the discovery pass that puts it back after the lookup stops returning it.

1. The lookup

lookupStatusPr already suppresses terminal matches, but only on the default branch:

if (details.isDefaultBranch && latest.state !== "open") {
  return { pr: null, headContext };
}

An integration branch never qualifies, so it keeps its merged release PR indefinitely.

The branch is now compared against the commit the change request was opened from. A branch still sitting on that commit is described by it; a branch that has moved on was reused for later work.

  • ChangeRequest gains an optional headRefOid, from one extra field on the gh pr list --json call that already runs — no extra request.
  • A terminal change request is dropped when the branch's own tip points elsewhere.

Why commits and not dates. Comparing the branch tip's date against updatedAt is simpler but breaks squash and rebase merges, and would have broken status finds a merged PR after its remote branch was deleted, which backdates the PR while committing "now". The recorded head commit is the head branch's own, so it survives any merge strategy.

Which ref. The local ref wins where there is one — that is where a thread's work lands, so unpushed commits still count as moving on. for-each-ref matches a pattern literally or up to a slash, so refs/heads/feature/foo also reports refs/heads/feature/foo/child; every row is matched back against the ref it has to be, the way branchPullRequest already reads its own branch ref.

2. The discovery pass

Filtering the lookup is not enough on its own. ThreadPullRequestReactor restores a thread's saved branchPullRequest whenever detection returns null and the saved reference is merged or closed. A thread created after the release merged — while develop still sat on that change request's head — acquires the reference before its first turn moves the branch. The lookup then correctly reports nothing, discovery reads that as "not found", and restores the very reference the lookup rejected. That fallback runs on the reused branch, not only after a return to the default branch.

So null had to stop being ambiguous. The lookup now keeps what it rejected, and branchSupersededPullRequest(cwd, branch, pullRequest) answers whether this branch outgrew that specific change request. The retention fallback consults it and stands down only for that reference; numbers repeat across repositories, so the URLs have to agree.

It reads the entry the badge lookup already cached, so it costs no extra git or host work, and it is additive to the GitManager interface — existing callers and mocks are untouched.

Preserved on purpose: a saved reference the branch did not outgrow is still restored, explicit linkedPullRequest is untouched, and worktree threads skip the fallback exactly as before.

Not knowable means "keep it"

The comparison returns false, preserving today's behaviour, when the forge reports no head commit (every non-GitHub provider today), when no ref resolves — the deleted-branch case from #6216 — or when the git call fails. Losing it can never drop a badge that is otherwise correct.

#7394 covers the separate stale-origin/HEAD case.

Tests

Against real git repositories with a fake gh, each verified to fail without its fix:

  • status drops a merged PR once its long-lived branch moves past it
  • status drops a merged PR once its branch is committed to without pushing
  • status keeps a merged PR while its branch still sits on the merged commit — the squash-merge case
  • branch PR lookup ignores a nested child ref once the branch itself is gone
  • branch lookup reports the terminal PR a reused branch has outgrown
  • clears a saved reference the branch outgrew and keeps the rest of its history — a thread created after the old PR merged, on that PR's head, then advanced; alongside one holding a different historical reference and one with an explicit link

800 tests pass across src/git/, src/sourceControl/ and src/orchestration/. Full-repo typecheck and lint clean. Rebased on main, keeping its isDraft/closedAt fields and its 60s PR_LOOKUP_CACHE_TTL.

No UI change — the badge stops appearing where it was wrong.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Prevented merged or closed pull requests from being reattached to threads when their branches have been reused for newer changes.
    • Improved handling of merged pull requests as branches advance, including shared and nested branch scenarios.
    • Preserved pull request associations when a branch remains at the merged commit.
    • Improved pull-request tracking by comparing the branch with the recorded pull-request commit, including cases where commit information is unavailable.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:M 30-99 changed lines (additions + deletions). labels Sep 3, 2026
Comment thread apps/server/src/git/GitManager.ts

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit f764477. Configure here.

Comment thread apps/server/src/git/GitManager.ts Outdated
@macroscopeapp

macroscopeapp Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This targeted fix changes production status and thread-settlement behavior by comparing terminal PR head OIDs with local or remote branch tips, alongside additive GitHub metadata plumbing. It also adds line-level static-analysis suppressions in the test file, so human review is warranted.

You can add or adjust custom eligibility rules. Learn more.

@dakixr

dakixr commented Sep 3, 2026

Copy link
Copy Markdown
Author

Both review findings were real. Fixed in 2afe53b.

Cursor Bugbot — tip check matches sibling branch refs. Correct, with one refinement. refs/heads/develop does not report refs/heads/development (the prefix match stops at a slash), and git forbids holding feature/foo and feature/foo/child at once:

fatal: cannot lock ref 'refs/heads/feature/foo': 'refs/heads/feature/foo/child' exists

So a sibling can never pin a badge on a live branch. The other half is the real one: the child only surfaces once the branch itself is gone — exactly when the merged badge is meant to be kept — and standing in for the deleted branch dropped it. Every row is now matched back against the ref it has to be, following the exact-refname read branchPullRequest already does.

Macroscope — stale badge when the local branch advances without pushing. The underlying observation is right, but dropping the headRefOid forwarding would have disabled the comparison entirely rather than tightened it. The actual cause was collecting every ref and keeping the badge if any of them matched, so a stale remote-tracking ref outvoted the local branch. It now reads one tip, preferring the local ref: that is where a thread's work lands, so a branch with unpushed commits has moved on from a merged change request.

Head commits are also compared case-insensitively now.

Two tests added, both verified to fail against the previous commit:

  • status drops a merged PR once its branch is committed to without pushing
  • branch PR lookup ignores a nested child ref once the branch itself is gone

248 tests pass across src/git/, src/sourceControl/ and ThreadSettlementReactor; full-repo typecheck and lint clean.

On the flagged suppressions — the only ones added are @effect-diagnostics-next-line preferSchemaOverJson:off on test fixtures, matching every other JSON.stringify fixture in GitManager.test.ts. Happy to drop them if you'd rather these fixtures went through a schema.

@github-actions github-actions Bot added size:L 100-499 changed lines (additions + deletions). and removed size:M 30-99 changed lines (additions + deletions). labels Sep 3, 2026
@dakixr

dakixr commented Sep 3, 2026

Copy link
Copy Markdown
Author

Both blocking findings are clear on 2afe53b — Cursor Bugbot pass (was 1 issue), Macroscope Correctness pass, and the approvability verdict no longer lists a blocking correctness issue.

On the remaining note about line-level static-analysis suppressions in the test file: I checked whether they can be dropped, and they can't. Removing the four this PR adds fails typecheck with hard errors:

src/git/GitManager.test.ts(1484,13): error TS377026: This code uses `JSON.parse` or `JSON.stringify`.
  Use `Schema.UnknownFromJsonString` ... effect(preferSchemaOverJson)

The fake gh returns raw JSON stdout, so a fixture has to be a string at that boundary. GitManager.test.ts already carries 67 of these directives for exactly that reason; the four added here follow that convention rather than introducing it. Converting the fixtures to schemas would mean touching all 67 and is well outside this fix — happy to do it as a separate PR if that's wanted.

That leaves the verdict resting on "human review is warranted" for a change to status and settlement behavior, which seems right — flagging it for a maintainer rather than something further I can address in code.

@shivamhwp

Copy link
Copy Markdown
Collaborator

Note: GPT-6 on behalf of shivam (@shivamhwp).

The GitManager filter does not clear an already saved stale badge on a shared checkout. If a thread acquires the old merged branchPullRequest before its first new commit, the later lookup correctly returns null, but ThreadPullRequestReactor restores the saved reference whenever its summary is merged or closed. That fallback also runs when the thread is still on the same reused develop branch, not only after switching back to the default branch.

Please handle this downstream case too. Discovery needs enough information to distinguish a terminal match rejected because the branch moved from the legitimate historical-reference fallback. Preserve historical threads and explicit links. Include a new thread created after the old PR merged, initially on that PR's head, then advanced by its first turn.

The rebase must retain the current GitHub isDraft/closedAt fields and the current lookup cache cadence. Non-GitHub providers still lack headRefOid, as the description notes; #7394 covers the separate stale-default-branch case.

@dakixr
dakixr force-pushed the fix/terminal-pr-on-reused-branch branch 2 times, most recently from 594f45b to 34a3f5b Compare September 11, 2026 14:43
@dakixr

dakixr commented Sep 11, 2026

Copy link
Copy Markdown
Author

Thanks — you're right, and the downstream case is the one that actually bites. Rebased onto main and handled in 34a3f5b.

The gap. ThreadPullRequestReactor's retention fallback fires whenever detection returns null and the saved reference's summary is terminal. My filter made detection return null for a reused branch, so the reactor read that as "not found" and restored the very reference the filter had just rejected. The badge came back, and as you say the fallback isn't limited to a return to the default branch.

Distinguishing the two. The lookup now keeps what it rejected instead of discarding it, and a new branchSupersededPullRequest(cwd, branch, pullRequest) answers whether this branch outgrew that specific change request. The retention fallback consults it and stands down only for that reference. Numbers repeat across repositories, so the URLs have to agree before it counts as the same one.

It reads the same cache entry the badge lookup reads, so there's no extra git or host work, and it's additive to the GitManager interface — existing callers and mocks are untouched.

Preserved, as asked:

  • a saved reference the branch did not outgrow is still restored (covered in the test);
  • explicit linkedPullRequest is untouched — the replacement path is unchanged;
  • worktree threads still skip the fallback exactly as before.

Your test case. clears a saved reference the branch outgrew and keeps the rest of its history in ThreadPullRequestReactor.test.ts is the scenario you described — a thread created after the old PR merged, holding that reference from when develop still sat on its head, then advanced. Alongside it, a thread on the same shared checkout holding a different historical reference, and one with an explicit link. I verified it fails without the reactor change.

Rebase. Kept main's isDraft/closedAt fields (headRefOid is inserted after headRefName, nothing dropped) and main's 60s PR_LOOKUP_CACHE_TTL with its sweep-cadence rationale — my branch had been written against the old 2min value. No lockfile changes.

Non-GitHub providers still have no headRefOid, so they keep their current behaviour, and I've left the stale-origin/HEAD case to #7394.

800 tests pass across src/git/, src/sourceControl/ and src/orchestration/; full-repo typecheck and lint clean.

@coderabbitai

coderabbitai Bot commented Sep 11, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: c74e7b4b-144a-443d-8b49-1c8a6bfb5286

📥 Commits

Reviewing files that changed from the base of the PR and between 2e3c820 and 3f3d8b7.

📒 Files selected for processing (7)
  • apps/server/src/git/GitManager.test.ts
  • apps/server/src/git/GitManager.ts
  • apps/server/src/sourceControl/GitHubCli.test.ts
  • apps/server/src/sourceControl/GitHubCli.ts
  • apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts
  • apps/server/src/sourceControl/GitHubSourceControlProvider.ts
  • packages/contracts/src/sourceControl.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/server/src/sourceControl/GitHubCli.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review.


📝 Walkthrough

Walkthrough

The change propagates pull request head commit OIDs through GitHub integrations, detects superseded terminal pull requests after branch movement, and prevents stale thread references from being restored.

Changes

Superseded pull request tracking

Layer / File(s) Summary
Head commit data contract
packages/contracts/src/sourceControl.ts, apps/server/src/sourceControl/*
GitHub queries and normalized records now carry optional headRefOid values. The provider maps these values into ChangeRequest.
Branch supersession detection
apps/server/src/git/GitManager.ts, apps/server/src/git/GitManager.test.ts
GitManager compares branch tip OIDs with terminal pull request head OIDs. Branch lookup returns superseded pull requests separately, and branchSupersededPullRequest verifies the matching pull request.
Thread restoration guard
apps/server/src/orchestration/ThreadPullRequestReactor.ts, apps/server/src/orchestration/ThreadPullRequestReactor.test.ts
Thread restoration checks whether the saved pull request was superseded before restoring it. Tests cover shared checkouts and preserved unrelated references.

Priority: ⬇️ Low

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Bug fix

Sequence Diagram(s)

sequenceDiagram
  participant ThreadPullRequestReactor
  participant GitManager
  participant GitRefs
  ThreadPullRequestReactor->>GitManager: check branchSupersededPullRequest
  GitManager->>GitRefs: read branch tip OID
  GitManager-->>ThreadPullRequestReactor: return superseded status
  ThreadPullRequestReactor-->>ThreadPullRequestReactor: restore or clear branch pull request
Loading

Suggested reviewers: juliusmarminge

Merge Risk: ⚪ Minimal · up to 3f3d8

The change adds optional head-commit tracking to prevent stale terminal pull-request references from being restored. No actionable merge-blocking risk remains in the reviewed change.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 5 functions across 10 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main fix: removing a terminal pull request association when its branch advances beyond the recorded commit.
Description check ✅ Passed The description clearly explains the problem, implementation, rationale, preserved behavior, tests, and lack of UI changes. It does not use the template headings or checklist, but it contains the requ…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@dakixr
dakixr force-pushed the fix/terminal-pr-on-reused-branch branch from e21d327 to 2e3c820 Compare September 12, 2026 06:32
@dakixr

dakixr commented Sep 12, 2026

Copy link
Copy Markdown
Author

Two follow-ups from the CodeRabbit pass, both fair.

Description/diff mismatch. Correct — the description still described only the original lookup filter and never mentioned the discovery work added in the rebase. Rewritten to match what the branch actually does now: the lookup half, the ThreadPullRequestReactor half, and what each deliberately preserves.

Docstring coverage. The 0% reading looks like the heuristic not recognising const x = Effect.fn(...) assignments — four of the five functions it counted already carry doc comments. But it caught a real one: branchSupersededPullRequest's explanation sat only on the service interface, with nothing at the implementation. Documented there in 2e3c820.

No code behaviour changed in either follow-up.

dakixr and others added 5 commits September 15, 2026 09:00
A long-lived branch keeps matching a merged pull request by name forever.
Release `develop` into `main` and every later thread on `develop` is
stamped with that same historical number, and settles against its merged
state.

`lookupStatusPr` already suppresses terminal matches, but only on the
default branch, so an integration branch never qualifies. Compare the
branch against the commit the change request was opened from instead: a
branch still sitting on that commit is described by it, and a branch that
has moved on was reused for later work. Commits rather than dates, so
squash and rebase merges keep their badge — the recorded head commit is
the branch's own, not the base's.

`headRefOid` is optional, and the check is skipped whenever the answer is
not knowable: a forge that does not report the head commit, a deleted
branch with no ref left to compare, or a failed git call. Every one of
those keeps today's behaviour rather than dropping a badge.

Only GitHub populates it here; the other forges keep their current
behaviour until they carry the field too.

Refs pingdotgg#4970

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two problems with the first pass, both from review.

`for-each-ref` matches a pattern literally *or* up to a slash, so
`refs/heads/feature/foo` also reports `refs/heads/feature/foo/child`. Git
forbids holding both at once, so the child only surfaces once the branch
itself is gone — exactly when the merged badge is meant to be kept — and
standing in for the deleted branch dropped it instead. Every row is now
matched back against the ref it has to be, the way `branchPullRequest`
already reads its own branch ref.

Collecting every ref also meant a branch committed to but not yet pushed
kept its badge, because the remote-tracking ref still sat on the old head
and any match was enough. Read one tip instead, preferring the local ref:
that is where a thread's work lands, so a branch with unpushed commits has
still moved on from a merged change request.

Head commits are compared case-insensitively.

Refs pingdotgg#4970

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The lookup filter alone does not clear a badge that is already saved. A
thread created after the release merged, while `develop` still sat on that
change request's head, acquires the reference before its first turn moves
the branch. The later lookup correctly reports nothing, but discovery reads
that as "not found" and restores the saved reference because its summary is
merged — putting back the very badge the lookup rejected. The fallback runs
on the reused branch, not only after a return to the default branch.

Discovery needs to tell the two apart, so the lookup now keeps what it
rejected and `branchSupersededPullRequest` answers whether this branch
outgrew a given change request. The retention fallback consults it and
stands down only for that reference; numbers repeat across repositories, so
the URLs have to agree. A saved reference the branch did not outgrow is
still restored, and explicit links are untouched.

Refs pingdotgg#4970

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The explanation sat only on the service interface, leaving the
implementation undocumented.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ChangeRequest.headRefOid` is a `Schema.optional` field, so its type admits
an explicit `undefined`. `PullRequestInfo` declared it as `string | null`,
which `exactOptionalPropertyTypes` rejects once a mapped change request is
spread into it — as the Forgejo head-matching test now does. Widen it the
way the neighbouring `PullRequestHeadRemoteInfo` fields already are.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dakixr
dakixr force-pushed the fix/terminal-pr-on-reused-branch branch from 2e3c820 to 3f3d8b7 Compare September 15, 2026 07:07
@dakixr

dakixr commented Sep 15, 2026

Copy link
Copy Markdown
Author

Rebased onto current main (cc839c4).

Main hadn't picked up a text conflict, but a merge check against it failed typecheck. The Forgejo head-matching test added in #11436 spreads a mapped ChangeRequest into matchesBranchHeadContext, and ChangeRequest.headRefOid — a Schema.optional field — admits an explicit undefined that my PullRequestInfo.headRefOid?: string | null rejected under exactOptionalPropertyTypes. Widened to string | null | undefined in 3f3d8b7, matching the neighbouring PullRequestHeadRemoteInfo fields.

No behaviour change. On the rebased branch: full-repo typecheck and lint clean, 869 tests pass across src/git/, src/sourceControl/ and src/orchestration/, no lockfile changes.

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

Labels

size:L 100-499 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants