# ADR-0047: Remote-First Default Base-Branch Resolution That Fails Closed

## Status

✅ Accepted (2026-07-07)

## Context

Several omni-dev commands analyse "the commits on this branch" — the range between
a base branch and `HEAD`. When the user gives no explicit range, the tool has to
pick a base branch. Two commands did this on their own, and did it differently.

Before [#1106](https://github.com/rust-works/omni-dev/issues/1106) (commit
`44459e6c`, `feat(cli,git)!`), `git commit message check` and `git branch info`
each resolved the default base **independently**:

- `check` tried local `main`, then local `master`, then fell back to `HEAD~5` — a
  guess that silently analysed the wrong commits in any repo without those
  branches.
- `branch info` tried local `main`, then local `master`, then bailed with a generic
  `No default base branch found (main or master)`.

Both had two problems. They **diverged** when neither local `main` nor `master`
existed (one guessed `HEAD~5`, the other errored), and — more importantly — both
bound the range to the **local** branch. A stale local `main` that trailed
`origin/main` still contained commits already merged upstream, so the computed
range wrongly included those already-merged commits. The behaviour depended on how
recently the user had run `git pull` on their local `main`.

Meanwhile, two *other* commands had already learned the right answer. `create pr`
(`src/cli/git/create_pr.rs:310-316`) treats a base like `origin/main` as
already-valid and otherwise prepends the primary remote, and `coverage diff`
(`src/coverage/diff.rs:230-249`, `default_base_ref`) resolves `origin/main`
before falling back to local `main` and takes the merge-base with `HEAD`. Both
prefer the **remote's** view of the mainline. The default-range logic in `check`
and `info` had simply never been unified with them.

This is a *policy* decision — which ref to treat as the mainline, and what to do
when none is found — distinct from the decisions already recorded nearby.
[ADR-0003](adr-0003.md) governs the git-access *mechanism* (git2 for reads, shell
for complex mutations), not base-resolution policy. [ADR-0041](adr-0041.md) detects
whether a commit is *contained in* a remote main branch to refuse amending pushed
history; that is adjacent, but a different mechanism (containment) on a different
surface (amend refusal) than choosing a base for a default range.

## Decision

### One shared, remote-first resolver

We resolve the default base through a single function,
[`GitRepository::resolve_default_base_branch`](../../src/git/repository.rs)
(`src/git/repository.rs:137-154`), which tries a fixed list of candidates in
strict precedence order and returns the first that exists:

```rust
const CANDIDATES: [(&str, git2::BranchType); 4] = [
    ("origin/main", git2::BranchType::Remote),
    ("origin/master", git2::BranchType::Remote),
    ("main", git2::BranchType::Local),
    ("master", git2::BranchType::Local),
];
```

We prefer **remote-tracking refs over local branches**: `origin/main` →
`origin/master` → local `main` → local `master`. Each candidate is probed with
git2's `find_branch(name, BranchType)` using an **explicit** `Remote`/`Local`
type, so a remote-tracking ref outranks a same-named local branch rather than
whichever `revparse` happens to hit first. This binds the default range to the
remote's view of the mainline and fixes the stale-local-`main` bug. When no
candidate exists the resolver returns `None` — it does not guess.

### A single chokepoint that fails closed

We turn that `Option` into a range (or an error) in one place,
[`default_commit_range`](../../src/cli/git.rs) (`src/cli/git.rs:42-53`): it forms
`<base>..HEAD` from the resolved base, and on `None` it fails with an actionable
error rather than falling back to a guess:

```
No default base branch found (checked origin/main, origin/master, main, master).
Pass an explicit commit range (e.g. 'origin/develop..HEAD') or base branch.
```

Both `git commit message check` (`src/cli/git/check.rs:226-230`) and `git branch
info` (`src/cli/git/info.rs:150-158`) route their no-argument path through this
chokepoint, and the MCP `git_branch_info` tool inherits it by forwarding to
`run_info` (`src/mcp/git_tools.rs`). Because resolution lives in one function,
`check`, `info`, and MCP cannot drift apart the way they did before.

Explicit input bypasses the resolver unchanged: a positional commit range on
`check`, a `<BASE_BRANCH>` argument on `branch info` (validated for existence,
then formed as `<branch>..HEAD`), and the MCP `git_check_commits` tool, which
requires an explicit `range` and never touches the resolver at all.

### A deliberately narrow candidate list

We hardcode exactly these four candidates and **no `develop`** (or any other
name). `develop` appears only as the *example* of an explicit range in the
error/help text — never as an auto-detected candidate. Repos with a non-standard
mainline are expected to pass an explicit range; the resolver's job is to be
correct and predictable for the common case, not to guess every layout.

## Consequences

**Positive**

- Fixes a real correctness bug: the default range now binds to `origin/*`, so a
  stale local `main` no longer pulls already-merged commits into the analysed
  range. Results no longer depend on when the user last pulled.
- One chokepoint (`resolve_default_base_branch` + `default_commit_range`) means
  `check`, `branch info`, and MCP `git_branch_info` share identical precedence and
  identical failure behaviour — they can no longer diverge.
- Unifies default resolution with the remote-first behaviour `create pr` and
  `coverage diff` already had, so the whole tool agrees on what "the mainline"
  means.
- Failure is explicit and actionable — a named error that tells the user exactly
  which refs were checked and how to supply a range — instead of a silent wrong
  answer from the old `HEAD~5` guess.

**Negative**

- **Breaking**: in a repo with none of `origin/main`, `origin/master`, local
  `main`, or local `master`, `check` previously fell back to `HEAD~5`; it now
  errors. Automation or hooks that relied on that fallback must pass an explicit
  range (e.g. `origin/develop..HEAD`) or ensure a standard base branch exists.
- The fixed four-candidate list excludes `develop` and other layouts by design; a
  project whose mainline is named otherwise must always pass an explicit base.
- Detection is only as fresh as the last `fetch`: `origin/main` is a
  remote-tracking ref, so a base that has moved upstream since the last fetch
  resolves to the locally-known tip. This is an accepted tradeoff for offline,
  deterministic resolution.

**Neutral**

- Reading refs via git2 `find_branch` (rather than shelling out) is consistent
  with [ADR-0003](adr-0003.md)'s hybrid split — base resolution is a read.
- Adjacent to [ADR-0041](adr-0041.md)'s remote-main detection but distinct:
  precedence-based *selection* of a base for a default range here, versus
  containment-based *refusal* of an amend there.
- Explicit ranges and `branch info <BASE_BRANCH>` are unaffected; only the
  no-argument default path changed.
