fix(guard): derive cross-repository target base ref

Cross-repository mutation gating assumed the tracking base ref was
prgs/master, and parity reporting independently assumed origin/master.
A namespace bound to any other repository -- for example remote MDCPS on
integration branch dev -- could not prove base equivalence, so every
gated mutation failed closed with no reachable remedy. The two modules
also disagreed with each other, so at most one could be right for any
given repository.

Derive the target instead of assuming it. canonical_repository_root
already discovered the correct remote while resolving repository
identity and then discarded its name; it now returns that name with its
exact configured case preserved, and resolve_target_base_ref() builds
refs/remotes/<remote>/<branch> from it. The integration branch comes
from refs/remotes/<remote>/HEAD -- git's own record of the remote's
default branch -- so no new configuration field is required. Only when
a remote publishes no such default does it fall back to exactly one
present integration-branch candidate.

Resolution fails closed with a machine-checkable reason_code when
identity is unprovable, when distinct remotes claim different
repositories, when several candidate branches exist with no recorded
default, or when no candidate exists. It never invents a branch, writes
a ref, or falls back to another repository's base.

Both the mutation guard and the parity report now consume that one
resolved target, so they cannot disagree again. Root-checkout
contamination names the ref it actually compared rather than a literal
prgs/master the target repository may not have.

Fixes an observable defect in this repository: refs/remotes/origin/master
survives as an orphan ref from a removed remote, so parity reported the
target stale against a dead commit while reporting its identity as
underivable.

PRGS behaviour is unchanged -- prgs/master still resolves via the
recorded remote HEAD to the same SHA, and an explicit remote_refs
override keeps the historical probe path verbatim.

Tests: 24 new hermetic regression tests covering PRGS prgs/master,
MDCPS/dev, no origin remote, exact remote-name case, equal/behind/
divergent targets, missing remote or ref, ambiguous remote and branch
resolution, gate/report agreement, and every affected production
caller. Full suite 6191 passed / 28 failed, byte-identical failure set
to the baseline at 108cbfa (zero introduced failures).

