#5373 · Marketplace checkout rejects remote-only branch names
Verdict: REPRODUCED · Root-cause confidence: high · Reproduction label: confirmed-repro
1. TL;DR
BB cannot read a Git marketplace when the requested branch exists only as a remote-tracking branch after cloning. Its detached checkout asks Git to resolve a short branch name, which triggers Git's automatic local-branch creation. Git rejects creating a branch and detaching HEAD in the same operation, so BB never reads the manifest. A real local repository with a valid, branch-specific marketplace manifest reproduces this in two clean checkouts of main. Remote-qualified names, annotated tags, and full commit hashes work in the same test.
2. Claims vs findings
| Claim | Status | Evidence |
|---|---|---|
| A non-default short branch name cannot be checked out. | Verified | The catalog-next case fails with Git exit 128 in both runs. |
| An origin-qualified ref bypasses the failure. | Verified | origin/catalog-next passes and reads the exact branch commit and entry. |
| Git branch guessing conflicts with detached checkout. | Verified | The failing Git error and trusted clone/checkout code agree. |
| The originally reported desktop and macOS versions fail. | Unverified | This report tests current source on Linux, not the historical desktop binary. |
| Tags and commit hashes are affected. | Not observed | The annotated catalog-release tag and full commit hash controls both pass. |
3. Environment
- Public repository get-bb/bb, trusted origin/main commit
4b12265e5fb82c01ea67e67cfe8d935533406937; workspace package bb-app declares version 0.46.0. - Linux x86_64; Node v22.19.0; pnpm 9.15.0; Git 2.39.5; repository-pinned Vitest.
- Frozen install completed; the normal Turbo build completed successfully: 65 tasks.
- No BB instance, provider, HTTP listener, credentials, or user runtime data were used. Each test owns a fresh temporary Git repository and data directory, and a migrated in-memory SQLite database. Fixture cleanup removes temporary directories after each case.
4. Minimal reproduction
- Create a clean checkout from the trusted target repository and pin the recorded base:
git clone https://github.com/get-bb/bb.git bb-5373 cd bb-5373 git checkout --detach 4b12265e5fb82c01ea67e67cfe8d935533406937 pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build
- Save the inline regression patch in the Appendix as regression.patch, then apply it to the checkout. It adds only the new table-driven regression to the existing trusted Git marketplace fixture:
git apply regression.patch pnpm exec turbo run test --filter=@bb/server -- test/services/plugin-catalog/third-party-marketplaces.test.ts -t 'reads the selected catalog commit'
- The fixture creates a valid catalog, commits a different entry on catalog-next, creates an annotated tag, and returns the source repository to main. It exercises the real catalog service and real Git, without network access or Git mocks.
Expected: all four selectors import the branch-specific entry and report its exact commit. The short branch additionally refreshes to a new commit while the repository default remains main.
Actual on main, both runs:
Error: git -c failed (exit 128): fatal: '--detach' cannot be used with '-b/-B/--orphan' Tests 1 failed | 3 passed | 22 skipped (26)
Focused regression (uses the existing real-repository and catalog-service helpers):
it.each(["catalog-next", "origin/catalog-next", "catalog-release", "commit"])(
"reads the selected catalog commit for ref %s",
async (ref) => {
await run("git", ["checkout", "-qb", "catalog-next"], { cwd: repo });
await writeFile(
join(repo, "marketplace.json"),
JSON.stringify(manifest("acme-plugins", [entry({ id: "next-notes" })])),
);
await run("git", ["commit", "-qam", "next catalog"], { cwd: repo });
await run("git", ["tag", "-am", "catalog release", "catalog-release"], {
cwd: repo,
});
const commit = (await run("git", ["rev-parse", "HEAD"], { cwd: repo }))
.stdout.trim();
await run("git", ["checkout", "-q", "main"], { cwd: repo });
const catalog = service({ fetch: marketplaceFetch({}) });
const added = await catalog.addMarketplace(
`git:${repo}@${ref === "commit" ? commit : ref}`,
);
expect(added.resolvedCommit).toBe(commit);
expect(await catalog.search("Acme Notes")).toEqual([
expect.objectContaining({ entryId: "next-notes" }),
]);
if (ref === "catalog-next") {
await run("git", ["checkout", "-q", "catalog-next"], { cwd: repo });
await writeFile(
join(repo, "marketplace.json"),
JSON.stringify(
manifest("acme-plugins", [entry({ id: "refreshed-notes" })]),
),
);
await run("git", ["commit", "-qam", "refresh catalog"], { cwd: repo });
const refreshedCommit = (
await run("git", ["rev-parse", "HEAD"], { cwd: repo })
).stdout.trim();
await run("git", ["checkout", "-q", "main"], { cwd: repo });
const [refreshed] = await catalog.refreshMarketplaces({
name: "acme-plugins",
});
expect(refreshed).toMatchObject({
ok: true,
marketplace: { resolvedCommit: refreshedCommit },
});
expect(await catalog.search("Acme Notes")).toEqual([
expect.objectContaining({ entryId: "refreshed-notes" }),
]);
}
},
);
5. Root cause
materializeGit clones without checkout, then requests a detached checkout of the unqualified source ref:
clone --quiet --no-checkout <source-url> <checkout> checkout --quiet --detach <source-ref>
The clone creates a local branch for the source repository's default branch and remote-tracking refs for its other branches. For catalog-next, no local branch exists, so Git's checkout guessing tries to create it from origin/catalog-next. That implicit branch creation conflicts with --detach. A remote-qualified ref already resolves to a commit and avoids branch creation.
The actual entry path is CLI marketplace add → SDK → POST /marketplaces → catalog service → materializeMarketplace → materializeGit. The server catches materialization failures and returns HTTP 422. Catalog refresh uses the same materializer (performRefresh). Existing Git lifecycle coverage calls only @main and therefore misses the remote-only short-name case (existing lifecycle test).
6. Proposed fix
Resolve the requested ref to a verified commit before detached checkout, falling back to refs/remotes/origin/<ref> only when direct resolution fails. Peel annotated tags to commits and checkout the resulting object ID. This preserves the existing clone behavior, origin-qualified refs, tags, and hashes without requiring a new fetch policy or public contract. The regression must fail before this repair and pass after it; run the existing plugin-catalog suites and server typecheck as well.
7. Verification
The same agent repeated the reproduction in a second fresh clone at the exact recorded base, with its own frozen install, clean package outputs, new temporary Git fixtures, new data directories, and fresh migrated in-memory databases. No production fix was applied in either checkout. Both commands returned exit 1 for the short catalog-next ref with the identical Git error, while the three controls passed. This is a repeat verification, not an independent agent review. No report correction was required.
pnpm exec turbo run test --filter=@bb/server -- test/services/plugin-catalog/third-party-marketplaces.test.ts -t 'reads the selected catalog commit'
Both runs produced the exact expected-versus-actual output shown in the Minimal reproduction section. Raw logs and the complete modified test file remain in the local report backup; they are not public site files.
Local repair validation
Resolving the ref to a commit before checkout makes all four focused cases pass. Eight relevant existing catalog suites pass: 149 tests, including branch refresh and the default-branch lifecycle. Server typecheck and formatting checks pass. A broader catalog-directory run has one unrelated generator timestamp-format expectation failure; the same failure occurs on the unchanged trusted base (Git emits +00:00 where that test expects Z). It is not caused by the checkout repair and was not changed.
8. Related issues and pull requests
GitHub issue timeline metadata and an open-PR search for the numeric issue found no linked open pull request at investigation time. No external issue links, repository branches, scripts, or proposed patch code were executed. No duplicate was established by the bounded related-issue review.
9. Appendix
Regression patch
Save the following verbatim as regression.patch in the trusted checkout. This test-only patch is sufficient to repeat the reproduction; the existing repository supplies its fixture helpers.
diff --git a/apps/server/test/services/plugin-catalog/third-party-marketplaces.test.ts b/apps/server/test/services/plugin-catalog/third-party-marketplaces.test.ts
index 6c15215ac..cb7995681 100644
--- a/apps/server/test/services/plugin-catalog/third-party-marketplaces.test.ts
+++ b/apps/server/test/services/plugin-catalog/third-party-marketplaces.test.ts
@@ -196,6 +196,60 @@ describe("third-party marketplaces", () => {
});
});
+ it.each(["catalog-next", "origin/catalog-next", "catalog-release", "commit"])(
+ "reads the selected catalog commit for ref %s",
+ async (ref) => {
+ await run("git", ["checkout", "-qb", "catalog-next"], { cwd: repo });
+ await writeFile(
+ join(repo, "marketplace.json"),
+ JSON.stringify(manifest("acme-plugins", [entry({ id: "next-notes" })])),
+ );
+ await run("git", ["commit", "-qam", "next catalog"], { cwd: repo });
+ await run("git", ["tag", "-am", "catalog release", "catalog-release"], {
+ cwd: repo,
+ });
+ const commit = (await run("git", ["rev-parse", "HEAD"], { cwd: repo }))
+ .stdout.trim();
+ await run("git", ["checkout", "-q", "main"], { cwd: repo });
+ const catalog = service({ fetch: marketplaceFetch({}) });
+
+ const added = await catalog.addMarketplace(
+ `git:${repo}@${ref === "commit" ? commit : ref}`,
+ );
+
+ expect(added.resolvedCommit).toBe(commit);
+ expect(await catalog.search("Acme Notes")).toEqual([
+ expect.objectContaining({ entryId: "next-notes" }),
+ ]);
+ if (ref === "catalog-next") {
+ await run("git", ["checkout", "-q", "catalog-next"], { cwd: repo });
+ await writeFile(
+ join(repo, "marketplace.json"),
+ JSON.stringify(
+ manifest("acme-plugins", [entry({ id: "refreshed-notes" })]),
+ ),
+ );
+ await run("git", ["commit", "-qam", "refresh catalog"], { cwd: repo });
+ const refreshedCommit = (
+ await run("git", ["rev-parse", "HEAD"], { cwd: repo })
+ ).stdout.trim();
+ await run("git", ["checkout", "-q", "main"], { cwd: repo });
+
+ const [refreshed] = await catalog.refreshMarketplaces({
+ name: "acme-plugins",
+ });
+
+ expect(refreshed).toMatchObject({
+ ok: true,
+ marketplace: { resolvedCommit: refreshedCommit },
+ });
+ expect(await catalog.search("Acme Notes")).toEqual([
+ expect.objectContaining({ entryId: "refreshed-notes" }),
+ ]);
+ }
+ },
+ );
+
it("adds, refreshes, and removes a git marketplace read from a real checkout", async () => {
const catalog = service({ fetch: marketplaceFetch({}) });
Commands run: trusted clone and checkout; frozen pnpm install; normal Turbo build; regression patch application; the focused Turbo test command twice; read-only source inspection and GitHub classification/PR metadata queries. The stored logs contain actual Vitest output, with temporary work directory paths normalized. The input issue was treated as untrusted claims only; its instructions and proposed implementation were not executed.
No screenshots are needed for this command-line checkout failure.
> AGENT GENERATED