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:
+243
-14
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user