Refs #983

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-30 22:31:15 -04:00
co-authored by Claude Opus 4.8
parent 108cbfa173
commit 2d5d5c9d17
7 changed files with 945 additions and 38 deletions
+243 -14
View File
@@ -37,6 +37,23 @@ CANONICAL_ROOT_ENV = "GITEA_CANONICAL_REPOSITORY_ROOT"
# Candidate git remote names probed when deriving repository identity.
_IDENTITY_REMOTE_CANDIDATES = ("prgs", "origin", "dadeschools", "mdcps")
# Fallback integration-branch names, probed only when the remote publishes no
# ``refs/remotes/<remote>/HEAD`` symbolic ref (#983). Mirrors the stable base
# branches recognised elsewhere in the workflow (``stacked_pr_support``). The
# fallback is deliberately *not* ordered-first-wins: when more than one of these
# refs exists and git records no default, the integration branch is genuinely
# ambiguous and resolution fails closed instead of guessing.
INTEGRATION_BRANCH_CANDIDATES: tuple[str, ...] = ("master", "main", "dev")
# Reason codes for base-ref derivation outcomes (#983), so callers and tests can
# assert the refusal cause instead of string-matching prose.
BASE_REF_SOURCE_REMOTE_HEAD = "remote_head_symref"
BASE_REF_SOURCE_UNIQUE_CANDIDATE = "unique_integration_branch_ref"
DENY_NO_IDENTITY_REMOTE = "no_identity_remote"
DENY_AMBIGUOUS_REMOTE = "ambiguous_identity_remote"
DENY_AMBIGUOUS_BASE_BRANCH = "ambiguous_integration_branch"
DENY_NO_BASE_BRANCH = "no_integration_branch_ref"
# Repository-authority modes (#973 B10). Exactly two values are supported.
# ``mode`` selects how repository authority is established, so an unrecognised
# value must never be normalised onto one of these: aliasing a trusted mode is
@@ -120,17 +137,15 @@ def resolve_repo_toplevel(path: str) -> str | None:
return os.path.realpath(top) if top else None
def repository_identity_slug(path: str, *, remote: str | None = None) -> str | None:
"""``owner/repository`` derived from a git remote configured at *path*.
def _identity_remote_candidates(path: str, remote: str | None) -> list[str]:
"""Ordered remote names to probe for identity at *path*.
Tries the caller-named remote first, then a small set of known remote names,
then whatever remote the repository actually has. Returns None when no remote
URL is parseable (identity cannot be proven).
Names are used verbatim — never case-folded. Git config subsection names are
case-sensitive, so a repository whose remote is ``MDCPS`` is reached only by
the exact string ``MDCPS``; the lowercase entry in
:data:`_IDENTITY_REMOTE_CANDIDATES` simply does not resolve, and the exact
name arrives from ``git remote`` below (#983).
"""
text = (path or "").strip()
if not text:
return None
ordered: list[str] = []
for name in (remote, *_IDENTITY_REMOTE_CANDIDATES):
clean = (name or "").strip()
@@ -139,7 +154,7 @@ def repository_identity_slug(path: str, *, remote: str | None = None) -> str | N
try:
listed = subprocess.run(
["git", "-C", text, "remote"],
["git", "-C", path, "remote"],
capture_output=True,
text=True,
check=True,
@@ -149,11 +164,20 @@ def repository_identity_slug(path: str, *, remote: str | None = None) -> str | N
for name in listed:
if name and name not in ordered:
ordered.append(name)
return ordered
for name in ordered:
def _configured_remote_identities(path: str, remote: str | None) -> list[tuple[str, str]]:
"""``(remote_name, owner/repository)`` for every probe name that resolves.
Remote names are returned exactly as configured so downstream tracking refs
(``refs/remotes/<remote>/<branch>``) address the real ref (#983).
"""
found: list[tuple[str, str]] = []
for name in _identity_remote_candidates(path, remote):
try:
url = subprocess.run(
["git", "-C", text, "remote", "get-url", name],
["git", "-C", path, "remote", "get-url", name],
capture_output=True,
text=True,
check=True,
@@ -162,8 +186,213 @@ def repository_identity_slug(path: str, *, remote: str | None = None) -> str | N
continue
parsed = remote_repo_guard.parse_org_repo_from_remote_url(url)
if parsed:
return f"{parsed[0]}/{parsed[1]}"
return None
found.append((name, f"{parsed[0]}/{parsed[1]}"))
return found
def resolve_identity_remote(
path: str, *, remote: str | None = None
) -> tuple[str | None, str | None]:
"""``(remote_name, owner/repository)`` for the remote that proves identity.
The remote *name* is the piece historically thrown away by
:func:`repository_identity_slug`, even though resolving the slug already
required discovering it. Cross-repository base-ref derivation needs that
name to build ``refs/remotes/<remote>/<branch>``, so it is now returned
rather than discarded (#983). Returns ``(None, None)`` when no remote URL is
parseable (identity cannot be proven).
"""
text = (path or "").strip()
if not text:
return None, None
for name, slug in _configured_remote_identities(text, remote):
return name, slug
return None, None
def repository_identity_slug(path: str, *, remote: str | None = None) -> str | None:
"""``owner/repository`` derived from a git remote configured at *path*.
Tries the caller-named remote first, then a small set of known remote names,
then whatever remote the repository actually has. Returns None when no remote
URL is parseable (identity cannot be proven).
"""
return resolve_identity_remote(path, remote=remote)[1]
def _base_ref_result(
*,
proven: bool,
reasons: list[str],
remote: str | None = None,
branch: str | None = None,
repository_slug: str | None = None,
source: str | None = None,
reason_code: str | None = None,
) -> dict:
"""Build the base-ref derivation payload.
``tracking_refs`` is the ordered probe tuple downstream guards hand to
``git rev-parse``: the ``<remote>/<branch>`` shorthand first, then the fully
qualified ``refs/remotes/<remote>/<branch>``. It is empty whenever the
derivation is not ``proven``, so an unresolved target can never be probed
against some other repository's ref.
"""
tracking_ref = f"refs/remotes/{remote}/{branch}" if proven and remote and branch else None
tracking_refs: tuple[str, ...] = (
(f"{remote}/{branch}", tracking_ref) if tracking_ref else ()
)
return {
"proven": proven,
"block": not proven,
"remote": remote,
"branch": branch,
"repository_slug": repository_slug,
"tracking_ref": tracking_ref,
"tracking_refs": tracking_refs,
"source": source,
"reason_code": reason_code,
"reasons": list(reasons),
}
def _ref_exists(path: str, ref: str) -> bool:
"""Whether *ref* resolves in the checkout at *path*."""
try:
res = subprocess.run(
["git", "-C", path, "rev-parse", "--verify", "--quiet", ref],
capture_output=True,
text=True,
check=False,
)
except Exception:
return False
return res.returncode == 0 and bool((res.stdout or "").strip())
def resolve_target_base_ref(path: str, *, remote: str | None = None) -> dict:
"""Derive the authoritative integration base ref for the checkout at *path*.
This is the single resolved target shared by cross-repository mutation
gating and parity reporting, so the two can never disagree about which
commit a checkout is supposed to match (#983).
Resolution order:
1. The identity remote — the remote that already proves repository identity
via :func:`resolve_identity_remote`, with its **exact configured case**
preserved (``MDCPS`` stays ``MDCPS``).
2. The integration branch, from ``refs/remotes/<remote>/HEAD`` — git's own
record of that remote's default branch, written by ``clone`` and
``remote set-head``. This is authoritative repository state, which is why
no new configuration field is required.
3. Only if the remote publishes no such symbolic ref, exactly one of
:data:`INTEGRATION_BRANCH_CANDIDATES` present as a remote-tracking ref.
Fails closed — ``proven`` False, empty ``tracking_refs``, and a
``reason_code`` — when identity is unprovable, when distinct remotes claim
different repositories, when several candidate branches exist with no
recorded default, or when no candidate exists at all. Nothing here invents a
branch, writes a ref, or falls back to another repository's base.
"""
text = (path or "").strip()
if not text:
return _base_ref_result(
proven=False,
reasons=["no repository path supplied for base-ref derivation (fail closed)"],
reason_code=DENY_NO_IDENTITY_REMOTE,
)
identities = _configured_remote_identities(text, remote)
if not identities:
return _base_ref_result(
proven=False,
reasons=[
f"no git remote at '{text}' yields a parseable repository identity, so "
"the integration base ref cannot be derived (fail closed)"
],
reason_code=DENY_NO_IDENTITY_REMOTE,
)
# Ambiguity only matters when the caller named no remote: if distinct remotes
# describe different repositories there is no single authoritative target,
# and picking the first would silently gate against the wrong repository.
if not (remote or "").strip():
distinct = {slug for _, slug in identities}
if len(distinct) > 1:
listed = ", ".join(f"{name} -> {slug}" for name, slug in identities)
return _base_ref_result(
proven=False,
reasons=[
f"ambiguous repository identity at '{text}': remotes resolve to "
f"different repositories ({listed}); no single authoritative "
"integration base ref can be derived (fail closed)"
],
reason_code=DENY_AMBIGUOUS_REMOTE,
)
remote_name, slug = identities[0]
symref = None
try:
res = subprocess.run(
["git", "-C", text, "symbolic-ref", "--quiet", f"refs/remotes/{remote_name}/HEAD"],
capture_output=True,
text=True,
check=False,
)
if res.returncode == 0:
symref = (res.stdout or "").strip() or None
except Exception:
symref = None
prefix = f"refs/remotes/{remote_name}/"
if symref and symref.startswith(prefix):
branch = symref[len(prefix):].strip()
if branch and branch != "HEAD":
return _base_ref_result(
proven=True,
reasons=[],
remote=remote_name,
branch=branch,
repository_slug=slug,
source=BASE_REF_SOURCE_REMOTE_HEAD,
)
present = [
candidate
for candidate in INTEGRATION_BRANCH_CANDIDATES
if _ref_exists(text, f"{prefix}{candidate}")
]
if len(present) == 1:
return _base_ref_result(
proven=True,
reasons=[],
remote=remote_name,
branch=present[0],
repository_slug=slug,
source=BASE_REF_SOURCE_UNIQUE_CANDIDATE,
)
if len(present) > 1:
return _base_ref_result(
proven=False,
reasons=[
f"remote '{remote_name}' publishes no '{prefix}HEAD' default and "
f"several integration branches exist ({', '.join(present)}); the "
"integration base ref is ambiguous (fail closed)"
],
reason_code=DENY_AMBIGUOUS_BASE_BRANCH,
)
return _base_ref_result(
proven=False,
reasons=[
f"remote '{remote_name}' publishes no '{prefix}HEAD' default and none of "
f"{'/'.join(INTEGRATION_BRANCH_CANDIDATES)} exists as a remote-tracking "
f"ref under '{prefix}'; the integration base ref cannot be derived "
"(fail closed; no fetch is performed here)"
],
reason_code=DENY_NO_BASE_BRANCH,
)
def assess_canonical_repository_root(