fix(guard): derive base ref from configured upstream, not the remote-HEAD cache

Remediates the REQUEST_CHANGES verdict (review 658) at head 2d5d5c9d.

B1 — refs/remotes/<remote>/HEAD is a stale local cache, not authority.

The previous derivation read that symref as "git's own record of the remote
default branch". It is a cache written once at clone time; an ordinary fetch
never refreshes it, and only an explicit `git remote set-head` updates it. On
the real target this issue exists to unblock, the cache still named `main`
while the checkout tracked and sat exactly on `dev`, so the guard compared
HEAD a37ac427c18b against MDCPS/main 9a84325a1b68 and blocked a checkout that
was not behind anything.

The resolver now derives, in order:

  1. the identity remote, exact case preserved;
  2. the checkout's own configured upstream — branch.<current>.remote plus
     branch.<current>.merge — accepted only when it names that remote and its
     remote-tracking ref actually exists;
  3. otherwise exactly one present master/main/dev remote-tracking ref.

The cached symref is demoted to an observation. It is still read and reported
as cached_remote_head_branch / cached_remote_head_conflicts, and it is named in
refusal text so an operator can see the misleading signal, but it never decides
the branch and never breaks a tie between ambiguous candidates. Requiring the
tracking ref to exist also makes `proven` honest: every proven target now names
a ref that resolves.

Verified read-only against /Users/jasonwalker/Development/weekly-briefings:
MDCPS/dev, source configured_branch_upstream, cached_remote_head_conflicts
true, checkout not stale. PRGS is unchanged — prgs/master, identical SHA.

B2 — an inferred remote must not be laundered into explicit caller intent.

assess_target_repository_parity resolved the identity remote itself and passed
it back into resolve_target_base_ref, which reads a caller-supplied remote as
"the caller already disambiguated" and skips its ambiguity gate. On a target
whose remotes claim different repositories the gate refused while the report
named a different repository with stale=false and no reasons.

The parameter is renamed `explicit_remote` through the resolver and both
root_checkout_guard entry points so the two meanings cannot be confused, and
the reporting path no longer supplies one. Identity resolution for reporting
moves to the new ambiguity-aware assess_identity_remote, so an ambiguous target
now yields a null slug, no tracking ref, and the same reason_code the gate
emits. resolve_identity_remote / repository_identity_slug keep their first-wins
behaviour for the #706/#973 canonical-root validation path, which compares
against an independently trusted expected slug and needs no ambiguity verdict.

Nothing fetches, sets a remote HEAD, writes a ref, adds a remote, invents a
branch, or changes any repository's default branch. A test snapshots refs,
remotes, local config, HEAD, branch, working-tree status, and the cached symref
across every resolver entry point and asserts all are unchanged.

Tests: tests/test_issue_983_cross_repo_base_ref.py rewritten to 37 tests. The
principal MDCPS/dev fixture now reproduces the real defect — upstream dev,
cached refs/remotes/MDCPS/HEAD -> main, both refs present at different commits
— rather than manufacturing the cache state the real checkout does not have.
Added: cache-alone never proves a target, cache never breaks a tie, gate and
report agree on ambiguous remote and ambiguous branch, explicit disambiguation
stays distinct from inferred identity, no production caller passes
explicit_remote, and the read-only proof above.

Focused suite 37 passed. Nineteen affected suites 441 passed, 167 subtests
passed. Full suite 28 failed, 6204 passed, 6 skipped, 1106 subtests passed;
clean baseline at the same base commit 108cbfa 28 failed, 6166 passed, 6
skipped, 1106 subtests passed. Sorted FAILED lists are byte-identical
(sha1 092dae4bc8c4e77d14504d90690d50e0fcd2f637) — zero introduced failures.

Refs #983

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-31 00:00:14 -04:00
co-authored by Claude Opus 4.8
parent 2d5d5c9d17
commit 03b434a0b6
4 changed files with 685 additions and 230 deletions
+29 -9
View File
@@ -445,6 +445,7 @@ def assess_target_repository_parity(
"remote_tracking_head": None,
"determinable": False,
"stale": False,
"reason_code": None,
"reasons": [],
}
if not canonical_root:
@@ -484,19 +485,38 @@ def assess_target_repository_parity(
# 'prgs', the old lookup failed outright and reported the identity as
# underivable while a leftover refs/remotes/origin/master still resolved.
#
# Identity is resolved independently of the base ref: a target that has never
# been fetched still has a provable repository identity, and reporting it as
# unidentifiable would lose real information over an unrelated missing ref.
identity_remote, slug = _crr.resolve_identity_remote(canonical_root)
result["repository_slug"] = slug
if not slug:
result["reasons"].append(
"target repository identity could not be derived from its git remote"
# #983 B2: this must be the *ambiguity-aware* resolver. The first-wins
# `resolve_identity_remote` picks whichever remote probes first, so on a
# target where distinct remotes claim different repositories the report
# confidently named one of them while the mutation gate refused the same
# target — gating and reporting evaluating different repositories, which is
# precisely the divergence this issue exists to end.
identity = _crr.assess_identity_remote(canonical_root)
result["repository_slug"] = identity["slug"]
if not identity["slug"]:
result["reason_code"] = identity["reason_code"]
result["reasons"].extend(
identity["reasons"]
or ["target repository identity could not be derived from its git remote"]
)
if identity["ambiguous"]:
# An ambiguous target has no single authoritative base, so reporting one
# would be a guess. Fail closed here exactly as the gate does.
return result
# Identity is otherwise resolved independently of the base ref: a target that
# has never been fetched still has a provable repository identity, and
# reporting it as unidentifiable would lose real information over an
# unrelated missing ref.
if not tracking_ref:
base = _crr.resolve_target_base_ref(canonical_root, remote=identity_remote)
# No remote argument. The identity remote resolved just above was
# *inferred here*, and feeding it back in would tell the resolver a
# caller had explicitly disambiguated the repository, suppressing its
# ambiguity gate (#983 B2). Only an operator-supplied remote may do that,
# and this call site has none.
base = _crr.resolve_target_base_ref(canonical_root)
if not base.get("proven"):
result["reason_code"] = base.get("reason_code")
result["reasons"].extend(base.get("reasons") or [])
return result
tracking_ref = base["tracking_ref"]