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
+19 -7
View File
@@ -36,13 +36,15 @@ BASE_BRANCHES = frozenset({"master", "main", "dev"})
REMOTE_MASTER_REFS = ("prgs/master", "refs/remotes/prgs/master")
def _derive_probe_refs(root: str, remote: str | None) -> dict:
def _derive_probe_refs(root: str, explicit_remote: str | None) -> dict:
"""Derive the ordered tracking refs to probe for *root*.
Returns the derivation payload from :mod:`canonical_repository_root` plus a
``refs`` tuple, which is empty when the target is not provable.
"""
derived = canonical_repository_root.resolve_target_base_ref(root, remote=remote)
derived = canonical_repository_root.resolve_target_base_ref(
root, explicit_remote=explicit_remote
)
return {
"refs": tuple(derived.get("tracking_refs") or ()),
"remote": derived.get("remote"),
@@ -51,6 +53,8 @@ def _derive_probe_refs(root: str, remote: str | None) -> dict:
"proven": bool(derived.get("proven")),
"reason_code": derived.get("reason_code"),
"reasons": list(derived.get("reasons") or []),
"cached_remote_head_branch": derived.get("cached_remote_head_branch"),
"cached_remote_head_conflicts": bool(derived.get("cached_remote_head_conflicts")),
}
@@ -58,7 +62,7 @@ def resolve_remote_master_sha(
canonical_repo_root: str,
*,
remote_refs: tuple[str, ...] | None = None,
remote: str | None = None,
explicit_remote: str | None = None,
) -> str | None:
"""Return the commit SHA for the tracking integration ref when available.
@@ -67,6 +71,10 @@ def resolve_remote_master_sha(
returned when ``rev-parse`` failed. Callers that must fail closed on missing
evidence (the #749/#757 bootstrap path) surface that None as *missing
evidence*, never as "no constraint".
*explicit_remote* is a caller-supplied disambiguation and is named that way
deliberately: an internally inferred remote handed back in would suppress the
resolver's ambiguity gate (#983 B2). No production caller supplies it.
"""
root = (canonical_repo_root or "").strip()
if not root:
@@ -74,7 +82,7 @@ def resolve_remote_master_sha(
if remote_refs:
probe: tuple[str, ...] = tuple(remote_refs)
else:
derived = _derive_probe_refs(root, remote)
derived = _derive_probe_refs(root, explicit_remote)
if not derived["proven"]:
return None
probe = derived["refs"]
@@ -96,7 +104,7 @@ def resolve_remote_master_ref_state(
canonical_repo_root: str,
*,
remote_refs: tuple[str, ...] | None = None,
remote: str | None = None,
explicit_remote: str | None = None,
) -> dict:
"""Resolve the tracking integration ref together with its commit SHA.
@@ -119,6 +127,8 @@ def resolve_remote_master_ref_state(
"source": None,
"reason_code": None,
"reasons": [],
"cached_remote_head_branch": None,
"cached_remote_head_conflicts": False,
}
if not root:
state["reasons"].append("no canonical repository root supplied (fail closed)")
@@ -128,17 +138,19 @@ def resolve_remote_master_ref_state(
probe: tuple[str, ...] = tuple(remote_refs)
state["source"] = "explicit_remote_refs"
else:
derived = _derive_probe_refs(root, remote)
derived = _derive_probe_refs(root, explicit_remote)
state["remote"] = derived["remote"]
state["branch"] = derived["branch"]
state["source"] = derived["source"]
state["cached_remote_head_branch"] = derived["cached_remote_head_branch"]
state["cached_remote_head_conflicts"] = derived["cached_remote_head_conflicts"]
if not derived["proven"]:
state["reason_code"] = derived["reason_code"]
state["reasons"] = derived["reasons"]
return state
probe = derived["refs"]
sha = resolve_remote_master_sha(root, remote_refs=probe, remote=remote)
sha = resolve_remote_master_sha(root, remote_refs=probe, explicit_remote=explicit_remote)
if not sha:
state["reasons"].append(
"tracking integration ref "