Skip to content

#48 fix: refuse to start when main is ahead of origin/main - #49

Merged
gregoryfoster merged 3 commits into
mainfrom
48-refuse-ahead-of-origin
Aug 18, 2026
Merged

gregoryfoster merged 3 commits into
mainfrom
48-refuse-ahead-of-origin

Conversation

@gregoryfoster

@gregoryfoster gregoryfoster commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor

Closes #48 — the decision it asks for, plus the sub-decision it surfaces.

The call: refuse

Unpushed commits on main are the "code that is on no shared branch" case #37 objects to. The staleness objection that keeps behind a warn does not transfer to ahead: origin/main is updated by git fetch and by a successful git push from this repository, so a never-fetched ref can hide behind-ness — remote commits this checkout cannot see — but it cannot manufacture ahead-ness for commits this checkout pushed. Ahead is local evidence, and needs no network call to be sound, which is what the fetch-less guard can afford to assert.

The only false positive needs the identical SHAs published from another clone; through a PR merge the SHAs differ, so the tree reads as diverged rather than merely ahead — refused too, with the behind warning printed alongside it so an operator is not told to push into a rejection.

Option 3 (gate the refusal on .git/FETCH_HEAD freshness) is rejected in the issue thread: that measures inbound knowledge, the behind axis, and says nothing about whether the local commits were pushed.

Sub-decision: a missing origin/main warns out loud

With ahead refusing, silence on a missing ref would make git remote remove origin a bypass of the refusal without touching the guard. Not fatal — HEAD is already proven to be main, so it is absence of evidence — but named.

Changes

  • scripts/check_main_checkout.sh — ahead refuses, naming both remedies (git push / git reset --hard origin/main), the staleness hypothesis (git fetch), and the override; missing-ref warn; every warn now prints before the refusal, so one failed start names every condition; the two-tier comment rewritten as the decision.
  • tests/test_check_main_checkout.py — ahead refuses, override lets unpushed commits through, diverged refuses and names both sides, missing ref warns and starts, a refused start still names the dirty tree. test_being_behind_origin_main_only_warns unchanged — that is the point.
  • deploy/replicator.service — the guard's own comment, which described unpushed as a warn. Comment-only, but the installed unit is a copy: sudo cp deploy/replicator.service /etc/systemd/system/ && sudo systemctl daemon-reload after merge.
  • docs/DEPLOYMENT.md, docs/STYLE.md — verdict table, the asymmetry argument, the journald line inventory, and REPLICATOR_ALLOW_ANY_CHECKOUT's incident-time use (a hotfix committed while the remote is unreachable).
  • AGENTS.md, README.md, docs/COMMANDS.md, the DEPLOYMENT lifecycle table — the deploy sequence now splits the two merge paths: git pull --ff-only when the merge happened on GitHub, git push when it happened locally. A line whose first command routinely prints Everything up-to-date is one an operator learns to skip.
  • .socraticodecontextartifacts.json — both descriptions that named the guard. Re-index (codebase_context_index) after merge, from the main checkout.

After merge

git pull --ff-only on the VM's main checkout, then the cp above, then restart. The branch also carries 62ce64c ("chore: update skills submodules"), which was committed to local main and never pushed — merging this PR publishes it, which is what makes the pull a fast-forward.

Reviewed as CR #17–#23; findings applied in 2867cbb. Tests: uv run pytest — 777 passed.

🤖 Generated with Claude Code

gregoryfoster and others added 3 commits August 18, 2026 02:14
Unpushed commits on `main` are precisely the "code that is on no shared
branch" case #37 objects to, and #37's table did not name them at all —
the guard shipped warning on them, with the call deferred to this issue.

Refuse. The staleness objection that keeps *behind* a warn does not
transfer: `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. The only false positive needs the identical
SHAs published from another clone; through a PR merge the SHAs differ,
so the tree reads as diverged — refused too, with the behind warning
printed alongside so both sides of the reconcile get named.

A missing `origin/main` ref now warns rather than passing in silence:
with ahead refusing, silence would make `git remote remove origin` a
bypass of the refusal without touching the guard.

The cost is one `git push` before the restart, in a flow that already
meant to push — the lifecycle tables now say push first. The
network-partition case keeps REPLICATOR_ALLOW_ANY_CHECKOUT=1, named in
DEPLOYMENT.md as the override's incident-time use so reaching for it
there is a documented path rather than guard erosion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CR #17 — deploy/replicator.service's own comment still said the guard
"warns only on a stale, unpushed, or dirty tree". That is the file an
operator reads while a start is failing, now describing the opposite
verdict for the condition that refused them. Needs the cp to
/etc/systemd/system/ like any unit edit, comment-only though it is.

CR #18 — docs/STYLE.md's journald inventory listed unpushed among the
warn-tier lines. It is a refusal line now, which is the half of that
inventory that matters: refusals appear exactly when something is
already wrong.

CR #19 — the deploy sequence front-loaded `git push`, but the usual path
is a PR merged on GitHub, after which this checkout is *behind* and the
push is a no-op. A deploy line whose first command routinely prints
"Everything up-to-date" is one an operator learns to skip. Both merge
paths now get their own row: pull when it merged on GitHub, push when it
merged here (AGENTS.md, README.md, docs/DEPLOYMENT.md, docs/COMMANDS.md).

CR #21 — the manifest's deployment-topology description still called the
guard branch-only.

CR #22 — the dirty-tree warning moved above the origin comparison, so
every warn prints before the one refusal that can still fire. An
operator who fixes the ahead refusal should not then discover a dirty
tree on the next start; same reasoning as printing `behind` before the
`ahead` refusal. Pinned by a test.

CR #20 (re-index the context manifest) is deliberately deferred to after
the merge — codebase_context_index has to run from the main checkout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gregoryfoster
gregoryfoster merged commit 8f93d17 into main Aug 18, 2026
2 checks passed
@gregoryfoster
gregoryfoster deleted the 48-refuse-ahead-of-origin branch August 18, 2026 13:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant