← reports

#4624 · Historical PR association survives new commits

Bug Priority: Medium Effort: Low workspaces github GitHub issue
October 1, 2026 · base 08cbb7149a51522ed148e1ca8c4d5f7306a927e1

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

ClaimFindingEvidence
A terminal PR can remain associated after new branch commits.VerifiedTwo real-git Workspace test runs return found for MERGED and CLOSED after HEAD changes.
The lookup selects by branch without limiting to open PRs.VerifiedProduction 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 mainProduction 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.UnverifiedNo 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 generalizationWe verify the no-open-PR terminal case, not GitHub CLI's entire selection algorithm.

3. Environment

4. Minimal reproduction

Deterministic owner-boundary regression

  1. 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
  2. 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.
  3. Run:
    pnpm exec turbo run test --filter=@bb/host-workspace -- issue-4624-repro.test.ts
  4. 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.

CheckResult
First checkout: focused Workspace reproduction2 expected failures (MERGED, CLOSED), 1 OPEN control passes. Log
Second checkout: same focused commandSame 2 expected failures and 1 passing control, uncached execution. Log
Real CLI reproduction, first and second checkoutBoth return merged PR #4622 after new commits; both production display helpers return “Merged”. First, second
Existing host-workspace suite, without the intentionally failing reproduction8 files pass; 154 tests pass, 1 skipped.
Existing server pull-request assembly suite19 tests pass, including terminal-state preservation.
Existing app context-banner suite33 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.