| Fail the local preflight when the branch is behind its base |
A new gate script computes git rev-list --left-right --count between the base ref and HEAD and exits non-zero when the behind count is greater than zero, printing the count and the concrete remedy (merge or rebase). Exit 0 when the count is zero. Base ref is origin/main, hardcoded, matching exception-handler-gap's own existing local_stdin convention rather than resolving a per-branch upstream -- main is the only base branch in this repository's flow |
Add .github/scripts/gitapex_gate_behind_base.py; register it in .gitapex/ssot.json with planes containing local and a local_invocation argv |
Run the runner on a branch deliberately left behind its base and confirm one FAIL naming the behind count; run it on an up-to-date branch and confirm PASS. Both as tests in tests/test_gitapex_gate_behind_base.py, not only by hand |
Hardcoding origin/main is wrong the day this repository grows a second long-lived base branch. Named rather than solved; the cost of getting it wrong is a false FAIL with an obvious message, not a silent pass |
| The gate fetches its own base ref before comparing |
Requester's recorded decision. The gate runs a git fetch for the base branch as its first step, so the comparison is always against real remote state rather than whatever the contributor last pulled. This is the first network call in an otherwise fully offline preflight runner, and that posture change is deliberate, not incidental |
Fetch the base ref inside the gate script before the rev-list call; document the posture change in the script docstring and in the runner's own docstring bullet that currently describes the origin/main staleness behavior, which this gate falsifies for its own case |
A test that stubs the fetch and asserts it ran before the comparison; a second that asserts a stale local ref still produces a correct verdict once the fetch has run |
Adds wall-clock to every push. Unquantified here -- measure it against the runner's existing sequential total (mypy-type-check and cyclomatic-complexity-floor already dominate) and report the real number in the PR rather than asserting it is negligible |
| A failed fetch never becomes a silent pass |
An offline machine, an unreachable remote, or an auth failure must not let the gate report PASS. It fails closed with a message naming the fetch failure as the reason, distinct from the message for a genuinely behind branch, so a contributor can tell "cannot check" from "you are behind". This is evaluating-deterministic-gate-quality dimension 15 applied to this gate's own dominant failure mode |
Non-zero exit plus a distinct message on fetch failure; no fallback to comparing against the local ref |
A test that simulates fetch failure and asserts a non-zero exit with the fetch-failure message, and specifically that the behind-count path is not reached |
An offline contributor is blocked at push time with no override. Whether that needs an escape hatch (an environment variable, a documented skip flag) is unknown, pending the requester's call once the FAIL has been seen in practice -- deliberately not designed in advance here |
| Plane registration is a stated decision, not a default |
local is required (that is where the runner discovers it). Whether a ci plane is added alongside is unknown, pending the implementing pass -- this would be the repository's first local-only gate, and GitHub already surfaces behind-ness on the PR itself, so a CI copy may be redundant rather than defensive |
Set planes in the registry entry, and state in the PR body which choice was made and why |
gitapex_scan_ssot_schema.py passes (it makes local_invocation required exactly when planes contains local, and local_exclusion required exactly when it does not); gitapex_gate_registry_wiring.py passes |
Choosing local-only sets a precedent for a plane combination nothing else uses. Low blast radius, but it should be an argued choice in the PR body rather than an unremarked first |
| The change satisfies this repository's own gate bar |
A new .github/scripts/gitapex_gate_*.py is itself a deterministic gate script, so the implementing PR owes the deterministic-gate-quality and checker-script-adversarial-review disclosures its own gates require, and the script must pass mypy --strict, ruff, and the per-file coverage floor |
Write the script, its tests, and the registry entry together; run gitapex_gate_skill_audit_disclosure.py --check-diff against the drafted PR body before opening |
Local preflight all wired gates PASS; pytest green; the skill-audit-disclosure CI check green on the PR |
A gate that ships fail-open in its own first version is the exact defect PR #651 shipped and issue #673 built a disclosure check for; the third ACM row above is where that risk concentrates |
ACM: draft, not pre-verified -- any reader must independently re-check each row against this issue's own stated Facts before acting on it.
Facts
Verified live against
origin/mainat836318dthis session, not recalled.grep -rn "rev-list|left-right"over.github/scripts/,hooks/and.pre-commit-config.yamlreturns no behind-base logic, and none of the 45 gates registered in.gitapex/ssot.jsoncovers it.pytestrun against a stale base. In PR refactor: absorb auditing-git-hosting-surface into scanning-attack-surfaces #961's cycle (issue chore(retrospective): merge retrospective for PR #961 #966) every one of the eight required checks passed on head8a02addand the gap still cost a full extra push and CI cycle, because the branch was four commits behind at open..github/scripts/gitapex_gate_local_preflight.pyruns a gate when and only when its.gitapex/ssot.jsonentry lists thelocalplane and declareslocal_invocation; that module contains no gate names at all. 21 of the 45 gates are wired locally today.origin/maininverts polarity for this gate specifically. The runner's own docstring records that a staleorigin/main"widens the diff rather than narrowing it, so it errs toward grading more than the branch changed, never less" -- which is safe for the one existing consumer (exception-handler-gap, a diff-scoped gate). For a behind-base comparison the same staleness makes a behind branch report as up to date. This is not hypothetical: in PR refactor: absorb auditing-git-hosting-surface into scanning-attack-surfaces #961's session the localorigin/mainwas four commits stale until fetched, which is exactly the case the gate exists to catch.git fetchbefore comparing, rather than reading a possibly-stale ref or checking a freshness TTL. The requester chose this over both alternatives when presented with all three.local-only gate. Every one of the 21 currently-wired local gates also carriesciorpretooluse. Whether this one gains a CI plane is an open implementation decision, not something this issue settles -- see the ACM's fourth row.exception-handler-gapis the only one that even referencesorigin/main, and it only reads the local ref.Requested outcome
Implement the behind-base check the 17 retrospectives above have proposed and carried without ever building: a deterministic gate, discovered by
gitapex_gate_local_preflight.pythrough its registry entry, that fetches the base branch and fails when the current branch is behind it.Acceptance Criteria Map
git rev-list --left-right --countbetween the base ref andHEADand exits non-zero when the behind count is greater than zero, printing the count and the concrete remedy (merge or rebase). Exit 0 when the count is zero. Base ref isorigin/main, hardcoded, matchingexception-handler-gap's own existinglocal_stdinconvention rather than resolving a per-branch upstream --mainis the only base branch in this repository's flow.github/scripts/gitapex_gate_behind_base.py; register it in.gitapex/ssot.jsonwithplanescontaininglocaland alocal_invocationargvtests/test_gitapex_gate_behind_base.py, not only by handorigin/mainis wrong the day this repository grows a second long-lived base branch. Named rather than solved; the cost of getting it wrong is a false FAIL with an obvious message, not a silent passgit fetchfor the base branch as its first step, so the comparison is always against real remote state rather than whatever the contributor last pulled. This is the first network call in an otherwise fully offline preflight runner, and that posture change is deliberate, not incidentalrev-listcall; document the posture change in the script docstring and in the runner's own docstring bullet that currently describes theorigin/mainstaleness behavior, which this gate falsifies for its own casemypy-type-checkandcyclomatic-complexity-flooralready dominate) and report the real number in the PR rather than asserting it is negligibleevaluating-deterministic-gate-qualitydimension 15 applied to this gate's own dominant failure modelocalis required (that is where the runner discovers it). Whether aciplane is added alongside is unknown, pending the implementing pass -- this would be the repository's first local-only gate, and GitHub already surfaces behind-ness on the PR itself, so a CI copy may be redundant rather than defensiveplanesin the registry entry, and state in the PR body which choice was made and whygitapex_scan_ssot_schema.pypasses (it makeslocal_invocationrequired exactly whenplanescontainslocal, andlocal_exclusionrequired exactly when it does not);gitapex_gate_registry_wiring.pypasses.github/scripts/gitapex_gate_*.pyis itself a deterministic gate script, so the implementing PR owes thedeterministic-gate-qualityandchecker-script-adversarial-reviewdisclosures its own gates require, and the script must passmypy --strict,ruff, and the per-file coverage floorgitapex_gate_skill_audit_disclosure.py --check-diffagainst the drafted PR body before openingpytestgreen; theskill-audit-disclosureCI check green on the PRConstraints
gitapex_gate_local_preflight.py's discovery logic. Wiring is a registry entry; that module deliberately contains no gate names.Non-goals
skill-audit-disclosurelocal-plane registration, markdownlint wiring, flaky-test detection). Each is separately proposed and stays separately tracked.