Files
chemenu/tools/chemenu/commands/git_publish.py
T
torben 7263f85936
CI / verify (push) Successful in 44s
Release / release (push) Successful in 36s
feat: Publish-Remote Gate und die Anleitung fuer eine private Instanz (2.2.0)
Files changed:
- .gitignore
- AGENTS.md
- CHANGES.md
- VERSION
- instructions/gates.md
- instructions/private-instance.md
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/git_publish.py
- tools/chemenu/config.py
- tools/chemenu/tests/test_git_publish.py
2026-09-01 18:06:55 +02:00

1141 lines
52 KiB
Python

"""Wrap the repo's publishing contract: stage everything, commit with a
templated message (auto-listing changed files), and push to a remote branch.
Exits non-zero on any failure so the caller must stop and ask the user - it
never force-pushes, and the one retry it does attempt (below) is bounded to a
single safe reconcile, never a loop.
Also implements the pull/rebase path (`reconcile`, exposed standalone as
`sync` and run automatically by `publish` before staging and again if the
push is rejected): fetch the remote, fast-forward or rebase when that cannot
collide with what this call is about to publish, and stop for review - the
same exit-42 idiom as the gate below - when it might. This is what keeps a
rejected push from stranding the commit that made it: a previous `publish`
whose push failed leaves a real, unpushed commit sitting on the branch, and
the next `publish` now pushes it instead of reporting "Nothing to commit"
forever.
Also implements the Mass-Update Gate (wiki/concepts/Mass-Update Gate.md):
a push to origin/main is the one action in this system with a real,
irreversible external effect (publicly visible commit history, possible CI
triggers, other clients pulling). Small/normal publishes (< threshold
counted files) go straight through, same as always - only publishes at or
above the threshold require human approval, so routine single-page
operations don't suffer "reviewer fatigue" from a gate that fires on every
commit. Paths under GATE_EXEMPT_PREFIXES are committed but not counted. The
gate is evaluated before anything is staged, so refusing a publish leaves
the working tree exactly as it was found.
**How the gate is cleared, and what that does and does not prove.** A tripped
gate exits `EXIT_NEEDS_CLEARANCE` (42) - its own code, distinct from a
validation error - and prints the counted file list plus the exact command to
re-run. That command carries `--confirm <token>`, where the token is a digest
of the file list and publish target (`changeset_token`). Two properties fall
out of that, and one deliberately does not:
* **Approval is bound to a changeset.** Touch one more file after the refusal
and the token no longer matches; the gate asks again with the new list. The
old `--yes` published whatever was in the tree at the time it ran, which is
not necessarily what the human was shown.
* **Clearance is legible in a trace.** The refusal and the `--confirm` that
follows it are separate, matchable events, so a scorer can ask whether the
agent actually stopped in between (`evals/trajectory.py`).
* **It does not prove a human typed anything.** The token sits in the agent's
own context; an agent that wants to bypass this can. That is a deliberate
trade: enforcement here is cheap and never blocks legitimate work, and the
"did a human really clear it?" question is answered in the eval layer
instead of by making every publish an interactive ceremony.
"""
from __future__ import annotations
import hashlib
import json
import shlex
import subprocess
from dataclasses import dataclass, field
from typing import NamedTuple, Optional
import typer
from chemenu import config
from chemenu.commands._util import fail, needs_clearance, success
from chemenu.telemetry import emit
# "Farza's Rule" from the Mass-Update Gate concept: halt and request
# confirmation for operations affecting >= 10 pages.
DEFAULT_MASS_UPDATE_THRESHOLD = 10
# Path prefixes that are committed like everything else but do not count toward
# the gate. The gate's justification is that a push to origin/main publishes
# knowledge irreversibly; `work/` holds a run's working notes, which are tracked
# only so a multi-session task survives, and are deleted when it closes (see
# work/CONTRACT.md). Making a reviewer approve twelve scratch files is the
# review fatigue the threshold exists to avoid.
#
# Deliberately a constant and not an option: a `--gate-exempt` flag would be a
# gate an agent could open on its own initiative, which AGENTS.md forbids.
GATE_EXEMPT_PREFIXES = ("work/",)
def _run(args: list[str]) -> subprocess.CompletedProcess:
return subprocess.run(args, cwd=config.ROOT, capture_output=True, text=True)
# --- Publish-Remote Gate -----------------------------------------------------
#
# The Mass-Update Gate asks "is this too much to publish?". This one asks the
# question underneath it: "is this the right place to publish to at all?".
#
# A checkout holding private content typically has two remotes - its own, and
# the public upstream it takes stack updates from. Nothing in git distinguishes
# them at push time, so a single wrong `--remote` puts a private corpus on a
# public repository, where a force-push does not take it back: the objects stay
# fetchable by SHA until someone expires the server's reflogs.
#
# Like the other two gates this refuses with exit 42 and has **no flag that
# opens it**. The way past it is to name the URL in the file, which is an edit
# the user makes deliberately rather than something an agent can decide mid-run.
def read_allowed_push_urls() -> Optional[list[str]]:
"""The URLs this checkout permits `publish` to push to, or None when the
file is absent (unrestricted - see config.PUBLISH_REMOTES_FILENAME)."""
path = config.ROOT / config.PUBLISH_REMOTES_FILENAME
if not path.is_file():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
fail(
f"{config.PUBLISH_REMOTES_FILENAME} is unreadable ({exc}). It decides where "
"`publish` may push, so a broken one is not treated as 'no restriction' - "
"fix the file or delete it deliberately."
)
return None
urls = data.get("allowed_push_urls")
if not isinstance(urls, list) or not all(isinstance(u, str) for u in urls):
fail(
f"{config.PUBLISH_REMOTES_FILENAME} has no usable `allowed_push_urls` list of "
"strings. Expected: {\"schema\": 1, \"allowed_push_urls\": [\"<url>\"]}"
)
return None
return urls
def push_url_for(remote: str) -> Optional[str]:
"""The URL `git push <remote>` would actually write to - `pushurl` when the
remote sets one, otherwise its fetch URL. Reading the resolved value rather
than the name is the whole point: a repointed `origin` must not pass."""
result = _run(["git", "remote", "get-url", "--push", remote])
if result.returncode != 0:
return None
return result.stdout.strip() or None
def publish_remote_refusal(remote: str, branch: str) -> Optional[str]:
"""The gate's message when `remote` is not an allowed push target, else None."""
allowed = read_allowed_push_urls()
if allowed is None:
return None
url = push_url_for(remote)
if url is None:
return (
f"Publish-Remote Gate: '{remote}' resolves to no push URL, so this publish "
f"cannot be checked against {config.PUBLISH_REMOTES_FILENAME}. Nothing was "
"committed or pushed."
)
if url in allowed:
return None
listed = "\n".join(f" - {u}" for u in allowed) or " (the list is empty)"
return (
f"Publish-Remote Gate: this checkout does not allow publishing to '{remote}'.\n\n"
f" would push to: {url}\n"
f" allowed here:\n{listed}\n\n"
"Nothing was committed or pushed. This checkout holds content that belongs to it "
"alone, and a push to the wrong remote is not cheaply reversible - the objects stay "
"fetchable by SHA even after a force-push, until the server's reflogs are expired.\n\n"
"THE USER CANNOT SEE THIS OUTPUT. It went to your context, not to their screen.\n"
"Show them the two lines above and stop. There is no flag that opens this gate: if "
f"the target really is right, the user adds its URL to {config.PUBLISH_REMOTES_FILENAME} "
"themselves. Do not edit that file to get past this."
)
def parse_porcelain_entries(stdout: str) -> list[tuple[str, str]]:
"""Parse `git status --porcelain -z` output into (status_code, path) pairs.
NUL-delimited output is used instead of line-splitting because it is the
only form that survives paths containing spaces, quotes, or newlines
(line mode quotes and escapes them instead). Rename/copy entries carry a
second NUL-separated field with the original path; the new path is what
gets committed, so the original is consumed and discarded.
"""
fields = [f for f in stdout.split("\0") if f != ""]
entries: list[tuple[str, str]] = []
index = 0
while index < len(fields):
entry = fields[index]
index += 1
if len(entry) < 4:
continue
status_code, path = entry[:2], entry[3:]
if status_code[0] in ("R", "C") or status_code[1] in ("R", "C"):
index += 1 # skip the original path of a rename/copy
entries.append((status_code, path))
return entries
def parse_porcelain_z(stdout: str) -> list[str]:
"""Just the paths - what the gate counts and what `git add -A` would stage."""
return [path for _, path in parse_porcelain_entries(stdout)]
def describe_status(status_code: str) -> str:
"""Porcelain XY code -> the word a reviewer needs. Deletions and additions
are what a reader scans for first, so they must not be flattened into a
generic "changed"."""
if status_code == "??":
return "added"
if "D" in status_code:
return "deleted"
if "R" in status_code or "C" in status_code:
return "renamed"
if "A" in status_code:
return "added"
return "modified"
class FileChange(NamedTuple):
"""One path in the changeset, with enough detail to judge it without
opening it. `added`/`removed` are -1 when git reports the file as binary
or the churn could not be determined."""
path: str
status: str
added: int
removed: int
digest: str
@property
def churn(self) -> int:
return max(self.added, 0) + max(self.removed, 0)
@property
def churn_text(self) -> str:
if self.status == "deleted":
# The size of what is being removed, not just the fact of it: a
# one-line stub and a 700-line document read identically as
# "deleted", and they are not the same decision.
return f"-{self.removed} deleted" if self.removed else "deleted"
if self.added < 0:
return "binary"
return f"+{self.added}/-{self.removed}"
def _numstat(paths: list[str]) -> dict[str, tuple[int, int]]:
"""Lines added/removed per tracked file, against HEAD.
`git diff HEAD` covers staged and unstaged changes together, which is what
`publish` is about to commit. Untracked files are absent from it and are
measured by reading them instead. A repository with no commits yet has no
HEAD to diff against - that is the first-commit case from
`instructions/setup-instance.md`, where everything is untracked anyway - so
a failure here is normal and yields no entries rather than an error.
`-z` is required, not a preference: without it git renders a path with any
non-ASCII byte in quoted form ("kb/W\\303\\266rterbuch.md"), while
`_changed_files` reads raw paths from `git status --porcelain -z`. The two
then never match, the caller's lookup misses, and the file is measured as
though git had never seen it - every line an addition, no removals. A
rewritten page reported as a pure insertion hides exactly what a reviewer
is being asked to approve.
"""
result = _run(["git", "diff", "--numstat", "-z", "HEAD", "--", *paths])
if result.returncode != 0:
return {}
stats: dict[str, tuple[int, int]] = {}
# With -z each record is "added\tremoved\tpath" terminated by NUL. A rename
# or copy leaves the path empty and follows with two more records, the old
# and the new path; the new one is what `git status` reports.
records = result.stdout.split("\0")
index = 0
while index < len(records):
record = records[index]
index += 1
if not record:
continue
fields = record.split("\t")
if len(fields) < 3:
continue
added, removed, path = fields[0], fields[1], fields[2]
if not path:
if index + 1 >= len(records):
continue
path = records[index + 1]
index += 2
# git writes "-" for both counts on a binary file.
stats[path] = (-1, -1) if added == "-" else (int(added), int(removed))
return stats
def _untracked_stat(path: str) -> tuple[int, int, str]:
"""(added, removed, digest) for a file git has never seen: every line is an
addition. Anything unreadable as UTF-8 counts as binary rather than
guessing at a line count."""
full = config.ROOT / path
try:
data = full.read_bytes()
except OSError:
return (-1, -1, "")
digest = hashlib.sha256(data).hexdigest()[:16]
try:
text = data.decode("utf-8")
except UnicodeDecodeError:
return (-1, -1, digest)
return (len(text.splitlines()), 0, digest)
def _digest_of(path: str) -> str:
"""A short content digest of the working-tree file, or "" if it is gone.
This is what binds a clearance to file *contents* and not merely to file
*names*: without it, approving a list and then rewriting one of those files
before confirming would still publish, which is the same "approved A,
published B" hole the token exists to close.
"""
try:
return hashlib.sha256((config.ROOT / path).read_bytes()).hexdigest()[:16]
except OSError:
return ""
def collect_changes(paths: list[str]) -> list[FileChange]:
"""Every path `git add -A` would stage, with its status, churn and content
digest. Ordered by path so the result is stable."""
result = _run(["git", "status", "--porcelain", "-z", "-uall", "--", *paths])
if result.returncode != 0:
fail(f"git status failed:\n{result.stderr}")
entries = parse_porcelain_entries(result.stdout)
numstat = _numstat(paths)
changes: list[FileChange] = []
for status_code, path in entries:
status = describe_status(status_code)
if status == "deleted":
# A deletion's churn is every line the file had, and git already
# knows it. Short-circuiting to 0/0 here (the first version of this
# code) silently understated the headline: one 718-line file went
# out reported as `-174` against git's own `-891`, hiding four
# fifths of the removals in the one direction a reviewer most needs
# not understated. The digest stays empty - there is no content
# left to fingerprint - which is itself what moves the token.
_, removed = numstat.get(path, (0, 0))
changes.append(FileChange(path, status, 0, max(removed, 0), ""))
elif path in numstat:
added, removed = numstat[path]
changes.append(FileChange(path, status, added, removed, _digest_of(path)))
else:
added, removed, digest = _untracked_stat(path)
changes.append(FileChange(path, status, added, removed, digest))
return sorted(changes, key=lambda change: change.path)
def _changed_files(paths: list[str]) -> list[str]:
"""Every path `git add -A` would stage, including untracked files,
optionally restricted to a pathspec."""
result = _run(["git", "status", "--porcelain", "-z", "-uall", "--", *paths])
if result.returncode != 0:
fail(f"git status failed:\n{result.stderr}")
return parse_porcelain_z(result.stdout)
def current_branch() -> Optional[str]:
"""The checked-out branch, or None in a detached HEAD / non-checkout."""
result = _run(["git", "rev-parse", "--abbrev-ref", "HEAD"])
if result.returncode != 0:
return None
name = result.stdout.strip()
return None if not name or name == "HEAD" else name
def branch_mismatch_message(checked_out: Optional[str], branch: str) -> str:
"""The ERROR shown when the push target is not the checked-out branch.
`git push <remote> <branch>` pushes the *ref named* `branch`, not `HEAD`.
On a feature branch that silently pushes an unrelated (usually unchanged)
ref and still exits 0, so the command reported "Published" while the commit
it had just made stayed local - the same class of failure as an ignore rule
that quietly un-publishes content.
"""
where = f"branch `{checked_out}`" if checked_out else "a detached HEAD"
return (
f"Refusing to push `{branch}` while on {where}. `git push` would push the ref named "
f"`{branch}`, not the commit just made, and would report success without publishing "
"anything. Either check out the branch you mean to publish, or pass "
f"`--branch {checked_out}` to push this one."
if checked_out
else (
f"Refusing to push `{branch}` from {where}. Check out a branch first - pushing a "
"named ref from a detached HEAD would publish something other than the commit "
"just made."
)
)
# Files `wikitool` writes itself. They are committed and pushed like everything
# else, but they carry no decision: each is recomputable from the tree at any
# commit by `index rebuild` / `sources rebuild-index` / the log append that
# produced it. Nothing is lost by not reading them, and nothing is protected by
# making a reviewer approve them.
#
# They are therefore exempt from the gate's *count*, for the same reason `work/`
# is. A routine ingest touches five or six of these - kb/index.md, kb/log.md,
# kb/provenance.md and one INDEX.md per collection it wrote into - so counting
# them pushed ordinary single-source ingests over a threshold meant for mass
# updates. Three consecutive ingests on 2026-08-31 each stopped at the gate with
# 5-7 of their files generated; none of them was a mass update.
GENERATED_SUFFIXES = ("/INDEX.md",)
GENERATED_PATHS = ("kb/index.md", "kb/log.md", "kb/provenance.md")
def is_generated(path: str) -> bool:
return path in GENERATED_PATHS or path.endswith(GENERATED_SUFFIXES)
def is_exempt(path: str, prefixes: tuple[str, ...] = GATE_EXEMPT_PREFIXES) -> bool:
"""Whether the Mass-Update Gate leaves this path out of its count."""
return path.startswith(prefixes) or is_generated(path)
def counted_files(changed_files: list[str], prefixes: tuple[str, ...] = GATE_EXEMPT_PREFIXES) -> list[str]:
"""The subset of `changed_files` the Mass-Update Gate counts.
Exempt paths are still staged, committed and pushed - only the threshold
ignores them.
"""
return [path for path in changed_files if not is_exempt(path, prefixes)]
def counted_files_of(
changes: list[FileChange], prefixes: tuple[str, ...] = GATE_EXEMPT_PREFIXES
) -> list[FileChange]:
"""`counted_files` over the richer records. One exemption rule, applied in
both shapes, so the count the gate enforces and the list it shows can never
disagree."""
return [change for change in changes if not is_exempt(change.path, prefixes)]
YES_REMOVED_MESSAGE = (
"--yes no longer exists. The Mass-Update Gate is cleared with `--confirm <token>`, and the "
"token comes from the gate's own refusal output - run this command without it first to see "
"the file list and the exact line to re-run."
)
def _exempt_note(changed_files: list[str], counted: list[str]) -> str:
"""Account for every changed file the count leaves out, by reason.
A reviewer who sees "11 counted" next to a 17-file commit needs the other
six explained, or the number reads like a bug. Naming the two reasons
separately also keeps them honestly distinct: `work/` is scratch state,
generated files are recomputable output.
"""
if len(changed_files) == len(counted):
return ""
scratch = sum(1 for p in changed_files if p.startswith(GATE_EXEMPT_PREFIXES))
generated = sum(1 for p in changed_files if is_generated(p))
reasons = []
if scratch:
prefixes = "/, ".join(p.rstrip("/") for p in GATE_EXEMPT_PREFIXES)
reasons.append(f"{scratch} under {prefixes}/")
if generated:
reasons.append(f"{generated} generated by wikitool")
return (
f" ({len(changed_files)} changed in total; "
f"{' and '.join(reasons)} committed but not counted)"
)
# How the file list is grouped for review, most-consequential first. Mirrors
# the pipeline stages AGENTS.md defines rather than inventing a second taxonomy:
# a reader who knows the repo already knows these names. The `note` is what the
# group means for the *reviewer* - which is the part a bare path cannot say.
#
# Order matters and is not alphabetical: published knowledge and the control
# plane are what a wrong publish does lasting damage to, so they are read first
# and the mechanically-regenerated files are read last, if at all.
FILE_GROUPS: tuple[tuple[str, tuple[str, ...], str], ...] = (
("Published knowledge", ("kb/",), "goes public on push; what the wiki claims to know"),
("Agent control plane", ("AGENTS.md", "instructions/", "types/"),
"changes how every future session behaves"),
("Source material", ("raw/",), "immutable inputs - changes here are unusual"),
("Tooling", ("tools/",), "the compiler itself"),
("Harness config", (".claude/", ".github/", ".vibe/", ".agents/"),
"hooks and permissions - affects what the harness lets an agent do"),
("Human docs", ("README.md", "INSTALL.md", "EVALS.md", "CHANGES.md"), "prose, no runtime effect"),
("Workshop", ("work/",), "scratch state, deleted when the run closes"),
)
OTHER_GROUP = ("Other", (), "")
def group_of(path: str) -> tuple[str, str]:
"""(group title, reviewer note) for one path."""
if is_generated(path):
return ("Generated", "rebuilt by wikitool - no review needed")
for title, prefixes, note in FILE_GROUPS:
if path.startswith(prefixes):
return (title, note)
return (OTHER_GROUP[0], OTHER_GROUP[2])
def _plural(count: int, noun: str) -> str:
return f"{count} {noun}" if count == 1 else f"{count} {noun}s"
def scale_line(changes: list[FileChange]) -> str:
"""One line answering "how big is this?" before any of the detail."""
added = sum(max(c.added, 0) for c in changes)
removed = sum(max(c.removed, 0) for c in changes)
by_status: dict[str, int] = {}
for change in changes:
by_status[change.status] = by_status.get(change.status, 0) + 1
breakdown = ", ".join(
f"{by_status[status]} {status}"
for status in ("added", "modified", "renamed", "deleted")
if by_status.get(status)
)
return (
f"Scale: {_plural(len(changes), 'file')}, +{added:,}/-{removed:,} lines ({breakdown})."
)
def attention_notes(changes: list[FileChange]) -> list[str]:
"""The handful of facts that make one publish riskier than another.
Deliberately not a score. Each line is a checkable statement about the
changeset, so a reader can disagree with it by looking; a number would
invite trusting it instead. Only lines that apply are emitted - a list of
"0 deletions" reassurances is how a reviewer learns to skim.
"""
notes: list[str] = []
deleted = [c for c in changes if c.status == "deleted"]
if deleted:
shown = ", ".join(c.path for c in deleted[:3])
more = f", +{len(deleted) - 3} more" if len(deleted) > 3 else ""
notes.append(
f"{_plural(len(deleted), 'file')} DELETED: {shown}{more} - a push makes that "
"removal public"
)
control = [c for c in changes if group_of(c.path)[0] == "Agent control plane"]
if control:
notes.append(
f"{_plural(len(control), 'file')} in the agent control plane "
"(AGENTS.md/instructions/types) - changes behaviour in every future session"
)
harness = [c for c in changes if group_of(c.path)[0] == "Harness config"]
if harness:
notes.append(
f"{_plural(len(harness), 'file')} of harness config - hooks and permissions decide "
"what an agent is allowed to do without asking"
)
pages = [c for c in changes
if c.path.startswith("kb/") and c.path.endswith(".md") and not is_generated(c.path)]
if pages:
notes.append(f"{_plural(len(pages), 'published wiki page')} changed")
scored = [c for c in changes if c.churn > 0]
if scored:
largest = max(scored, key=lambda c: c.churn)
if largest.churn >= 100:
notes.append(f"largest single change: {largest.path} ({largest.churn_text})")
binary = [c for c in changes if c.added < 0 and c.status != "deleted"]
if binary:
notes.append(
f"{_plural(len(binary), 'binary file')} - contents cannot be reviewed as a diff"
)
return notes
def format_changes(changes: list[FileChange]) -> str:
"""The grouped file list. Every path still appears exactly once - grouping
reorders and annotates, it never summarises anything away, because the
complete list is the artifact the user is being asked to approve."""
order = [title for title, _, _ in FILE_GROUPS] + ["Generated", OTHER_GROUP[0]]
grouped: dict[str, list[FileChange]] = {}
notes: dict[str, str] = {}
for change in changes:
title, note = group_of(change.path)
grouped.setdefault(title, []).append(change)
notes[title] = note
width = max((len(c.path) for c in changes), default=0)
blocks: list[str] = []
for title in order:
members = grouped.get(title)
if not members:
continue
added = sum(max(c.added, 0) for c in members)
removed = sum(max(c.removed, 0) for c in members)
note = f" - {notes[title]}" if notes.get(title) else ""
header = f"{title} ({_plural(len(members), 'file')}, +{added:,}/-{removed:,}){note}"
lines = [
f" {c.status[0].upper()} {c.path.ljust(width)} {c.churn_text}"
for c in members
]
blocks.append(header + "\n" + "\n".join(lines))
return "\n\n".join(blocks)
def changeset_token(
counted: list[FileChange], threshold: int, remote: str, branch: str, paths: list[str]
) -> str:
"""sha256 over the counted files - path *and* content digest - plus the
publish target, cut to 12 hex chars. Same changeset, same token; anything
else needs its own clearance.
Content is part of the input, not just the file names. A token over names
alone would let an approved list be published with different contents:
clear a list, rewrite one of those files, confirm, and the user's approval
would cover text they never saw. That is the same "approved A, published B"
hole the token exists to close, one level down.
`counted` is already filtered through `counted_files()`, so an exempt
`work/` path changing does not move the token - the gate never asked about
those files in the first place.
Stateless on purpose: there is no ticket file, no TTL, and no session
bookkeeping to get out of sync. The token *is* the record of what was
shown, and it can be recomputed from the working tree at any time.
"""
payload = json.dumps(
{
"counted": sorted((c.path, c.digest) for c in counted),
"threshold": threshold,
"remote": remote,
"branch": branch,
"paths": sorted(paths),
},
sort_keys=True,
)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:12]
def rerun_command(token: str, message: str, push: bool, threshold: int, remote: str,
branch: str, paths: list[str]) -> str:
"""The exact command line that clears this gate, ready to copy.
Reconstructed rather than echoed from argv so it is correct regardless of
how the original call was spelled, and quoted so a message with spaces
survives. `--confirm` comes first so the whole line has a stable prefix a
harness permission rule can match on (see .claude/settings.json)."""
parts = ["tools/wikitool", "publish", "--confirm", token, "--message", message]
if not push:
parts.append("--no-push")
if threshold != DEFAULT_MASS_UPDATE_THRESHOLD:
parts += ["--threshold", str(threshold)]
if remote != "origin":
parts += ["--remote", remote]
if branch != "main":
parts += ["--branch", branch]
for p in paths:
parts += ["--path", p]
return " ".join(shlex.quote(part) for part in parts)
def clearance_message(changes: list[FileChange], threshold: int, token: str, rerun: str,
remote: str, branch: str, stale_token: Optional[str] = None) -> str:
"""The whole user-facing artifact of this gate.
Written to be shown to a human verbatim: what would happen, the evidence
(every counted file), and the one line that proceeds. The agent-facing
instruction layer deliberately holds none of this - a procedure written
down in advance is a ritual an agent can perform without ever involving a
human, which is what the three 2026-08 incidents all looked like.
**On the wording of the instruction line.** It used to read "SHOW THIS
OUTPUT TO THE USER", and the first agent to receive it answered with a file
*count* and a pointer to "the output above" - because on a harness that
runs `wikitool` through a shell tool, a command's stdout goes to the agent's
context, not to the user's screen. Printing and showing are different acts
there, and an instruction that conflates them reads as already satisfied the
moment the text exists. So the line now names the act that is actually
required: reproduce the list, in the reply, because the user cannot see
this.
"""
counted = counted_files_of(changes)
exempt_note = _exempt_note([c.path for c in changes], [c.path for c in counted])
notes = attention_notes(counted)
attention = ("\n\nWorth a closer look:\n" + "\n".join(f"- {n}" for n in notes)) if notes else ""
stale_line = (
f"\nThe token you passed ({stale_token}) does not match this changeset. Files or their "
"contents changed since it was issued, so that clearance does not carry - here is the "
"current state.\n"
if stale_token
else ""
)
return (
f"Mass-Update Gate: this publish would commit and push {len(counted)} counted files "
f"(>= threshold {threshold}){exempt_note} to {remote}/{branch}. A push there is "
f"immediately visible and not cheaply reversible.\n{stale_line}"
f"\n{scale_line(counted)}{attention}\n"
"\nTHE USER CANNOT SEE THIS OUTPUT. It went to your context, not to their screen.\n"
f"Reproduce the {len(counted)}-file breakdown below in your reply - the groups, the "
"paths, and the sizes - and stop there. A count, a summary, a description of the change, "
'or a pointer to "the output above" is not the list, and leaves the user approving '
"something they never saw. Run no further commands in this turn.\n\n"
"Once they have replied approving this exact changeset, the line that publishes it is:\n\n"
f" {rerun}\n\n"
f"The token {token} covers both the file list and the contents below; edit any of it and "
"the token changes, and clearance is asked again.\n\n"
f"CHANGES BY AREA ({len(counted)} files) - reproduce this in your reply:\n\n"
f"{format_changes(counted)}"
)
def remote_ref_exists(remote: str, branch: str) -> bool:
"""Whether `<remote>/<branch>` resolves at all - false for a remote that was never
fetched, or a branch that has never been pushed (the very first `publish`)."""
return _run(["git", "rev-parse", "--verify", "-q", f"{remote}/{branch}"]).returncode == 0
def fetch_remote(remote: str, branch: str) -> bool:
"""`git fetch <remote> <branch>`, true on success. A failure here (no remote configured,
network/auth, or a branch that does not exist on the remote yet) is never fatal on its own -
every caller falls back to today's behaviour and lets the eventual `git push` report the
real error, so an offline or brand-new instance sees no new failure mode."""
return _run(["git", "fetch", remote, branch]).returncode == 0
def divergence(local_ref: str, remote_ref: str) -> str:
"""Where `local_ref` stands relative to `remote_ref`: "up-to-date", "ff-possible" (remote
only, local can fast-forward), "local-ahead" (local only, nothing to pull), or "diverged"
(both sides have commits the other lacks - the race the rest of this module exists for)."""
result = _run(["git", "rev-list", "--left-right", "--count", f"{local_ref}...{remote_ref}"])
local_only, remote_only = (int(n) for n in result.stdout.split())
if local_only == 0 and remote_only == 0:
return "up-to-date"
if local_only == 0:
return "ff-possible"
if remote_only == 0:
return "local-ahead"
return "diverged"
def merge_base(a: str, b: str) -> Optional[str]:
result = _run(["git", "merge-base", a, b])
return result.stdout.strip() if result.returncode == 0 else None
def touched_files(base: str, tip: str) -> set[str]:
"""The files that changed between `base` and `tip` - the mechanical proxy this module uses
for "could these two commit ranges collide": disjoint file sets cannot produce a content
conflict, so a rebase between them needs no human/LLM review, only overlapping ones do."""
result = _run(["git", "diff", "--name-only", base, tip])
return {line for line in result.stdout.splitlines() if line}
def oneline_log(base: str, tip: str) -> list[str]:
result = _run(["git", "log", "--oneline", f"{base}..{tip}"])
return [line for line in result.stdout.splitlines() if line]
def rebase_review_token(remote: str, branch: str, local_before: str, remote_tip: str,
overlap_files: list[str]) -> str:
"""sha256 over the exact upstream state and overlapping-file set a rebase-review gate was
issued for, cut to 12 hex chars - same shape as `changeset_token`. Either side moving
(remote gains another commit, or the overlap set changes) changes the token, so a stale
`--confirm-rebase` is rejected the same way a stale Mass-Update `--confirm` already is."""
payload = json.dumps(
{
"remote": remote, "branch": branch, "local_before": local_before,
"remote_tip": remote_tip, "overlap_files": sorted(overlap_files),
},
sort_keys=True,
)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:12]
@dataclass
class ReconcileOutcome:
"""What happened when the local branch was brought up to date with the remote before a
publish/sync. `status` is one of: "no-remote-or-fetch-failed", "up-to-date",
"fast-forwarded", "local-ahead", "rebased", "needs-review", "conflict"."""
status: str
pulled_commits: list[str] = field(default_factory=list)
overlap_files: list[str] = field(default_factory=list)
overlap_diff: str = ""
token: str = ""
was_reviewed: bool = False
detail: str = ""
def reconcile(remote: str, branch: str, confirm_rebase: Optional[str] = None) -> ReconcileOutcome:
"""Fetch `<remote>/<branch>` and reconcile the local `branch` with it: fast-forward when
the remote is simply ahead, rebase local commits on top when both sides moved but touch
disjoint files (a content conflict is then impossible by construction), and refuse - via
the same exit-42 idiom as the Mass-Update Gate - when both sides touched the same file,
until `confirm_rebase` matches the token that refusal issues. Read-only until the moment it
actually rewrites history: an overlap that has not been cleared performs no rebase attempt
at all, so a refused call leaves the branch exactly where it was found.
Never commits, never pushes, never force-anything. Callers are `sync` (this is its whole
job) and `publish` (proactively before staging, and once more if the eventual push is
rejected - the real, narrow race this whole module exists to close)."""
if not fetch_remote(remote, branch) or not remote_ref_exists(remote, branch):
return ReconcileOutcome(status="no-remote-or-fetch-failed")
remote_ref = f"{remote}/{branch}"
state = divergence(branch, remote_ref)
if state == "up-to-date":
return ReconcileOutcome(status="up-to-date")
if state == "ff-possible":
pulled = oneline_log(branch, remote_ref)
result = _run(["git", "merge", "--ff-only", remote_ref])
if result.returncode != 0:
# Only reachable if uncommitted local changes would be overwritten by the merge -
# git itself refuses and touches nothing, so this is a safe abort, not a half-done
# state.
return ReconcileOutcome(status="conflict", detail=result.stderr)
return ReconcileOutcome(status="fast-forwarded", pulled_commits=pulled)
if state == "local-ahead":
return ReconcileOutcome(status="local-ahead")
# "diverged": both sides have unshared commits - the actual race.
local_before = _run(["git", "rev-parse", branch]).stdout.strip()
remote_tip = _run(["git", "rev-parse", remote_ref]).stdout.strip()
base = merge_base(branch, remote_ref)
if not base:
return ReconcileOutcome(status="conflict", detail="no common history with remote")
upstream_commits = oneline_log(base, remote_tip)
overlap = sorted(touched_files(base, local_before) & touched_files(base, remote_tip))
was_reviewed = False
token = ""
if overlap:
token = rebase_review_token(remote, branch, local_before, remote_tip, overlap)
if confirm_rebase != token:
diff = _run(["git", "diff", base, remote_tip, "--", *overlap]).stdout
return ReconcileOutcome(
status="needs-review", pulled_commits=upstream_commits,
overlap_files=overlap, overlap_diff=diff, token=token,
)
was_reviewed = True
rebase_result = _run(["git", "rebase", remote_ref])
if rebase_result.returncode != 0:
_run(["git", "rebase", "--abort"])
return ReconcileOutcome(status="conflict", detail=rebase_result.stderr)
return ReconcileOutcome(
status="rebased", pulled_commits=upstream_commits,
overlap_files=overlap, was_reviewed=was_reviewed, token=token,
)
def _local_ahead_of_remote(remote: str, branch: str) -> bool:
"""Whether `branch` currently has a commit `<remote>/<branch>` lacks - true right after a
stranded commit from a previous failed push, and also right after `reconcile` rebases local
work on top of a moved remote. Used to decide whether an otherwise-empty working tree still
has something to push."""
if not remote_ref_exists(remote, branch):
return False
result = _run(["git", "rev-list", "--count", f"{remote}/{branch}..{branch}"])
return result.returncode == 0 and result.stdout.strip() not in ("", "0")
def rebase_review_message(outcome: ReconcileOutcome, remote: str, branch: str, command: str) -> str:
"""The whole user-facing artifact of the rebase-review gate, written in the same voice as
`clearance_message`: what arrived, what it touches, the diff to actually read, and the exact
line that proceeds once a human has seen it."""
commits = "\n".join(f" {c}" for c in outcome.pulled_commits) or " (none)"
files = "\n".join(f" - {f}" for f in outcome.overlap_files)
rerun = f"tools/wikitool {command} --confirm-rebase {outcome.token}"
return (
f"Rebase Review: {remote}/{branch} has moved, and the incoming commit(s) touch "
f"{len(outcome.overlap_files)} file(s) this {command} is also changing. Rebasing would "
"replay local work on top of them without anyone having read what changed - unlike a "
"disjoint rebase, a content collision here is actually possible.\n\n"
"THE USER CANNOT SEE THIS OUTPUT. It went to your context, not to their screen.\n\n"
f"Commits arriving from {remote}/{branch}:\n{commits}\n\n"
f"Files touched on both sides:\n{files}\n\n"
f"Diff of those files as they stand on {remote}/{branch} since the common ancestor:\n\n"
f"{outcome.overlap_diff}\n\n"
"Read that diff, judge whether it conflicts logically with what you are about to "
"publish, and summarize your judgment to the user - reproduce the file list above, not "
'a count or "the output above" - before proceeding. Once they have agreed, the line '
f"that continues is:\n\n {rerun}\n\n"
f"The token {outcome.token} covers this exact upstream state and file overlap; either "
"moving before you re-run changes it, and this gate asks again with the current state."
)
def _reconcile_summary(outcome: ReconcileOutcome, remote: str, branch: str) -> str:
"""One line for the outcomes that do not fail or gate - what to tell the caller, or "" for
the ones not worth narrating every time (`up-to-date`, no remote)."""
if outcome.status == "fast-forwarded":
return f"Pulled {len(outcome.pulled_commits)} commit(s) from {remote}/{branch}."
if outcome.status == "local-ahead":
return f"Local branch is ahead of {remote}/{branch}; nothing to pull."
if outcome.status == "rebased":
reviewed = " after review" if outcome.was_reviewed else " (no file overlap, automatic)"
return f"Rebased onto {len(outcome.pulled_commits)} new commit(s) from {remote}/{branch}{reviewed}."
if outcome.status == "up-to-date":
return f"Already up to date with {remote}/{branch}."
if outcome.status == "no-remote-or-fetch-failed":
return f"No remote configured, or {remote} could not be reached - continuing without a pull."
return ""
def apply_reconcile(outcome: ReconcileOutcome, remote: str, branch: str, command: str) -> str:
"""Turn a `ReconcileOutcome` into this module's fail/needs_clearance contract: raises via
`fail()` on `conflict`, raises via `needs_clearance()` on `needs-review` (emitting the same
`gate.refused` shape the Mass-Update Gate uses, so the generic trajectory rules cover this
gate for free), emits `gate.cleared` when a rebase just consumed a matching
`--confirm-rebase`, and otherwise returns a one-line summary for the caller to print."""
if outcome.status == "conflict":
fail(
f"Automatic rebase against {remote}/{branch} failed - resolve manually "
f"(network/auth/merge conflict), do not force-push without asking the user:\n{outcome.detail}"
)
if outcome.status == "needs-review":
emit(
"wikitool", "gate.refused",
{
"gate": "rebase-review", "reason": "needs-clearance", "token": outcome.token,
"presented_token": None, "remote": remote, "branch": branch,
"overlap_files": outcome.overlap_files,
},
)
needs_clearance(rebase_review_message(outcome, remote, branch, command))
if outcome.status == "rebased" and outcome.was_reviewed:
emit(
"wikitool", "gate.cleared",
{
"gate": "rebase-review", "token": outcome.token, "remote": remote,
"branch": branch, "overlap_files": outcome.overlap_files,
},
)
return _reconcile_summary(outcome, remote, branch)
def sync_command(
remote: str = typer.Option("origin", "--remote", help="Git remote to reconcile against"),
branch: str = typer.Option("main", "--branch", help="Branch to reconcile"),
confirm_rebase: Optional[str] = typer.Option(
None,
"--confirm-rebase",
metavar="TOKEN",
help="Clear the rebase-review gate for the exact upstream state and file overlap this "
"token was issued for. The token comes from the gate's own refusal output.",
),
):
"""Fetch `<remote>/<branch>` and bring the local branch up to date with it: fast-forward
when possible, rebase local commits on top when that is safe, and ask for review when it is
not. Never commits, never pushes - the read-only-until-safe counterpart to `publish`'s own
reconcile step, meant to run once at the start of a writing session so the rest of it works
against a current tree instead of discovering the drift at the final push."""
outcome = reconcile(remote, branch, confirm_rebase)
summary = apply_reconcile(outcome, remote, branch, "sync")
success(summary or f"Nothing to reconcile against {remote}/{branch}.")
def publish_command(
message: str = typer.Option(..., "--message", help="Commit message summary, e.g. 'ingest: docker-cheatsheet'"),
push: bool = typer.Option(True, "--push/--no-push"),
confirm: Optional[str] = typer.Option(
None,
"--confirm",
metavar="TOKEN",
help="Clear the Mass-Update Gate for the exact changeset this token was issued for. "
"The token comes from the gate's own refusal output - run without it first to see the "
"file list, show that output to the user, and only pass this once they have approved it.",
),
confirm_rebase: Optional[str] = typer.Option(
None,
"--confirm-rebase",
metavar="TOKEN",
help="Clear the rebase-review gate raised by the pre-push reconcile step. The token "
"comes from that gate's own refusal output - see `tools/wikitool sync`.",
),
yes: bool = typer.Option(
False,
"--yes",
"-y",
hidden=True,
help="Removed - kept only so a stale invocation gets a real ERROR instead of a Typer "
"usage error. The gate is cleared with --confirm <token>.",
),
threshold: int = typer.Option(
DEFAULT_MASS_UPDATE_THRESHOLD,
"--threshold",
help="Mass-Update Gate threshold: number of counted files at/above which clearance is required.",
),
remote: str = typer.Option("origin", "--remote", help="Git remote to push to"),
branch: str = typer.Option("main", "--branch", help="Branch to push"),
path: Optional[list[str]] = typer.Option(
None,
"--path",
help="Limit this publish to a path (repeatable). Lets a large change be committed in "
"reviewable batches instead of one opaque commit - the Mass-Update Gate then counts "
"only the files in scope.",
),
):
"""Stage all changes, commit, and push to <remote>/<branch>."""
if yes:
fail(YES_REMOVED_MESSAGE)
paths = list(path or [])
# Checked before anything is staged, for the same reason as the gate below:
# a refused publish must leave the working tree exactly as it was found.
# `--branch` names the ref git will push, which is not necessarily the ref
# this commit lands on - see branch_mismatch_message().
checked_out = current_branch()
if push and checked_out != branch:
fail(branch_mismatch_message(checked_out, branch))
# Before the reconcile below, which is the first thing that talks to the
# remote at all: a publish aimed at the wrong repository should not even
# fetch from it, and the refusal message promises that nothing was
# committed or pushed.
if push:
refusal = publish_remote_refusal(remote, branch)
if refusal:
emit(
"wikitool",
"gate.refused",
{
"gate": "publish-remote",
"reason": "remote-not-allowed",
"remote": remote,
"branch": branch,
"url": push_url_for(remote),
},
)
needs_clearance(refusal)
# Pull against the remote before anything else, to minimise the window in which this
# publish could diverge from it - and, as a side effect, to finally publish a commit left
# stranded by a previous push that failed (see `_local_ahead_of_remote` below). Skipped
# entirely for `--no-push`: nothing is being published this call, so there is no race to
# protect against, and rewriting local history when nobody asked to publish would surprise
# a deliberate local-only commit.
if push:
outcome = reconcile(remote, branch, confirm_rebase)
summary = apply_reconcile(outcome, remote, branch, "publish")
if summary:
typer.echo(summary)
# The gate is evaluated *before* anything is staged, so a refused publish
# leaves the working tree exactly as it was found.
changes = collect_changes(paths)
local_ahead = push and _local_ahead_of_remote(remote, branch)
if not changes:
if not local_ahead:
success("Nothing to commit.")
return
# A stranded commit from an earlier failed publish (or one the reconcile step above
# just rebased back on top of the remote): nothing new to stage, but there is still
# something to push below.
typer.echo("No new changes to commit; publishing the existing local commit(s).")
changed_files = [c.path for c in changes]
counted = counted_files_of(changes)
if len(counted) >= threshold:
token = changeset_token(counted, threshold, remote, branch, paths)
if confirm != token:
# Both "no clearance yet" and "clearance for a changeset that has
# since moved" end here: the user has not seen *this* state, so the
# answer is the same, only the explanation differs.
emit(
"wikitool",
"gate.refused",
{
"gate": "mass-update",
"reason": "stale-token" if confirm else "needs-clearance",
"token": token,
"presented_token": confirm,
"changed": len(changes),
"counted": len(counted),
"threshold": threshold,
"files": [c.path for c in counted],
},
)
needs_clearance(clearance_message(
changes, threshold, token,
rerun_command(token, message, push, threshold, remote, branch, paths),
remote, branch, stale_token=confirm,
))
emit(
"wikitool",
"gate.cleared",
{
"gate": "mass-update",
"token": token,
"counted": len(counted),
"threshold": threshold,
"files": [c.path for c in counted],
},
)
if changes:
add_result = _run(["git", "add", "-A", "--", *paths])
if add_result.returncode != 0:
fail(f"git add failed:\n{add_result.stderr}")
file_list = "\n".join(f"- {f}" for f in changed_files)
full_message = f"{message}\n\nFiles changed:\n{file_list}"
# With a pathspec, `git commit -- <paths>` commits exactly those paths and
# ignores anything else that happens to be staged, so batches stay disjoint.
commit_args = ["git", "commit", "-m", full_message]
if paths:
commit_args += ["--", *paths]
commit_result = _run(commit_args)
if commit_result.returncode != 0:
fail(f"git commit failed:\n{commit_result.stderr}")
typer.echo(commit_result.stdout)
if push:
push_result = _run(["git", "push", remote, branch])
if push_result.returncode != 0:
# The real, narrow race this module exists to close: something landed on the
# remote between the reconcile above and this push. One more reconcile-and-retry,
# never a loop - if it finds nothing new, the rejection had some other cause
# (branch protection, a hook), and the *original* error is what gets reported.
retry_outcome = reconcile(remote, branch, confirm_rebase)
if retry_outcome.status in ("fast-forwarded", "rebased"):
retry_summary = apply_reconcile(retry_outcome, remote, branch, "publish")
if retry_summary:
typer.echo(retry_summary)
push_result = _run(["git", "push", remote, branch])
elif retry_outcome.status in ("conflict", "needs-review"):
apply_reconcile(retry_outcome, remote, branch, "publish") # raises
if push_result.returncode != 0:
fail(
"git push failed - resolve manually (network/auth/merge conflict), "
f"do not force-push without asking the user:\n{push_result.stderr}"
)
typer.echo(push_result.stdout)
emit(
"wikitool",
"publish.commit",
{
"summary": message,
"files": changed_files,
"changed": len(changed_files),
"counted": len(counted_files(changed_files)),
"paths": paths,
"pushed": push,
"remote": remote,
"branch": branch,
},
)
success(f"Published changes to {remote}/{branch}." if push else "Committed changes (not pushed).")