#1917 · spawn --base-branch <name> resolves the stale local ref; only remote-prefixed names are fetched

Bug Priority: Medium Effort: — duplicate cli workspaces open on GitHub 2026-08-24 · base 494f66526

Verdict: PARTIALLY REPRODUCED — the behaviour in the issue's title (--base-branch <name> for any plain name) still reproduces at base for every name that is not the checkout's default branch (e.g. --base-branch develop lands on the stale local ref, no fetch, no warning). The one concrete scenario the reporter ran, --base-branch main (the default branch), is ALREADY FIXED by PR #2152 (merge 52083aa55, 2026-08-21, ancestor of base 494f66526), which closed the duplicate target #1770 and explicitly scoped out non-default names. · Root-cause confidence: high

1. TL;DR

When you run bb thread spawn --new-environment worktree --base-branch <name>, bb creates a git worktree starting from <name>. The host daemon's createWorktree() only runs git fetch when the name is remote-qualified (origin/main); a bare name goes verbatim to git worktree add, which resolves the host's local refs/heads/<name>. On a machine that fetches but never pulls, that local ref can be arbitrarily behind the remote, and the spawned agent silently starts on old code.

This issue was filed on 2026-08-19 against that behaviour. Two days later PR #2152 (for the duplicate-target #1770) added a server-side policy: when the named branch equals the checkout's default branch, the server calls host.list_branches (which fetches), compares local vs origin, and rewrites the base to origin/<default> when local is equal or behind. I confirmed on base 494f66526 that --base-branch main now lands on the origin tip and the transcript shows Fetching origin/main. The daemon code the issue points at is unchanged, so --base-branch develop (non-default) still lands on the stale local commit with no fetch step. The PR author documented this as a known residual in docs/worktrees.md.

2. Claims vs findings

Claim from the issueStatusEvidence
createWorktree() calls fetchRemoteBaseBranch() only when baseBranch contains a remote prefix, then passes the bare name to git worktree add.VerifiedresolveRemoteBaseBranch returns null for names without / (provisioning.ts:240); fetchRemoteBaseBranch returns early (285); gitArgs uses the bare name (388–395). Daemon-level unit test below: no Fetching step for main or develop.
A bare main resolves against the host's refs/heads/main, which can be far behind.Verified at the daemon; Refuted end-to-end at baseDaemon test: worktree HEAD = local main (cea9901) not origin (e97ff2a). But the live spawn on base 494f66526 landed on origin/main (9ff91e3) because the server rewrote the spec (PR #2152). Issue was filed before that merge.
The existing test fetches remote base branches before creating worktrees covers only origin/main; bare-name path has no coverage.Verified (daemon); partially staleprovisioning.test.ts:204 uses baseBranch: "origin/main". Since #2152, apps/server/test/public/public-threads.named-base-branch.test.ts covers bare main at the server layer (11/11 pass at base). No test anywhere asserts a fetch for a bare non-default name; the existing server test for release/2026-05 asserts the opposite (spec unchanged).
Silent: spawn reports success; nothing indicates the base was stale.Verified (for non-default names)--base-branch develop spawn: exit 0, bb thread show reports baseBranch: "develop", transcript has no fetch step, worktree HEAD is 1 behind origin/develop. No warning anywhere.
Recurring on every spawn until someone refreshes the local ref by hand.Verified (mechanism)Nothing in the daemon path updates refs/heads/<name>; host.list_branches only refreshes remote-tracking refs (git fetch --all --prune --quiet, host-branches.ts:264 → fetchRemoteBranches).
"Measured on a working host: local main was 5 commits behind"; "~70 PRs behind, docs 11 releases stale".UnverifiedReporter's anecdotes; not reproducible here. The mechanism is real; the numbers are whatever the host's local ref happened to be.
Workaround: --base-branch origin/main takes the fetch path.VerifiedCode path (provisioning.ts:234–329) and the existing test at provisioning.test.ts:204.
Suggested resolution (1): resolve <branch> against origin/<branch> when a remote-tracking ref exists.Partially implemented#2152 implements this only when <branch> is the default branch (resolveManagedNamedBaseBranchSpec returns the spec unchanged otherwise). The issue asks for it for any branch.

3. Environment

4. Minimal reproduction

4a. Daemon-level unit test (fails at base for both main and develop)

This exercises exactly the function the issue names, createWorktree() in packages/host-workspace/src/provisioning.ts, with a bare base name and a local ref one commit behind origin. It asserts the behaviour the issue asks for (a fetch step, worktree at the origin tip), so it fails on base. Both cases fail identically, which shows the daemon is unchanged; the main case is rescued one layer up by the server (4b).

  1. Save 1917/repro/issue-1917-bare-base-branch.test.ts as packages/host-workspace/test/issue-1917-bare-base-branch.test.ts in a bb worktree at 494f66526 (after pnpm install --frozen-lockfile and pnpm exec turbo run build).
  2. Run it (or run run-unit-tests.sh <worktree>, which does steps 1–2 plus the #2152 server tests):
    cd packages/host-workspace && pnpm exec vitest run test/issue-1917-bare-base-branch.test.ts
  3. Expected: 2 passed (a Fetching step and worktree HEAD = origin tip). Actual (full output, colour codes stripped):
    [#1917 main] local=cea9901 origin=e97ff2a worktree=cea9901 steps=["Creating worktree","Created worktree","Using workspace: …/feature"]
    [#1917 develop] local=65823e3 origin=7ca0731 worktree=65823e3 steps=["Creating worktree","Created worktree","Using workspace: …/feature"]
    
     ❯  @bb/host-workspace  test/issue-1917-bare-base-branch.test.ts (2 tests | 2 failed) 714ms
         × starts the worktree from origin/main when local main is behind
         × starts the worktree from origin/develop when local develop is behind
    AssertionError: expected false to be true // Object.is equality
     ❯ test/issue-1917-bare-base-branch.test.ts:106:59
        106|       expect(steps.some((s) => s.startsWith("Fetching"))).toBe(true);
          Tests  2 failed (2)
    The worktree HEAD equals the local ref (cea9901 / 65823e3), not origin, and there is no fetch step in the transcript.
// packages/host-workspace/test/issue-1917-bare-base-branch.test.ts (excerpt; full file linked above)
for (const branch of ["main", "develop"]) {
  describe(`createWorktree with bare --base-branch ${branch} (#1917)`, () => {
    it(`starts the worktree from origin/${branch} when local ${branch} is behind`, async () => {
      const { repoPath, remotePath } = await initRemoteBackedRepo(branch);
      const remoteHead = await pushRemoteCommit(remotePath, branch);
      // host fetches but never pulls: origin/<branch> fresh, refs/heads/<branch> 1 behind
      await runGit(["fetch", "origin"], { cwd: repoPath });
      …
      await createWorktree({ sourcePath: repoPath, targetPath, branchName: "feature",
        baseBranch: branch, timeoutMs: 60_000,
        onProgress: (e) => { if (e.type === "step") steps.push(e.text); } });
      const worktreeHead = (await runGit(["rev-parse", "HEAD"], { cwd: targetPath })).stdout.trim();
      expect(steps.some((s) => s.startsWith("Fetching"))).toBe(true);   // FAILS at base
      expect(worktreeHead).toBe(remoteHead);                              // would also fail
    });
  });
}

4b. End-to-end through the CLI on a running instance

Every command below takes the path of your bb worktree ($WT, checked out at 494f66526, built, with scripts/bb-dev-app current running) and derives the host id, project id, environment ids and data dir from the instance itself; nothing instance-specific is hardcoded. The ids shown in the output are from my run and will differ on yours.

  1. Build the scratch repos (bare origin at /tmp/bb-1917/origin.git, a checkout at /tmp/bb-1917/checkout whose local main and develop are each one commit behind origin, remote-tracking refs fresh): setup-scratch-repos.sh (output)
    bash setup-scratch-repos.sh
    --- checkout state: local is behind origin on both branches
    main     local=71100a4 origin=9ff91e3
    develop  local=4a3e333 origin=099916e
    * 9ff91e3 B: pushed by someone else (main)
    | * 099916e E: pushed by someone else (develop)
    | * 4a3e333 D: develop initial
    |/
    * 71100a4 A: initial
  2. Register the checkout as a project on your instance. e2e-create-project.sh runs eval "$(scripts/bb-dev-app env)", reads the host id from pnpm bb:dev machine list --json (machine-list.json), POSTs the project (create-project.json) and prints the project id to use next:
    bash e2e-create-project.sh "$WT"
    BB_SERVER_URL=http://localhost:22461
    HOST=host_u3tk78vmxr
    PROJ=proj_igv9vj9edp
    
    # the POST it sends, with $HOST substituted:
    curl -s -X POST $BB_SERVER_URL/api/v1/projects -H 'content-type: application/json' \
      -d '{"name":"qa-1917","source":{"type":"local_path","path":"/tmp/bb-1917/checkout","hostId":"'$HOST'"}}'
  3. Spawn once with the default branch name and once with a non-default name, using the project id from step 2. e2e-spawn.sh runs the two commands below and saves the responses (spawn-main.json, spawn-develop.json):
    bash e2e-spawn.sh "$WT" proj_igv9vj9edp
    spawn main exit=0 thread=thr_9ehmakdvgx status=starting
    spawn develop exit=0 thread=thr_ed79un6mt9 status=starting
    
    # the commands it runs:
    pnpm bb:dev thread spawn --project $PROJ --new-environment worktree --base-branch main \
      --provider codex --permission-mode accept-edits --title "1917 main" --prompt "Reply only with ok." --json
    pnpm bb:dev thread spawn --project $PROJ --new-environment worktree --base-branch develop \
      --provider codex --permission-mode accept-edits --title "1917 develop" --prompt "Reply only with ok." --json
    Both exit 0 and report "status": "starting"; the id field of each response is the thread id for step 4.
  4. Inspect where each worktree landed, passing the two thread ids from step 3. inspect-worktrees.sh waits for provisioning, reads .thread.environmentId from pnpm bb:dev thread show <id> --json (saved as thread-show-main.json / thread-show-develop.json), takes the data dir from scripts/bb-dev-app status, compares the worktree HEAD with the checkout's local and origin refs, and dumps the provisioning transcript from the events table (raw). Output of my run (inspect-worktrees.out):
    bash inspect-worktrees.sh "$WT" thr_9ehmakdvgx thr_ed79un6mt9
    data dir = /Users/USER/.bb-dev/bb-machines-HOST.getbb.app-checkouts-bb-.claude-worktrees-wf_846839f8-f8a-29-a7d581c5f92f
    === --base-branch main  (thr_9ehmakdvgx, env_ypsmassjfd)
      thread show: baseBranch="origin/main" defaultBranch="main" branchName=bb/1917-main-thr_9ehmakdvgx
      checkout local main     = 71100a4
      checkout origin/main    = 9ff91e3
      worktree HEAD           = 9ff91e3  (B: pushed by someone else (main))
      files in worktree       = file.txt remote.txt
      RESULT: worktree is at origin/main tip (fresh)                       <-- fixed by #2152
    === --base-branch develop  (thr_ed79un6mt9, env_zad3hx3gpt)
      thread show: baseBranch="develop" defaultBranch="main" branchName=bb/1917-develop-thr_ed79un6mt9
      checkout local develop  = 4a3e333
      checkout origin/develop = 099916e
      worktree HEAD           = 4a3e333  (D: develop initial)
      files in worktree       = dev.txt file.txt
      RESULT: worktree is BEHIND origin/develop (stale local ref used)     <-- still the bug
    === provisioning transcript (events table)
      thr_9ehmakdvgx  step   workspace-started        Preparing worktree
      thr_9ehmakdvgx  step   git-fetch-started        Fetching origin/main
      thr_9ehmakdvgx  step   git-fetch-completed      Fetched origin/main
      thr_9ehmakdvgx  step   git-worktree-started     Creating worktree
      thr_9ehmakdvgx  output git-worktree-output-1    branch 'bb/1917-main-thr_9ehmakdvgx' set up to track 'origin/main'.
      thr_9ehmakdvgx  output git-worktree-output-2    HEAD is now at 9ff91e3 B: pushed by someone else (main)
      thr_9ehmakdvgx  output git-worktree-output-3    Preparing worktree (new branch 'bb/1917-main-thr_9ehmakdvgx')
      thr_9ehmakdvgx  step   git-worktree-completed   Created worktree
      thr_9ehmakdvgx  step   workspace-branch         Using branch: bb/1917-main-thr_9ehmakdvgx (9ff91e3)
      thr_ed79un6mt9  step   workspace-started        Preparing worktree
      thr_ed79un6mt9  step   git-worktree-started     Creating worktree
      thr_ed79un6mt9  output git-worktree-output-1    HEAD is now at 4a3e333 D: develop initial
      thr_ed79un6mt9  output git-worktree-output-2    Preparing worktree (new branch 'bb/1917-develop-thr_ed79un6mt9')
      thr_ed79un6mt9  step   git-worktree-completed   Created worktree
      thr_ed79un6mt9  step   workspace-branch         Using branch: bb/1917-develop-thr_ed79un6mt9 (4a3e333)
    Expected for develop: HEAD 099916e with remote-dev.txt and a Fetching origin/develop step. Actual: HEAD 4a3e333, the stale local ref, remote-dev.txt absent, no fetch step. The server rewrote the main spec to origin/main (the #2152 policy) and left develop verbatim.
Thread page for --base-branch develop showing provisioning transcript without a fetch step and HEAD at 4a3e333
--base-branch develop on base 494f66526 (thread thr_ed79un6mt9): the "Provisioned thread" transcript has no fetch step and reports HEAD is now at 4a3e333 D: develop initial, which is the stale local commit (origin/develop is 099916e). Nothing on the page says the base is behind.
Thread page for --base-branch main showing Fetching origin/main and HEAD at 9ff91e3
--base-branch main on the same base (thread thr_9ehmakdvgx): transcript shows Fetching origin/main, Fetched origin/main, and HEAD is now at 9ff91e3 B: pushed by someone else (main), the origin tip. This is the #2152 fix in action.

Repro files: 1917/repro/

5. Root cause

Daemon layer (unchanged since the issue; the mechanism the issue describes). resolveRemoteBaseBranch returns null for any base without a /, so fetchRemoteBaseBranch is a no-op, and createWorktree runs git worktree add -B <branch> <target> <bareName>:

async function resolveRemoteBaseBranch(sourcePath, baseBranch, …) {
  if (!baseBranch.includes("/")) {
    return null;                       // bare name: no remote, no fetch
  }
  …
}
…
  await fetchRemoteBaseBranch({ sourcePath: args.sourcePath, baseBranch, … });   // returns early for bare names
  const gitArgs = ["worktree", "add", "-B", args.branchName, args.targetPath, baseBranch];

Git resolves a bare develop via refs/heads/develop first. That ref is only moved by a checkout/pull on the host; a fetch updates refs/remotes/origin/develop only. So the worktree starts from whatever the host last pulled, and the transcript just echoes HEAD is now at <stale sha> with no comparison to origin.

Server layer (where #2152 fixed the reported case). resolveManagedBaseBranchForCreate calls host.list_branches (which runs a throttled git fetch --all --prune --quiet on the daemon via fetchRemoteBranches, host-branches.ts:264) and then routes named specs through resolveManagedNamedBaseBranchSpec:

export function resolveManagedNamedBaseBranchSpec(spec, checkout) {
  if (spec.name !== checkout.defaultBranch) {
    return spec;                       // non-default plain name: untouched -> stale local ref downstream
  }
  const resolved = resolveDefaultWorktreeBaseBranch(checkout);   // origin/<default> when equal or local-behind
  return resolved && resolved !== spec.name ? { kind: "named", name: resolved } : spec;
}

So the fix is a policy rewrite main → origin/main that only fires for the default branch, because host.list_branches only reports a local-vs-origin relation (defaultBranchRelation) for the default branch. For any other name the server has no relation data, returns the spec verbatim, and the daemon path above does the rest. docs/worktrees.md at base says so explicitly (lines 45–47): "Any other plain name starts from that local branch as it is; pass origin/<name> to fetch and start from the remote branch."

Why it is silent. No layer compares the chosen start point against its upstream for non-default names. The transcript prints git's own HEAD is now at … line, which is correct for what git was asked to do. bb thread show stores the spec the server settled on ("develop"), which looks like exactly what the user typed.

Deeper issue. The "resolve against origin when safe" policy now lives in two asymmetrical places: the server rewrites only the default branch name (with an ahead/diverged guard), while the daemon fetches only remote-qualified names (with no guard, since origin/x is unambiguous). Any plain name that is not the default falls through both.

6. Proposed fix (first principles)

Generalise the server policy that #2152 added from "the default branch" to "any named branch that has an origin/<name> remote-tracking ref", keeping the same guard (rewrite only when local is equal or behind; keep local when ahead, diverged, or unknown). The server cannot do that today because the daemon only reports the relation for the default branch, so:

  1. Daemon (raw data): in listHostBranches (apps/host-daemon/src/command-handlers/host-branches.ts), when command.selectedBranch is set, compute selectedBranchRelation for refs/heads/<selected> vs refs/remotes/origin/<selected>, plus selectedBranchOrigin: "origin/<selected>" | null. The comparison logic already exists as readDefaultBranchRelation(cwd, localDefaultBranch, originDefaultBranch) in packages/host-workspace/src/git.ts, but it is module-private (no export) and named/typed for the default branch, so export and generalise it to readBranchRelation(cwd, localBranch, originBranch) (its body is already branch-agnostic: two rev-parse plus up to two merge-base --is-ancestor). The relation enum is the domain type DefaultBranchRelation (equal | local-behind | local-ahead | diverged | unknown) in packages/domain/src/git-checkout.ts; reuse it. The host.list_branches result schema is projectSourceCheckoutSchema from that same domain file (referenced as resultSchema in host-daemon-contract/src/commands.ts:1894), so the two new fields go on projectSourceCheckoutSchema, and HOST_DAEMON_PROTOCOL_VERSION must be bumped (164 → 165 in packages/host-daemon-contract/src/protocol.ts:303), since the result shape changes and old daemons would omit the fields.
  2. Server (policy): in resolveManagedBaseBranchForCreate pass selectedBranch: args.baseBranch.name for named specs; in resolveManagedNamedBaseBranchSpec drop the spec.name !== checkout.defaultBranch early return and apply the equal/local-behind → origin/<name> rule using selectedBranchRelation / selectedBranchOrigin (fall back to the default-branch fields when the name is the default, so existing tests keep passing). A name with no remote-tracking counterpart stays local (the issue's case 2).
  3. Tests: extend apps/server/test/public/public-threads.named-base-branch.test.ts with "named release/2026-05 with local-behind → origin/release/2026-05" and "named non-default with no origin → unchanged" (the existing "release/2026-05 unchanged" case must be updated: it currently asserts the residual behaviour). Add a daemon test for the new result fields. The daemon test in 4a is not the right regression test, because the server is the layer that owns this policy; keep createWorktree as the dumb primitive it is.
  4. Docs: update the sentence in docs/worktrees.md (lines 45–47) and the --base-branch help text in apps/cli/src/commands/thread/spawn.ts (lines 181–183: "naming the default branch fetches and prefers origin the same way") that currently promise local-only behaviour for non-default names.

What could go wrong: a remote named something other than origin (the default-branch path already assumes origin; keep that assumption and document it). A checkout with many remotes where origin/<name> is absent should stay local, not error. The extra relation computation is two rev-parse and up to two merge-base --is-ancestor calls, which is negligible next to the fetch that already runs. The alternative of doing the rewrite inside the daemon's createWorktree would be simpler but puts product policy in the daemon, against the server/daemon split in AGENTS.md, and would diverge from how the default-branch case is already handled.

7. PR review

No open PRs are linked to #1917. The relevant merged PR is #2152 (for #1770), reviewed here only to establish that the reported case is fixed: it is server-only (no wire change, correctly no protocol bump), rewrites a named default-branch spec to origin/<default> when local is equal/behind, keeps local when ahead/diverged/unknown, and explicitly scopes out non-default names in its description and in docs/worktrees.md. Its tests pass at base (11/11). Nothing in it is wrong; it is just narrower than what #1917 asks for.

8. Related issues

Recommendation for the issue: the duplicate label only covers the --base-branch main scenario. Either reopen #1770 or retitle this one to the residual ("plain non-default --base-branch <name> is not resolved against origin/<name>") so the remaining gap is tracked rather than closed as a duplicate of a fixed issue.

9. Appendix

Commands run (revised run, all from the bb worktree at 494f66526)

gh issue view 1917 --repo get-bb/bb --json …            # body, labels (duplicate, cli, workspaces), no comments
gh issue view 1770 --repo get-bb/bb …                     # duplicate target, closed COMPLETED 2026-08-21
gh api repos/get-bb/bb/issues/1770/timeline               # cross-ref PR 2152, merge 52083aa55
git merge-base --is-ancestor 52083aa55 494f66526           # -> IS ancestor (fix is in base)
git fetch origin main; git log 494f66526..origin/main -- packages/host-workspace/src/provisioning.ts …   # empty (origin/main b735da19e)
git checkout 494f66526
bash /tmp/bb-reports/issues/1917/repro/install-and-build.sh "$WT"      # pnpm install + turbo build, exit 0
bash /tmp/bb-reports/issues/1917/repro/run-unit-tests.sh "$WT"         # daemon test 2 failed (bug); server tests 11 passed
scripts/bb-dev-app current                                  # App :14461, Server :22461, Host daemon :30461
bash /tmp/bb-reports/issues/1917/repro/setup-scratch-repos.sh
bash /tmp/bb-reports/issues/1917/repro/e2e-create-project.sh "$WT"     # host_u3tk78vmxr -> proj_igv9vj9edp
bash /tmp/bb-reports/issues/1917/repro/e2e-spawn.sh "$WT" proj_igv9vj9edp   # thr_9ehmakdvgx (main), thr_ed79un6mt9 (develop)
bash /tmp/bb-reports/issues/1917/repro/inspect-worktrees.sh "$WT" thr_9ehmakdvgx thr_ed79un6mt9
doobie --headless < screenshot-{develop,main}.js            # screenshots of /projects/proj_igv9vj9edp/threads/<id> with "Provisioned thread" expanded
pnpm dev:stop; doobie stop; rm -rf <data dir> /tmp/bb-1917

Provisioning transcript entries (from the events table, revised run)

thr_9ehmakdvgx (--base-branch main)
    step   git-fetch-started      Fetching origin/main
    step   git-fetch-completed    Fetched origin/main
    step   git-worktree-started   Creating worktree
    output                        branch 'bb/1917-main-thr_9ehmakdvgx' set up to track 'origin/main'.
    output                        HEAD is now at 9ff91e3 B: pushed by someone else (main)
    step   git-worktree-completed Created worktree
    step   workspace-branch       Using branch: bb/1917-main-thr_9ehmakdvgx (9ff91e3)

thr_ed79un6mt9 (--base-branch develop)
    step   git-worktree-started   Creating worktree
    output                        HEAD is now at 4a3e333 D: develop initial
    output                        Preparing worktree (new branch 'bb/1917-develop-thr_ed79un6mt9')
    step   git-worktree-completed Created worktree
    step   workspace-branch       Using branch: bb/1917-develop-thr_ed79un6mt9 (4a3e333)

Artifacts

Verification

An independent verifier followed the report in a fresh worktree at 494f66526 with its own dev instance (App :18328 / Server :26328 / daemon :34328). The daemon-level test failed exactly as shown (2 failed, AssertionError: expected false to be true on the Fetching step, for both main and develop); #2152's server tests passed 11/11; the end-to-end spawns reproduced the same split (--base-branch main → baseBranch "origin/main", worktree at the origin tip, Fetching origin/main in the transcript; --base-branch develop → baseBranch "develop", worktree at the stale local commit, no fetch step, remote-dev.txt absent); every code claim (provisioning.ts lines, resolveManagedNamedBaseBranchSpec early return, git fetch --all --prune --quiet, protocol version 164, docs and help text wording, #2152 merge and scope) checked out; both screenshots matched their captions. Its artifacts are in 1917/verify/.

Findings and what changed in this revision: