Repository navigation
Description
What happened
On a self-hosted Forgejo, T3 Code does not recognize merged pull requests once their source branch has been deleted after merging, which is the common "delete branch on merge" setup. The branch lookup reports "no PR" for those threads, so they never record that their PR was merged.
We noticed this through its side effect. After a T3 update, every server start caused a heavy load spike on our Forgejo 15 server for about half an hour, while T3 re-checked hundreds of settled threads and found nothing for any of them.
Diagnosis
For a PR merged with "delete branch", Forgejo's API reports head.ref as refs/pull/<N>/head. The branch name remains only in head.label. This was seen on Forgejo 15.0.10 and on Codeberg (Forgejo 17.0.0), so it is not version-specific.
ForgejoSourceControlProvider.listChangeRequests drops every item where item.head.ref !== branch (packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts:347 at v0.1.8, unchanged on main @ 53bcb9a). So findLatestPrForHeadContext / branchPullRequest return null for any branch whose PR was merged and deleted.
Consequences, from code reading at v0.1.8:
- The thread never gets
branchPullRequest. The startup backfill inThreadPullRequestServiceV2(apps/server/src/orchestration-v2/ThreadPullRequestService.ts:391,:146) re-checks every settled thread withbranchPullRequest == nullon each server start. The result staysnull, so the same threads are re-checked after every restart. - The merged-PR worktree cleanup rule (
worktreeOnMerge) cannot fire. It callsbranchPullRequest(...)and requiresstate === "merged". It also comparespullRequest.headRef === worktree.branchunless the branch is integrated, and that comparison would fail too, becauseheadRefisrefs/pull/<N>/head.
Of 377 branches looked up after one restart, 339 do have a PR when matched by head.label.
On Forgejo < 16, each of these lookups also pages through the whole PR list, because head= is ignored there (#23551). That is what made the problem visible as server load. Upgrading Forgejo to 16+ removes that cost, but the lookups still return "no PR".
Suggested fix: when head.ref starts with refs/pull/, match the branch against head.label, or the stored head branch, and use that name as the change request's headRef.
Steps to reproduce
- On a Forgejo repository with "delete branch after merge", merge a PR from branch
<branch>. GET /api/v1/repos/<owner>/<repo>/pulls?state=allshows that PR with"head": {"ref": "refs/pull/<N>/head", "label": "<branch>"}.- In T3 Code, have a thread on
<branch>, and let it settle or restart the server. - The thread's branch PR stays empty (
branchPullRequest: nullin the thread projection), andfindLatestPrForHeadContextfor<branch>returns nothing. The merged-PR cleanup does not remove its worktree.
Version
0.1.7 and 0.1.8 (stable); code unchanged on main @ 53bcb9a
Environment
Ubuntu Linux, T3 Code background service (systemd user unit), fj 0.6.0; self-hosted Forgejo 15.0.10 (also checked against Codeberg, Forgejo 17.0.0)
Evidence
# Forgejo list response for a merged PR with deleted branch (public example, Codeberg 17.0.0)
"head": { "ref": "refs/pull/15209/head", "label": "<branch>" }
# Our repository: 595 PRs, 592 with head.ref = refs/pull/<N>/head
# After one server start: 377 distinct branches looked up, all "no PR";
# 339 of them have a PR when matched by head.labelRelated issues
#23551 / #19347: head= ignored on Forgejo < 16 and Gitea, so each lookup is a full scan (the cost side, and how we noticed). This issue is the wrong answer, on any version. #25569, #29514: also scan cost. Nothing existing covers the head.ref mismatch.
Fix applied or workaround
None in T3. Upgrading Forgejo to 16+ removes the scan cost but not the wrong answer. Archiving old settled threads shrinks the startup re-check.
Filed by
Claude Code (claude-opus-5-5) in a T3 Code thread, following .github/triage/PLAYBOOK.md manually (not via t3 triage)
Activity