#4624 · Historical PR association survives new commits
Verdict: REPRODUCED · Root-cause confidence: high · reproduction label: confirmed-repro
1. TL;DR
BB can continue returning an old merged or closed pull request after new commits are added to the same branch. The lookup asks GitHub CLI to view the branch PR, accepts a terminal-state response, and never compares the PR's commit identity with the current checkout. A focused test through the real Workspace API fails for both terminal states in two clean checkouts, while its open-PR control passes. A separate real GitHub CLI experiment also returns a merged PR for unrelated new local history sharing its branch name. This is an association problem, not a demonstrated state-rendering bug: the server preserves merged and the app supplies a “Merged” label and merge icon.
2. Claims vs findings
| Claim | Finding | Evidence |
|---|---|---|
| A terminal PR can remain associated after new branch commits. | Verified | Two real-git Workspace test runs return found for MERGED and CLOSED after HEAD changes. |
| The lookup selects by branch without limiting to open PRs. | Verified | Production runs gh pr view, with no open-only lookup or terminal-state rejection. Real CLI reproduction returns merged PR #4622. |
| The historical PR is presented as an open PR. | Not supported on tested main | Production assembly preserves merged state; display helpers return “Merged”; terminal-state banner tests pass. Full visual clarity on the reporter's device was not tested. |
| The reported private project, old PR number, merge date, and screenshot describe the same event. | Unverified | No private project/runtime data or issue attachment was accessed. All fixtures and local git history were newly created. |
| The selected PR is always the newest across every possible state/base combination. | Unverified generalization | We verify the no-open-PR terminal case, not GitHub CLI's entire selection algorithm. |
3. Environment
- Public target repository:
get-bb/bb; trusted base fetched from itsorigin/main:08cbb7149a51522ed148e1ca8c4d5f7306a927e1. - Linux x86_64, kernel 4.19.0-gvisor; Node 22.19.0; pnpm 9.15.0; Vitest 4.1.1; installed gh 2.23.0. No provider session was required.
- Both checkouts were separately cloned and frozen-installed. The first full Turbo build passed all 63 tasks. No BB server, provider, browser, listening port, or production data directory was used.
- GitHub metadata for public merged PR #4622 was used solely to obtain a branch name for a synthetic scratch repo. Neither that PR's code nor its branch was checked out.
- Latest main rechecked before publication:
e01b1c5434924ae1f682b7be31a67d6b752d7c15. No intervening changes affect the cited lookup, server assembly, or display paths.
4. Minimal reproduction
Deterministic owner-boundary regression
- Clone the public target and check out the trusted base, not any issue or PR branch:
git clone https://github.com/get-bb/bb.git checkout-a cd checkout-a git checkout --detach 08cbb7149a51522ed148e1ca8c4d5f7306a927e1 pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build
- Place the saved regression at
packages/host-workspace/test/issue-4624-repro.test.ts. It creates real isolated git repos and intercepts only the external gh executable response, not production Workspace code. There is no database mock or new production test seam. - Run:
pnpm exec turbo run test --filter=@bb/host-workspace -- issue-4624-repro.test.ts
- The test first verifies that a PR is found before the next commit, then adds a real new commit and checks that HEAD changed. Its expected open-only/current-work policy returns no terminal PR after that commit.
Expected terminal case: { outcome: "none" } Actual terminal case: { outcome: "found", pullRequest: { number: 46, state: "MERGED", ... } } Actual closed case: { outcome: "found", pullRequest: { number: 46, state: "CLOSED", ... } } Test Files 1 failed (1) Tests 2 failed | 1 passed (3)The failures are intentional evidence, not passing regression coverage for a shipped fix. The passing OPEN control demonstrates that the failure is not a general lookup or environment failure.
Real CLI corroboration without a mock response
Place the live reproduction at apps/app/fixtures/issue-4624-live.tsx and run:
NODE_OPTIONS=--conditions=source pnpm exec tsx --tsconfig apps/app/tsconfig.json apps/app/fixtures/issue-4624-live.tsx
The script validates metadata for public PR #4622, initializes a scratch repo with the same branch name and a constant public origin URL, and creates two unrelated empty commits. It runs real gh pr view before and after the second commit, then uses the production parser, server assembly, and display helpers.
{"fixture":"real gh on synthetic local git history","before":{"outcome":"found","number":4622,"state":"MERGED"},"after":{"outcome":"found","number":4622,"state":"MERGED"}}
{"serverState":"merged","attention":"merged"}
{"stateLabel":"Merged","attentionLabel":"Merged"}
Compatibility limit: this host's gh 2.23.0 rejects the production JSON field autoMergeRequest. The real CLI corroboration therefore requests the other production fields, excluding only that unsupported field. It is not a claim that a full production lookup succeeds with this old CLI; the deterministic Workspace reproduction exercises the exact unmodified production command path with controlled valid output. The first full-field live attempt returned unavailable; it did not reproduce the association. The adjusted experiment does not modify production code or forge a GitHub response.
Visual limit: the prescribed headless browser backend reported that Chrome/Chromium was unavailable. No substitute browser, fabricated screenshot, or reporter attachment was used. This report proves the lookup association and preserved status using executable evidence; it does not claim a full macOS visual reproduction.
Complete focused test
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, expect, it, vi } from "vitest";
import { runGit } from "../src/git.js";
import { Workspace } from "../src/workspace.js";
const tempDirs: string[] = [];
afterEach(async () => {
vi.unstubAllEnvs();
await Promise.all(
tempDirs.splice(0).map((directory) =>
fs.rm(directory, { recursive: true, force: true }),
),
);
});
it.each(["OPEN", "MERGED", "CLOSED"])(
"does not associate a historical %s PR with new work on a reused branch",
async (state) => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bb-4624-"));
tempDirs.push(directory);
const workspacePath = path.join(directory, "workspace");
const binPath = path.join(directory, "bin");
await fs.mkdir(workspacePath);
await fs.mkdir(binPath);
await runGit(["init", "-b", "qa-reused"], { cwd: workspacePath });
await runGit(["config", "user.name", "BB Tests"], { cwd: workspacePath });
await runGit(["config", "user.email", "bb@example.com"], {
cwd: workspacePath,
});
await runGit(["commit", "--allow-empty", "-m", "Initial fixture"], {
cwd: workspacePath,
});
const previousHead = await runGit(["rev-parse", "HEAD"], {
cwd: workspacePath,
});
const fixturePath = path.join(directory, "pull-request.json");
await fs.writeFile(
fixturePath,
JSON.stringify({
number: 46,
title: "Synthetic branch association fixture",
state,
url: "https://github.com/acme/qa/pull/46",
isDraft: false,
baseRefName: "main",
headRefName: "qa-reused",
headRefOid: previousHead.stdout.trim(),
updatedAt: "2026-09-01T12:00:00Z",
statusCheckRollup: [],
reviewDecision: null,
reviewRequests: [],
mergeStateStatus: "UNKNOWN",
mergeable: "UNKNOWN",
}),
);
const ghPath = path.join(binPath, "gh");
await fs.writeFile(
ghPath,
[
"#!/bin/sh",
'if [ "$1" = "pr" ] && [ "$2" = "view" ]; then',
' cat "$REPRO_PR_FIXTURE"',
" exit 0",
"fi",
'if [ "$1" = "api" ] && [ "$2" = "graphql" ]; then',
' printf \'%s\\n\' \'{"data":{"resource":{"isInMergeQueue":false}}}\'',
" exit 0",
"fi",
'printf "unexpected command\\n" >&2',
"exit 2",
"",
].join("\n"),
);
await fs.chmod(ghPath, 0o755);
vi.stubEnv("REPRO_PR_FIXTURE", fixturePath);
vi.stubEnv("PATH", `${binPath}${path.delimiter}${process.env.PATH ?? ""}`);
const workspace = new Workspace(workspacePath);
await expect(workspace.getPullRequest()).resolves.toMatchObject({
outcome: "found",
pullRequest: { number: 46, state },
});
await runGit(["commit", "--allow-empty", "-m", "New branch work"], {
cwd: workspacePath,
});
const currentHead = await runGit(["rev-parse", "HEAD"], {
cwd: workspacePath,
});
expect(currentHead.stdout).not.toBe(previousHead.stdout);
const result = await workspace.getPullRequest();
console.log(JSON.stringify({ state, newCommit: true, result }));
if (state === "OPEN") {
expect(result).toMatchObject({
outcome: "found",
pullRequest: { number: 46, state: "OPEN" },
});
} else {
expect(result).toEqual({ outcome: "none" });
}
},
);
5. Root cause
Workspace.getPullRequest resolves the local branch and forwards its name to the host lookup. It does not supply the current commit ID or an association epoch.
getPullRequestForCurrentBranch uses a branch-oriented view, then parses the object. There is no open-only query:
const ghArgs = [ "pr", "view", ...(target.outcome === "upstream-branch" ? [target.selector] : []), "--json", GH_PR_VIEW_JSON_FIELDS, ]; const pullRequest = parseGitHostPullRequest(stdout);
The requested fields omit headRefOid. The state check gates only a merge-queue query. A terminal PR still reaches the unconditional found result:
} else {
pullRequest.inMergeQueue = false;
}
return { outcome: "found", pullRequest };
The server read route assembles any available host result. Its assembly maps MERGED to merged and CLOSED to closed; it does not discard them. Consequently an unchanged external branch lookup remains associated even when local HEAD advances.
Importantly, the display contract labels merged/closed states separately. The compact pill exposes the attention label as its tooltip; the metadata row has visible state text. The evidence does not justify claiming BB rewrites a merged PR into an open one.
6. Proposed fix and safe-fix decision
First establish whether this surface is the currently active PR or a historical completion indicator. If it is active-only, the server should apply that product policy consistently to SDK/CLI/UI responses; the host should still return raw host-local facts. If terminal history should remain until a branch is reused, associate it with commit identity or persisted PR identity rather than only a branch name. Commit-aware association requires defining how unpublished commits, fork upstreams, branch rewrites, and squash/rebase merges count as new work.
No automatic PR: a blanket open-only host filter would remove existing terminal-state history, relocate server-owned product policy to the daemon, and change the meaning of the returned result. A commit-aware remedy needs an association policy and possibly a versioned host/server wire change. These violate the autopilot's “no product/architecture decision” and “no public protocol change” conditions. No production fix was attempted, no fix branch was pushed, and no ready PR was opened.
The focused test intentionally specifies the requested current-work behavior; its pre-commit assertion also demonstrates the existing terminal-history behavior that must not be removed accidentally. There is no after-fix claim.
7. Verification
The same agent repeated the reproduction in a second separately cloned checkout at the exact recorded base. This is a second clean run, not independent verification. A new frozen install created its own dependency tree; the Turbo reproduction task was a cache miss. Fresh temporary git repos and executable-response fixtures were created and removed by the test. No port or persistent BB data directory was required.
| Check | Result |
|---|---|
| First checkout: focused Workspace reproduction | 2 expected failures (MERGED, CLOSED), 1 OPEN control passes. Log |
| Second checkout: same focused command | Same 2 expected failures and 1 passing control, uncached execution. Log |
| Real CLI reproduction, first and second checkout | Both return merged PR #4622 after new commits; both production display helpers return “Merged”. First, second |
| Existing host-workspace suite, without the intentionally failing reproduction | 8 files pass; 154 tests pass, 1 skipped. |
| Existing server pull-request assembly suite | 19 tests pass, including terminal-state preservation. |
| Existing app context-banner suite | 33 tests pass, including merged/closed rendering cases. |
Report correction after verification: no correction to the association finding was needed. Scope is explicitly limited to data association and preserved state, not the reporter's private repository or a full browser screenshot. The live command omits the unsupported auto-merge field as disclosed above.
8. Related issues and PR metadata
#3215 requests retaining all PR history, including terminal states. It is a related product concern, not a duplicate of branch reuse. No linked open PR or open PR mentioning #4624 was found when checked before investigation and again before publication. PR #4622 is only public CLI fixture metadata, not a linked fix, and its diff/code was not used.
9. Appendix
Relevant validation commands:
pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build pnpm exec turbo run test --filter=@bb/host-workspace -- issue-4624-repro.test.ts NODE_OPTIONS=--conditions=source pnpm exec tsx --tsconfig apps/app/tsconfig.json apps/app/fixtures/issue-4624-live.tsx pnpm exec turbo run test --filter=@bb/host-workspace pnpm exec turbo run test --filter=@bb/server -- pull-request.test.ts pnpm exec turbo run test --filter=@bb/app -- ThreadPromptContextBanner.test.tsx
Read-only investigation used target-repository issue/PR metadata, GitHub property schemas, label inventory, trusted-main fetches, source searches, and git history/blame. All root-cause permalinks were checked against the recorded base. The primary reproduction artifact is the complete test above and its two raw sanitized logs. Temporary checkout paths in logs are replaced with CHECKOUT-A/B; no user runtime identifiers are published.
Trust boundary: issue text, comments, and attachments were treated only as untrusted claims. No issue instructions, issue command, linked external URL, linked branch code, private data, credential, or added dependency was used. No live BB instance was launched; each scratch git repo was deleted in test cleanup or finally blocks.