Skip to content

Branch Config

Branch detection and merge behavior, configured per-project in .claude/settings.local.json under the _branch key.

Script: ~/.claude/scripts/branch-detect.sh — single source of truth for branch resolution across all commands and hooks.

Field Type Default Description
_branch.main string|null auto-detect Override main branch name
_branch.session string|null unset (no stream) The declared integration/session branch — written by bob warp session start / /start-work, never inferred from HEAD (#2313)
_branch.merge_strategy "ff-only" | "merge-commit" | "squash" "ff-only" Merge strategy for /finish-work and warp-drive
_branch.scope {"area": "<slug>"} | {"flightplan": "<N>"} | absent none Stream↔scope binding (#1177): the warp-drive scope bound to the session branch

When _branch is not set, branch-detect.sh auto-detects:

  1. Main branch: origin/HEAD → local main → local master → error
  2. Session branch: the _branch.session pin, otherwise empty — declared, never inferred (#2313)
  3. Merge target: session branch if declared, otherwise main
  4. Merge strategy: ff-only

The session branch is never taken from HEAD. Until #2313, Step 6 adopted any checked-out non-main branch as the session when no pin was set, so a per-item feature/issue-N-x branch became merge_target and an L3 run stacked every later item on it — 24 commits that never reached master (flight plan #2092). Now an unpinned run on any branch resolves merge_target to main; a stream exists only when bob warp session start <branch> (or /start-work) has written the pin. /warp-drive init warns when HEAD is a non-main branch without a pin, naming the branch and the merge target it will use (recorded as branch_warning on the run state and shown by bob warp status). tests/test-branch-detect.sh pins the rule.

Most repos need no configuration — detection just works.

{}

Output on a main-based repo while on feature/foo (no pin — the feature branch is not the session, and the merge target is main):

{"main":"main","session":"","current":"feature/foo","on_main":false,"merge_target":"main","merge_strategy":"ff-only","scope":""}

Pin session branch so it persists even when on main (e.g., long-running dev branch):

{
"_branch": {
"session": "dev/sprint-3"
}
}

Bind a warp-drive scope (an area workstream or a flight-plan issue) to the integration branch when opening the stream, so every kickoff in the checkout lines up with it automatically:

Terminal window
bob warp session start dev/warp-stream --area warp-drive # or: --flightplan 1078
{
"_branch": {
"session": "dev/warp-stream",
"scope": { "area": "warp-drive" }
}
}

The binding is enforced at /warp-drive kickoff by resolveScopeBinding() (scripts/warp-drive/kickoff.js), feeding the run’s normal session.kickoff state so scope precedence stays in kickoffScope() (#992):

  • No scope flags → the kickoff defaults to the bound scope; an unscoped run on a bound stream can never reach the full approved queue.
  • Explicit matching scope → passes through unchanged.
  • Explicit --issue → allowed; a named issue is explicit operator intent strictly narrower than the binding.
  • Different area/plan (or mixed kinds) → refused with a message naming the bound stream and how to re-bind or end it.

Because it lives in settings.local.json, the binding survives session end, segmentation, and machine restart — and it is per-checkout, so cdfork worktrees each carry their own binding and parallel streams on different areas coexist. bob warp session end and bob warp finalize clear it together with the branch pin; to run a different scope, re-bind (bob warp session start <branch> --area <slug>) or end the stream first. A scope recorded without a session pin is inert.

branch-detect.sh reports the binding as a scope field ("area:<slug>" / "flightplan:<N>", empty when unbound; also via --field scope); bob warp session status and bob warp status display it omit-when-empty.

For repos using develop as the integration branch:

{
"_branch": {
"main": "develop"
}
}

For projects that prefer squash merges:

{
"_branch": {
"merge_strategy": "squash"
}
}
Strategy Git flag Behavior
ff-only --ff-only Fast-forward only, fails if diverged
merge-commit --no-ff Always create a merge commit
squash --squash Squash all commits into one

Unified freshness/finalize policy (#1178/#1179)

Section titled “Unified freshness/finalize policy (#1178/#1179)”

Integration-branch streams follow one sync policy in both directions, and it never rebases:

  • During the stream (kickoff, segment resume, bob warp session start on an existing branch): main is merged into the stream by scripts/warp-drive/stream-freshness.sh. A pushed stream must never be rebased — rewriting shared history breaks every checkout tracking it.
  • At bob warp finalize (L3): the stream is freshened one last time (same merge-in engine — no rebase-onto-main step), then shipped to main using the configured merge_strategy flag above. Because a freshened stream strictly descends from main, the ff-only default ships cleanly with any absorbed sync-merge commits preserved in history; set merge-commit if you want an explicit --no-ff ship commit marking the stream boundary. The policy is carried entirely by merge_strategy — there is no hidden finalize-specific override. When the stream lives in a worktree and main is checked out in another worktree (the farm-01 layout), git refuses to check main out there, so finalize lands the stream from a temporary detached worktree of origin/<main> (a squash is committed there) and pushes it; the other checkout is left alone, and this one is left detached at the new origin/<main> (#3543). Where the project’s manifest declares _meta.branch_protection enforced for admins, finalize pushes neither: it lands the stream through one pull request and merge-on-green (How finalize lands, #4184).

When does rebase apply? Never inside warp tooling. Rebasing is acceptable only as a manual choice on a branch that has never been pushed (nothing shared to rewrite); once a stream has an upstream, merge-in is the only sync direction.

Command / Script Uses
/start-work Detects main for checkout; writes _branch.session after branch creation
bob warp session start/end, bob warp finalize Write/clear _branch.session + _branch.scope (stream↔scope binding, #1177)
kickoff.js (resolveScopeBinding) Applies _branch.scope at /warp-drive kickoff — bound default, conflict refusal
/finish-work Detects main for rebase/merge target; reads --field session (the declared pin only) for the mismatch check
/warp-drive Detects main, session, merge_target, merge_strategy for all merge operations — session is the declared pin or empty, so an unpinned run merges into main (#2313)
check-branch.sh, session-branch-guard.sh, promotion-ceiling-guard.sh hooks Detect main for branch enforcement; the session guard is keyed on the _branch.session pin it reads itself
stream-freshness.sh Reads the _branch.session pin directly; without one it reports skipped and never checks a branch out
swarm/integrate.js Integration target is --target or the declared session; with neither it refuses (exit 2) rather than merging into the checked-out branch
state-machine.js Stores branch fields in warp-drive state; records branch_warning when HEAD is a non-main branch without a pin

Every consumer above was audited for #2313 (AC-02): none derives a session from HEAD — each reads the pin (directly or through branch-detect.sh), and tests/test-branch-detect.sh drives bob warp finalize, bob warp session status, stream-freshness.sh and swarm/integrate.js on an unpinned feature branch to pin their behaviour.