Skip to content

check_main_checkout: decide whether 'ahead of origin/main' should refuse rather than warn #48

Closed

Description

@gregoryfoster

The condition #37's table does not name

scripts/check_main_checkout.sh (shipped in #37, merged in f1e92dc) refuses a non-main
or detached HEAD and warns on three softer conditions. One of those three was not in
#37's verdict table at all, and by the issue's own reasoning it is the strongest of them:

Condition #37's verdict Shipped
HEAD is not main refuse refuse
detached HEAD refuse refuse
main behind origin/main warn warn
main ahead of origin/main (not named) warn
dirty working tree warn warn

#37 justifies warn-not-refuse on behind as "a stale-but-shared commit is a different
problem from an unshared one". But local commits on main that were never pushed are
precisely the unshared case — the same "code that is on no shared branch" the whole
issue objects to. git checkout main && git merge my-feature, then systemctl restart,
passes the guard as shipped.

This is not hypothetical. It fired for real during the Batch A merge, before the push:

check_main_checkout: main is 6 commit(s) ahead of origin/main (unpushed) — starting anyway
check_main_checkout: on main at f1e92dc

Why it shipped as warn

origin/main is a cached ref, only as fresh as the last git fetch, and the guard
deliberately never fetches — an ExecStartPre must not make a network call the start can
wait on. So ahead inherits exactly the staleness objection that makes behind a warn:
an operator who simply has not fetched would be refused a start.

The implementing agent flagged the tension rather than deciding it unilaterally, and
documented it in the script and in docs/DEPLOYMENT.md. This issue is that decision.

The options

  1. Keep it a warn (status quo) — and say so in the table as a decision rather than an
    omission. Cheapest, and the ambiguity is real.
  2. Refuse on ahead — treat unpushed commits as unshared code, which is what they are.
    Costs: a fetch-less guard cannot distinguish "unpushed" from "not fetched recently", so
    an operator behind a network partition who restarts gets refused for a stale ref. The
    escape hatch (REPLICATOR_ALLOW_ANY_CHECKOUT=1) covers it, but reaching for the
    override during an incident is exactly the pattern that gets guards deleted.
  3. Refuse only when the ref is demonstrably fresh — e.g. compare
    .git/FETCH_HEAD mtime against a threshold and refuse on ahead only if the last fetch
    is recent. More faithful, and more machinery than the condition may warrant.

Recommend deciding between 1 and 2 explicitly. Whichever wins, the table in
docs/DEPLOYMENT.md and the two-tier comment in the script should read as a decision.

Refs

Activity

  1. gregoryfoster commented on Aug 18, 2026

    @gregoryfoster
    ContributorAuthor

    Recommendation: option 2 — refuse on ahead

    The staleness objection that makes behind a warn does not transfer to ahead. It
    looks symmetric and it is not.

    origin/main is updated by fetch and by a successful push from this repo. So a
    never-fetched ref can hide behind-ness — remote commits you cannot see, hence refusing
    would fail an operator for not having network — but it cannot manufacture ahead-ness
    for commits this checkout pushed, because the push would have moved the ref. "Ahead" is
    therefore local evidence: these commits exist here, and nothing here ever published
    them. No network call is needed for that claim to be sound, which is exactly what the
    fetch-less guard can afford to assert.

    The only false positive is someone publishing the identical SHAs from another clone.
    Through the normal path — PR merge, squash or rebase — the SHAs differ, so the tree reads
    as diverged rather than merely ahead. Still unshared, still correctly refused.

    The cost is one command, in a flow that already intends it. Ahead-of-origin only
    arises when someone merges locally on the VM (Batch A, Batch C). The remedy is
    git push, or git reset --hard origin/main if the commits were unwanted — neither
    needs the override. The genuine partition case (a hotfix committed while GitHub is
    unreachable) keeps REPLICATOR_ALLOW_ANY_CHECKOUT=1, and docs/DEPLOYMENT.md should
    name that as the override's legitimate use so reaching for it there is a documented path
    rather than the guard-erosion pattern the issue worries about.

    Option 3 is rejected. .git/FETCH_HEAD mtime measures the freshness of inbound
    knowledge — the behind axis. It says nothing about whether the local commits were
    pushed, which is the actual question. Machinery on the wrong axis, plus a threshold
    nobody can tune from evidence.

    A sub-decision to settle in the same change

    If no origin/main ref exists at all, the comparison block is skipped entirely and
    the guard starts. Under option 2 that makes git remote remove origin a silent bypass of
    the new refusal — the same shape the "absence is hard" stance rejects for git itself.
    Proposed: keep it non-fatal but warn loudly ("no origin/main — cannot tell whether
    main is shared"), and write it into the table as a decision, or it comes back as its own
    issue.

    Note before this lands

    The live checkout is 1 commit ahead of origin/main right now (4e19c5b, unpushed).
    Once this ships, the next systemctl restart refuses until it is pushed. Ordering also
    wants to be explicit in AGENTS.md § Server Lifecycle, which currently reads "Deploy
    committed code with uv sync --frozen && sudo systemctl restart" — it should be push,
    then
    restart.

    Restart=on-failure with StartLimitBurst=3 / StartLimitIntervalSec=3600 means a
    refusal costs three attempts and then a clean failed state — no restart loop. Same as the
    existing branch refusal.

    Change set

    • scripts/check_main_checkout.sh — ahead moves to the refuse tier; the message names
      both hypotheses (git push, or git fetch if you believe they are already pushed) and
      the override. The two-tier comment is rewritten as the decision with the asymmetry
      argument, not as a deferral.
    • tests/test_check_main_checkout.py — test_being_ahead_of_origin_main_only_warns
      becomes a refusal; add a diverged case (ahead and behind ⇒ refuse) and a
      missing-origin/main case (starts, warns). test_being_behind_origin_main_only_warns
      stands unchanged — that is the point.
    • docs/DEPLOYMENT.md § The checkout guard — the row becomes refuse, carrying the
      ref-is-updated-by-push reasoning so the asymmetry does not read as inconsistency; the
      REPLICATOR_ALLOW_ANY_CHECKOUT entry gains the partition case.
    • AGENTS.md § Server Lifecycle — push, then restart.
    • deploy/replicator.service unchanged, so no cp to /etc/systemd/system/ is needed.

    Proceeding with implementation on a worktree branch.

  2. added a commit that references this issue on Aug 18, 2026
  3. gregoryfoster commented on Aug 18, 2026

    @gregoryfoster
    ContributorAuthor

    Shipped in #49 — merged as 8f93d17, deployed and running (BUILD_ID=8f93d17).

    Decision: option 2, refuse on ahead. origin/main is moved by git fetch and by a successful git push from this repository, so a never-fetched ref can hide behind-ness but cannot manufacture ahead-ness for commits this checkout pushed. Ahead is local evidence, assertable without the network call an ExecStartPre must not make — which is the asymmetry the issue was unsure about. Option 3 rejected: FETCH_HEAD mtime measures the behind axis.

    • 7349fdc — the refusal, its message set (git push / git reset --hard origin/main / git fetch if you believe they are already pushed / the override during a partition), and a missing-origin/main warn so git remote remove origin cannot become a silent bypass.
    • 2867cbb — review findings CR A body-less 304 dead-letters — make "not modified" a first-class outcome before conditional GET is usable #17–#22 docs: curate the context surface to the 6,000-token budget (wave A, pair 3) #23: the unit's own comment, docs/STYLE.md's journald inventory, the manifest's two descriptions, and every warn moved above the refusal so one failed start names every condition.
    • Deploy sequence now splits the merge paths in AGENTS.md, README.md, docs/DEPLOYMENT.md, docs/COMMANDS.md: git pull --ff-only when the merge happened on GitHub, git push when it happened here. The old single line front-loaded a push that routinely printed Everything up-to-date.
    • deploy/replicator.service comment changed ⇒ cp to /etc/systemd/system/ done; installed unit and repo copy verified identical.
    • Context artifacts re-indexed from the main checkout, and codebase_context_search verified to answer "refuse", not the old "warn".

    Tests: 777 passed; CI green on the PR. The escape hatch keeps the one case the refusal legitimately costs — a hotfix committed while the remote is unreachable — and docs/DEPLOYMENT.md names it as such rather than leaving it to improvisation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions