Chemenu 2.1.0 - deterministischer Wissenskompiler
CI / verify (push) Failing after 32s
Release / release (push) Successful in 38s

Chemenu kompiliert Rohnotizen zu einem verlinkten, quellengebundenen Wiki:
raw/ -> types/ + tools/ -> kb/ -> reports/. Was mechanisch ist, macht
tools/wikitool; was Urteil braucht, macht ein Agent unter Contracts, deren
Grenzen in Code durchgesetzt sind statt im Prompt.

Dieser Commit ist der Startpunkt der oeffentlichen Historie. Die vorherige
Entwicklung fand in einer privaten Instanz statt und ist nicht Teil dieses
Repositorys; ihre Erzaehlung steht vollstaendig in CHANGES.md, das mit 44
Eintraegen von 0.1.0 bis 2.1.0 erhalten geblieben ist.

Der mitgelieferte Korpus ist ein Testbett und eine Demo: 170 Seiten ueber den
Stack selbst - Gates, Lint, Versionierung, Suche, das Wiki-Muster. Er
dokumentiert das Werkzeug mit den eigenen Mitteln des Werkzeugs.

Lizenz: AGPL-3.0 fuer den Stack (tools/, types/), CC-BY-4.0 fuer die Inhalte.
Die Grenze zwischen beiden ist der Dateiplan, den dist export berechnet -
siehe NOTICE.
This commit is contained in:
2026-09-01 16:24:34 +02:00
commit 18ae28f918
368 changed files with 50628 additions and 0 deletions
+907
View File
@@ -0,0 +1,907 @@
import subprocess
import pytest
import typer
from chemenu import config
from chemenu.commands import git_publish
from chemenu.commands._util import EXIT_NEEDS_CLEARANCE
from chemenu.commands.git_publish import (
DEFAULT_MASS_UPDATE_THRESHOLD,
GATE_EXEMPT_PREFIXES,
YES_REMOVED_MESSAGE,
FileChange,
attention_notes,
branch_mismatch_message,
changeset_token,
clearance_message,
collect_changes,
counted_files,
describe_status,
format_changes,
group_of,
is_generated,
parse_porcelain_entries,
parse_porcelain_z,
publish_command,
reconcile,
rerun_command,
scale_line,
sync_command,
)
def fc(path, status="modified", added=1, removed=0, digest="d"):
"""A FileChange without touching git - the message/grouping helpers are
pure functions over these records."""
return FileChange(path, status, added, removed, digest)
def fcs(paths, **kw):
return [fc(p, **kw) for p in paths]
def test_default_threshold_matches_farzas_rule():
assert DEFAULT_MASS_UPDATE_THRESHOLD == 10
def test_porcelain_parsing_handles_paths_with_spaces():
stdout = " M kb/concepts/Hybrid Search.md\0?? kb/Lint Report 2026-08-13.md\0"
assert parse_porcelain_z(stdout) == [
"kb/concepts/Hybrid Search.md",
"kb/Lint Report 2026-08-13.md",
]
def test_porcelain_parsing_reports_the_new_path_of_a_rename():
"""Rename entries carry the original path in a second NUL field; only the
new path is what actually gets committed."""
stdout = "R kb/concepts/New Name.md\0kb/concepts/Old Name.md\0 M AGENTS.md\0"
assert parse_porcelain_z(stdout) == ["kb/concepts/New Name.md", "AGENTS.md"]
def test_branch_mismatch_names_both_branches_and_the_fix():
"""Regression guard: `git push origin main` from a feature branch pushes the
ref named `main` - an unrelated, usually unchanged commit - and exits 0, so
publish reported success while the new commit stayed local."""
message = branch_mismatch_message("restructure-kb-collections", "main")
assert "restructure-kb-collections" in message
assert "main" in message
assert "--branch restructure-kb-collections" in message
def test_branch_mismatch_handles_detached_head():
message = branch_mismatch_message(None, "main")
assert "detached HEAD" in message
assert "--branch None" not in message
def test_porcelain_parsing_of_empty_status_is_empty():
assert parse_porcelain_z("") == []
def _msg(changed, threshold=10, token="tok123456789", stale=None):
"""`changed` may be paths (convenience) or FileChange records."""
records = [fc(c) if isinstance(c, str) else c for c in changed]
return clearance_message(
records, threshold, token,
rerun_command(token, "m", True, threshold, "origin", "main", []),
"origin", "main", stale_token=stale,
)
def test_clearance_message_lists_every_counted_file():
changed = [f"kb/entities/systems/file{i}.md" for i in range(10)]
message = _msg(changed)
assert "Mass-Update Gate" in message
assert "10 counted files" in message
assert "threshold 10" in message
for f in changed:
assert f in message
assert "CHANGES BY AREA (10 files)" in message
def test_clearance_message_tells_the_agent_to_show_it_and_stop():
"""The whole procedure lives here, not in the instruction layer."""
message = _msg(["kb/a.md"], threshold=1)
assert "THE USER CANNOT SEE THIS OUTPUT" in message
assert "Run no further commands in this turn" in message
def test_clearance_message_asks_for_the_paths_not_a_summary():
"""The first agent to receive this gate answered with a file count and a
pointer to "the output above" - which the user could not see, because a
command's stdout goes to the agent's context. The message has to name the
act (reproduce the paths), not just the intent (show the user)."""
message = _msg([f"kb/page{i}.md" for i in range(3)], threshold=1)
assert "Reproduce the 3-file breakdown below in your reply" in message
assert "reproduce this in your reply" in message
# It must say explicitly that the alternatives do not count.
assert "A count, a summary" in message
assert '"the output above"' in message
def test_clearance_message_carries_a_copy_pasteable_rerun_line():
message = _msg(["kb/a.md"], threshold=1, token="abc123456789")
assert "tools/wikitool publish --confirm abc123456789 --message m" in message
def test_clearance_message_explains_a_stale_token():
message = _msg(["kb/a.md"], threshold=1, stale="oldtoken1234")
assert "oldtoken1234" in message
assert "does not match this changeset" in message
def test_clearance_message_omits_the_stale_note_on_a_first_refusal():
assert "does not match this changeset" not in _msg(["kb/a.md"], threshold=1)
def test_rerun_command_quotes_a_message_with_spaces():
line = rerun_command("tok1", "lint: full pass", True, 10, "origin", "main", [])
assert "'lint: full pass'" in line
def test_rerun_command_preserves_non_default_options_only():
plain = rerun_command("tok1", "m", True, 10, "origin", "main", [])
assert "--no-push" not in plain
assert "--remote" not in plain
assert "--branch" not in plain
assert "--threshold" not in plain
custom = rerun_command("tok1", "m", False, 5, "upstream", "dev", ["kb/"])
assert "--no-push" in custom
assert "--threshold 5" in custom
assert "--remote upstream" in custom
assert "--branch dev" in custom
assert "--path kb/" in custom
def test_rerun_command_puts_confirm_first_for_a_stable_prefix():
"""A harness permission rule matches on a command prefix, so --confirm has
to sit in a fixed position rather than wherever the caller put it."""
line = rerun_command("tok1", "m", True, 10, "origin", "main", [])
assert line.startswith("tools/wikitool publish --confirm tok1 ")
def test_yes_removed_message_points_at_confirm():
assert "--yes" in YES_REMOVED_MESSAGE
assert "--confirm" in YES_REMOVED_MESSAGE
# --- changeset_token ---
def test_token_is_deterministic_and_order_independent():
a = changeset_token(fcs(["b.md", "a.md"]), 10, "origin", "main", [])
b = changeset_token(fcs(["a.md", "b.md"]), 10, "origin", "main", [])
assert a == b
assert len(a) == 12
def test_token_changes_with_the_file_list():
a = changeset_token(fcs(["a.md"]), 10, "origin", "main", [])
b = changeset_token(fcs(["a.md", "b.md"]), 10, "origin", "main", [])
assert a != b
def test_token_changes_with_the_publish_target():
a = changeset_token(fcs(["a.md"]), 10, "origin", "main", [])
b = changeset_token(fcs(["a.md"]), 10, "origin", "release", [])
assert a != b
def test_token_changes_when_a_files_contents_change():
"""Approving a list and then rewriting one of those files must not publish
under the old clearance - the user approved text they would no longer be
getting."""
before = changeset_token([fc("a.md", digest="aaa")], 10, "origin", "main", [])
after = changeset_token([fc("a.md", digest="bbb")], 10, "origin", "main", [])
assert before != after
def test_work_is_the_only_gate_exempt_prefix():
assert GATE_EXEMPT_PREFIXES == ("work/",)
def test_workshop_only_change_is_not_counted():
"""A workshop run routinely produces more files than the threshold, and none
of them are published knowledge - they are deleted when the run closes."""
changed = [f"work/ingest-documents-almanac/extract-{i}.md" for i in range(15)]
assert counted_files(changed) == []
def test_mixed_change_counts_only_the_kb_half():
changed = [f"kb/entities/systems/file{i}.md" for i in range(9)] + [
f"work/ingest-documents-almanac/extract-{i}.md" for i in range(5)
]
counted = counted_files(changed)
assert len(counted) == 9
assert all(path.startswith("kb/") for path in counted)
def test_gate_message_reports_both_counts_when_work_files_are_exempt():
changed = [f"kb/entities/systems/file{i}.md" for i in range(10)] + [
f"work/ingest-documents-almanac/extract-{i}.md" for i in range(5)
]
message = _msg(changed)
assert "10 counted files" in message
assert "15 changed in total" in message
assert "5 under work/" in message
# Exempt files are committed, so they must not be presented for approval.
assert "work/ingest-documents-almanac/extract-0.md" not in message
def test_gate_message_omits_the_exemption_note_when_nothing_is_exempt():
message = _msg([f"kb/a{i}.md" for i in range(10)])
assert "changed in total" not in message
def test_generated_files_are_not_counted():
"""They carry no decision - every one is recomputable from the tree by
`index rebuild` / `sources rebuild-index`. Counting them made an ordinary
ingest look like a mass update."""
generated = ["kb/index.md", "kb/log.md", "kb/provenance.md",
"kb/sources/INDEX.md", "kb/concepts/INDEX.md"]
assert counted_files(generated) == []
def test_a_routine_ingest_no_longer_reaches_the_threshold():
"""The real changeset of the 2026-08-31 comma-bug ingest: one source page,
one new concept, seven page updates, and the five files wikitool rebuilt
afterwards. Fourteen files tripped the gate; nine of them carried a
decision, which is under the threshold."""
changed = [
"kb/concepts/Detect-Repair Asymmetry.md",
"kb/concepts/Iteration and Cost Limits.md",
"kb/concepts/Lint Workflow.md",
"kb/concepts/Mass-Update Gate.md",
"kb/concepts/Self-Healing.md",
"kb/entities/projects/Chemenu.md",
"kb/entities/systems/AGENTS.md.md",
"kb/entities/tools/wikitool.md",
"kb/sources/Source - Conversation - Comma Bug.md",
"kb/concepts/INDEX.md", "kb/index.md", "kb/log.md",
"kb/provenance.md", "kb/sources/INDEX.md",
]
assert len(changed) >= DEFAULT_MASS_UPDATE_THRESHOLD
assert len(counted_files(changed)) == 9
assert len(counted_files(changed)) < DEFAULT_MASS_UPDATE_THRESHOLD
def test_generated_files_still_reach_the_threshold_when_real_pages_do():
"""The exemption lowers the count; it does not disarm the gate. Ten real
pages still trip it however much index churn rides along."""
changed = [f"kb/entities/systems/file{i}.md" for i in range(10)] + ["kb/index.md"]
assert len(counted_files(changed)) == DEFAULT_MASS_UPDATE_THRESHOLD
def test_gate_message_names_generated_files_as_their_own_reason():
"""A reviewer seeing '10 counted' against a 15-file commit needs the other
five explained, and scratch state is not the same reason as derived output."""
changed = [f"kb/entities/systems/file{i}.md" for i in range(10)] + [
"kb/index.md", "kb/log.md", "kb/provenance.md", "kb/sources/INDEX.md",
] + ["work/ingest-x/extract-0.md"]
message = _msg(changed)
assert "10 counted files" in message
assert "15 changed in total" in message
assert "1 under work/" in message
assert "4 generated by wikitool" in message
# Not presented for approval: the token covers what the human actually read.
assert "kb/provenance.md" not in message
# --- publish_command integration: a real git repo + a local bare remote ---
def _git(root, *args):
result = subprocess.run(["git", *args], cwd=root, capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return result
@pytest.fixture
def repo(tmp_path, monkeypatch):
root = tmp_path / "repo"
remote = tmp_path / "remote.git"
root.mkdir()
subprocess.run(["git", "init", "-b", "main", "--bare", str(remote)], check=True, capture_output=True)
_git(root, "init", "-b", "main")
_git(root, "config", "user.name", "Test")
_git(root, "config", "user.email", "test@example.com")
_git(root, "remote", "add", "origin", str(remote))
(root / "kb").mkdir()
(root / "README.md").write_text("init\n", encoding="utf-8")
_git(root, "add", "-A")
_git(root, "commit", "-m", "init")
_git(root, "push", "-u", "origin", "main")
monkeypatch.setattr(config, "ROOT", root)
monkeypatch.setenv("WIKITOOL_SESSION_ID", "test-session")
return root
def _write_files(root, n, prefix="kb/page"):
for i in range(n):
(root / f"{prefix}{i}.md").write_text(f"page {i}\n", encoding="utf-8")
def _publish(**overrides):
"""Call `publish_command` directly. Every parameter must be given a real
value: bypassing Typer's CLI parsing means an omitted argument keeps its
`typer.Option(...)` sentinel instead of the value it wraps."""
kwargs = dict(message="change", push=True, confirm=None, yes=False, threshold=10,
remote="origin", branch="main", path=None)
kwargs.update(overrides)
publish_command(**kwargs)
def _token_for(root, threshold=10, paths=()):
"""The token the gate would issue right now, derived from the real working
tree the same way `publish` derives it."""
from chemenu.commands.git_publish import counted_files_of
counted = counted_files_of(collect_changes(list(paths)))
return changeset_token(counted, threshold, "origin", "main", list(paths))
def test_yes_flag_fails_with_the_explicit_error_not_a_usage_error(repo):
with pytest.raises(typer.Exit) as excinfo:
_publish(message="x", yes=True)
assert excinfo.value.exit_code == 1 # an ordinary validation error, not a clearance request
def test_below_threshold_publish_goes_straight_through(repo):
_write_files(repo, 3)
_publish(message="small change")
assert _git(repo, "status", "--porcelain", "-uall").stdout == ""
def test_at_threshold_publish_asks_for_clearance_and_stages_nothing(repo):
_write_files(repo, 10)
with pytest.raises(typer.Exit) as excinfo:
_publish(message="big change")
assert excinfo.value.exit_code == EXIT_NEEDS_CLEARANCE
# Nothing staged, nothing committed - the tree is exactly as it was found.
assert len(_git(repo, "status", "--porcelain", "-uall").stdout.strip().splitlines()) == 10
def test_the_token_from_the_refusal_clears_the_gate(repo):
_write_files(repo, 10)
with pytest.raises(typer.Exit):
_publish(message="big change")
_publish(message="big change", confirm=_token_for(repo))
assert _git(repo, "status", "--porcelain", "-uall").stdout == ""
def test_an_invented_token_does_not_clear_the_gate(repo):
_write_files(repo, 10)
with pytest.raises(typer.Exit) as excinfo:
_publish(message="big change", confirm="deadbeefcafe")
assert excinfo.value.exit_code == EXIT_NEEDS_CLEARANCE
assert len(_git(repo, "status", "--porcelain", "-uall").stdout.strip().splitlines()) == 10
def test_touching_a_file_after_clearance_invalidates_the_token(repo):
"""The regression test for the hole `--yes` always had: approval for file
list A must not publish file list B."""
_write_files(repo, 10)
with pytest.raises(typer.Exit):
_publish(message="big change")
token = _token_for(repo)
_write_files(repo, 1, prefix="kb/extra") # the changeset moves
with pytest.raises(typer.Exit) as excinfo:
_publish(message="big change", confirm=token)
assert excinfo.value.exit_code == EXIT_NEEDS_CLEARANCE
assert len(_git(repo, "status", "--porcelain", "-uall").stdout.strip().splitlines()) == 11
def test_an_exempt_work_file_does_not_move_the_token(repo):
"""`work/` is committed but never counted, so it must not invalidate a
clearance the user already gave for the counted files."""
_write_files(repo, 10)
with pytest.raises(typer.Exit):
_publish(message="big change")
token = _token_for(repo)
(repo / "work").mkdir()
_write_files(repo / "work", 5, prefix="scratch")
_publish(message="big change", confirm=token)
assert _git(repo, "status", "--porcelain", "-uall").stdout == ""
def test_the_clearance_request_emits_a_matchable_token(repo):
"""`clearance-was-asked-for` matches gate.cleared against gate.refused, so
both events have to carry the same token the CLI actually computed."""
from chemenu.telemetry import reader
_write_files(repo, 10)
with pytest.raises(typer.Exit):
_publish(message="big change")
_publish(message="big change", confirm=_token_for(repo))
records = reader.read_trace("test-session")
refused = [r for r in records if r["event"] == "gate.refused"]
cleared = [r for r in records if r["event"] == "gate.cleared"]
assert refused and cleared
assert cleared[-1]["attrs"]["token"] == refused[-1]["attrs"]["token"]
# --- grouping and review hints ---
def test_status_words_come_from_the_porcelain_code():
assert describe_status("??") == "added"
assert describe_status("A ") == "added"
assert describe_status(" D") == "deleted"
assert describe_status("D ") == "deleted"
assert describe_status("R ") == "renamed"
assert describe_status(" M") == "modified"
def test_porcelain_entries_keep_the_status_alongside_the_path():
stdout = " M kb/a.md\0?? kb/b.md\0 D kb/c.md\0"
assert parse_porcelain_entries(stdout) == [
(" M", "kb/a.md"), ("??", "kb/b.md"), (" D", "kb/c.md"),
]
def test_generated_files_are_recognised_wherever_they_sit():
assert is_generated("kb/index.md")
assert is_generated("kb/log.md")
assert is_generated("kb/provenance.md")
assert is_generated("kb/concepts/INDEX.md")
assert is_generated("kb/entities/tools/INDEX.md")
assert not is_generated("kb/concepts/Modbus.md")
def test_paths_land_in_the_group_a_reviewer_expects():
assert group_of("kb/concepts/X.md")[0] == "Published knowledge"
assert group_of("AGENTS.md")[0] == "Agent control plane"
assert group_of("instructions/gates.md")[0] == "Agent control plane"
assert group_of("types/entity.md")[0] == "Agent control plane"
assert group_of("tools/chemenu/cli.py")[0] == "Tooling"
assert group_of(".claude/settings.json")[0] == "Harness config"
assert group_of("README.md")[0] == "Human docs"
assert group_of("work/run/plan.md")[0] == "Workshop"
assert group_of("something-else.txt")[0] == "Other"
def test_generated_beats_the_collection_it_sits_in():
"""kb/index.md is under kb/, but grouping it with published pages would put
a file needing no review in the group that needs the most."""
assert group_of("kb/index.md")[0] == "Generated"
def test_scale_line_totals_churn_and_breaks_down_by_status():
changes = [
fc("kb/a.md", "added", 10, 0),
fc("kb/b.md", "modified", 5, 3),
fc("kb/c.md", "deleted", 0, 0),
]
line = scale_line(changes)
assert "3 files" in line
assert "+15/-3" in line
assert "1 added" in line and "1 modified" in line and "1 deleted" in line
def test_attention_flags_deletions_by_name():
notes = attention_notes([fc("kb/gone.md", "deleted", 0, 0)])
assert any("DELETED" in n and "kb/gone.md" in n for n in notes)
def test_attention_flags_the_control_plane():
notes = attention_notes([fc("instructions/gates.md")])
assert any("control plane" in n for n in notes)
def test_attention_flags_harness_config():
notes = attention_notes([fc(".claude/settings.json")])
assert any("harness config" in n for n in notes)
def test_attention_counts_published_pages_but_not_generated_ones():
notes = attention_notes([fc("kb/concepts/X.md"), fc("kb/index.md")])
assert any("1 published wiki page changed" in n for n in notes)
def test_attention_names_a_large_change_but_ignores_small_ones():
assert not any("largest" in n for n in attention_notes([fc("kb/a.md", added=5, removed=1)]))
notes = attention_notes([fc("kb/big.md", added=400, removed=50)])
assert any("largest single change: kb/big.md" in n for n in notes)
def test_attention_is_empty_for_a_dull_changeset():
"""Only what applies is emitted - a wall of "0 deletions" reassurances is
how a reviewer learns to skim past the part that matters."""
assert attention_notes([fc("README.md", added=2, removed=1)]) == []
def test_format_lists_every_path_exactly_once():
"""Grouping reorders and annotates; it must never summarise a path away,
because the complete list is what is being approved."""
paths = ["kb/a.md", "kb/index.md", "AGENTS.md", "tools/x.py", ".claude/settings.json"]
rendered = format_changes(fcs(paths))
for path in paths:
assert rendered.count(path) == 1
def test_format_puts_published_knowledge_before_generated():
rendered = format_changes(fcs(["kb/index.md", "kb/concepts/X.md"]))
assert rendered.index("Published knowledge") < rendered.index("Generated")
def test_format_marks_a_binary_file_rather_than_faking_a_line_count():
rendered = format_changes([fc("raw/assets/diagram.png", "added", -1, -1)])
assert "binary" in rendered
def test_format_marks_a_deletion_rather_than_showing_zero_churn():
rendered = format_changes([fc("kb/gone.md", "deleted", 0, 0)])
assert "D kb/gone.md" in rendered
assert "deleted" in rendered
def test_a_deletion_reports_how_much_is_being_removed():
"""A one-line stub and a 700-line document both read as "deleted", and they
are not the same decision. Regression guard: the first version of this
reported 0 removed lines for every deletion, understating one changeset's
headline from -891 to -174."""
big = fc("instructions/plan.md", "deleted", 0, 718)
assert big.churn_text == "-718 deleted"
assert big.churn == 718
assert "-718 deleted" in format_changes([big])
assert "+0/-718" in scale_line([big])
def test_a_deletion_with_no_known_size_still_reads_as_deleted():
assert fc("kb/gone.md", "deleted", 0, 0).churn_text == "deleted"
def test_editing_a_cleared_file_invalidates_the_token(repo):
"""The token covers contents, not just names: approve a list, rewrite one
of those files, and the old clearance must not publish the new text."""
_write_files(repo, 10)
with pytest.raises(typer.Exit):
_publish(message="big change")
token = _token_for(repo)
(repo / "kb/page0.md").write_text("rewritten after clearance\n", encoding="utf-8")
with pytest.raises(typer.Exit) as excinfo:
_publish(message="big change", confirm=token)
assert excinfo.value.exit_code == EXIT_NEEDS_CLEARANCE
assert len(_git(repo, "status", "--porcelain", "-uall").stdout.strip().splitlines()) == 10
def test_collect_changes_reports_status_and_churn_from_a_real_tree(repo):
_write_files(repo, 2)
(repo / "README.md").write_text("init\nsecond line\n", encoding="utf-8")
by_path = {c.path: c for c in collect_changes([])}
assert by_path["kb/page0.md"].status == "added"
assert by_path["kb/page0.md"].added == 1
assert by_path["README.md"].status == "modified"
assert by_path["README.md"].added == 1 and by_path["README.md"].removed == 0
assert by_path["kb/page0.md"].digest # content is fingerprinted
def test_collect_changes_reports_a_deletion(repo):
(repo / "README.md").unlink()
by_path = {c.path: c for c in collect_changes([])}
assert by_path["README.md"].status == "deleted"
assert by_path["README.md"].digest == ""
def test_collect_changes_counts_the_lines_a_deletion_removes(repo):
"""End-to-end guard for the same bug: git knows the size of a deleted
tracked file, and `collect_changes` has to ask it rather than assuming 0."""
(repo / "kb/doomed.md").write_text("\n".join(f"line {i}" for i in range(40)) + "\n",
encoding="utf-8")
_git(repo, "add", "-A")
_git(repo, "commit", "-m", "add a file worth deleting")
(repo / "kb/doomed.md").unlink()
change = {c.path: c for c in collect_changes([])}["kb/doomed.md"]
assert change.status == "deleted"
assert change.removed == 40
# --- reconcile / sync: the pull-before-push path ---
def _remote_for(repo):
"""The bare remote `repo`'s fixture pushed to - a sibling directory by that fixture's own
construction, not exposed on the fixture itself."""
return repo.parent / "remote.git"
def _clone_writer(repo):
"""A second clone of the same remote, standing in for another session/machine that pushes
independently - what makes the divergence in these tests real instead of asserted."""
writer = repo.parent / "writer"
subprocess.run(["git", "clone", str(_remote_for(repo)), str(writer)],
check=True, capture_output=True)
_git(writer, "config", "user.name", "Writer")
_git(writer, "config", "user.email", "writer@example.com")
return writer
def _push_from_writer(writer, path, content):
(writer / path).write_text(content, encoding="utf-8")
_git(writer, "add", "-A")
_git(writer, "commit", "-m", f"writer commit: {path}")
_git(writer, "push", "origin", "main")
def _sync(**overrides):
kwargs = dict(remote="origin", branch="main", confirm_rebase=None)
kwargs.update(overrides)
sync_command(**kwargs)
def _rebase_token_for(repo, remote="origin", branch="main"):
"""The token a rebase-review refusal would issue right now - computed the same way the
gate itself does, for a test to hand back as `--confirm-rebase`."""
outcome = reconcile(remote, branch, None)
assert outcome.status == "needs-review", outcome.status
return outcome.token
def test_sync_fast_forwards_silently_when_only_remote_moved(repo):
"""The common case: nothing local, the writer pushed - a plain pull, no gate."""
writer = _clone_writer(repo)
_push_from_writer(writer, "from-writer.md", "hello\n")
_sync() # must not raise
assert (repo / "from-writer.md").read_text(encoding="utf-8") == "hello\n"
assert _git(repo, "log", "--oneline", "-1").stdout.strip().endswith("from-writer.md")
def test_sync_is_a_noop_when_already_up_to_date(repo):
_sync() # must not raise on a freshly-cloned, unmodified repo
def test_sync_reports_no_remote_without_failing(repo):
_git(repo, "remote", "remove", "origin")
_sync() # must not raise
def test_publish_pushes_a_stranded_local_commit_with_no_new_changes(repo):
"""The exact TODO scenario: a commit that was made but never pushed (e.g. by an earlier
failed publish) must go out on the next call, even when there is nothing new to stage."""
(repo / "kb/stranded.md").write_text("stranded\n", encoding="utf-8")
_git(repo, "add", "-A")
_git(repo, "commit", "-m", "stranded commit, never pushed")
_publish(message="retry") # nothing new in the working tree
remote_log = subprocess.run(
["git", "log", "--oneline", "-1"], cwd=_remote_for(repo), capture_output=True, text=True,
).stdout
assert "stranded commit" in remote_log
def test_publish_auto_rebases_a_disjoint_divergence(repo):
"""The writer's change and this session's change touch different files: (a) alone is
enough, so this must go straight through - no exit 42."""
writer = _clone_writer(repo)
_push_from_writer(writer, "from-writer.md", "writer content\n")
(repo / "kb/local.md").write_text("local content\n", encoding="utf-8")
_publish(message="local change") # must not raise
remote_files = subprocess.run(
["git", "log", "--name-only", "--pretty=format:"], cwd=_remote_for(repo),
capture_output=True, text=True,
).stdout
assert "from-writer.md" in remote_files and "kb/local.md" in remote_files
def test_sync_gates_on_overlapping_files(repo):
"""Both sides changed the same file: (a) is not enough on its own, so this must stop for
review instead of rebasing silently."""
(repo / "shared.md").write_text("\n".join(str(i) for i in range(1, 21)) + "\n",
encoding="utf-8")
_git(repo, "add", "-A")
_git(repo, "commit", "-m", "add shared.md")
_git(repo, "push", "origin", "main")
writer = _clone_writer(repo)
lines = (writer / "shared.md").read_text(encoding="utf-8").splitlines()
lines[0] = "1-writer"
(writer / "shared.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
_git(writer, "add", "-A")
_git(writer, "commit", "-m", "writer edits top of shared.md")
_git(writer, "push", "origin", "main")
lines = (repo / "shared.md").read_text(encoding="utf-8").splitlines()
lines[-1] = "20-local"
(repo / "shared.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
_git(repo, "add", "-A")
_git(repo, "commit", "-m", "local edits bottom of shared.md")
with pytest.raises(typer.Exit) as excinfo:
_sync()
assert excinfo.value.exit_code == EXIT_NEEDS_CLEARANCE
# Refused: the branch is exactly where the local commit left it, no rebase attempted.
assert _git(repo, "status", "--porcelain").stdout == ""
assert "20-local" in (repo / "shared.md").read_text(encoding="utf-8")
def test_confirm_rebase_clears_the_gate_and_pushes(repo):
(repo / "shared.md").write_text("\n".join(str(i) for i in range(1, 21)) + "\n",
encoding="utf-8")
_git(repo, "add", "-A")
_git(repo, "commit", "-m", "add shared.md")
_git(repo, "push", "origin", "main")
writer = _clone_writer(repo)
lines = (writer / "shared.md").read_text(encoding="utf-8").splitlines()
lines[0] = "1-writer"
(writer / "shared.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
_git(writer, "add", "-A")
_git(writer, "commit", "-m", "writer edits top of shared.md")
_git(writer, "push", "origin", "main")
lines = (repo / "shared.md").read_text(encoding="utf-8").splitlines()
lines[-1] = "20-local"
(repo / "shared.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
_git(repo, "add", "-A")
_git(repo, "commit", "-m", "local edits bottom of shared.md")
token = _rebase_token_for(repo)
_sync(confirm_rebase=token) # must not raise
merged = (repo / "shared.md").read_text(encoding="utf-8")
assert "1-writer" in merged and "20-local" in merged
# sync never pushes - the rebased commit is local-only until an explicit publish sends it.
remote_log = subprocess.run(
["git", "log", "--oneline"], cwd=_remote_for(repo), capture_output=True, text=True,
).stdout
assert "local edits bottom" not in remote_log
assert _git(repo, "status", "--porcelain").stdout == ""
def test_stale_confirm_rebase_token_reissues_the_gate(repo):
(repo / "shared.md").write_text("\n".join(str(i) for i in range(1, 21)) + "\n",
encoding="utf-8")
_git(repo, "add", "-A")
_git(repo, "commit", "-m", "add shared.md")
_git(repo, "push", "origin", "main")
writer = _clone_writer(repo)
lines = (writer / "shared.md").read_text(encoding="utf-8").splitlines()
lines[0] = "1-writer"
(writer / "shared.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
_git(writer, "add", "-A")
_git(writer, "commit", "-m", "writer edits top of shared.md")
_git(writer, "push", "origin", "main")
lines = (repo / "shared.md").read_text(encoding="utf-8").splitlines()
lines[-1] = "20-local"
(repo / "shared.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
_git(repo, "add", "-A")
_git(repo, "commit", "-m", "local edits bottom of shared.md")
stale_token = _rebase_token_for(repo)
# The remote moves again before the stale token is redeemed.
_push_from_writer(writer, "unrelated.md", "more writer work\n")
with pytest.raises(typer.Exit) as excinfo:
_sync(confirm_rebase=stale_token)
assert excinfo.value.exit_code == EXIT_NEEDS_CLEARANCE
def test_real_conflict_aborts_cleanly(repo):
"""Overlapping edits to the very same line: git itself cannot merge this, and the abort
must leave nothing half-done."""
(repo / "shared.md").write_text("original\n", encoding="utf-8")
_git(repo, "add", "-A")
_git(repo, "commit", "-m", "add shared.md")
_git(repo, "push", "origin", "main")
writer = _clone_writer(repo)
(writer / "shared.md").write_text("writer version\n", encoding="utf-8")
_git(writer, "add", "-A")
_git(writer, "commit", "-m", "writer rewrites shared.md")
_git(writer, "push", "origin", "main")
(repo / "shared.md").write_text("local version\n", encoding="utf-8")
_git(repo, "add", "-A")
_git(repo, "commit", "-m", "local rewrites shared.md")
token = _rebase_token_for(repo)
before_head = _git(repo, "rev-parse", "HEAD").stdout.strip()
with pytest.raises(typer.Exit) as excinfo:
_sync(confirm_rebase=token)
assert excinfo.value.exit_code == 1 # an ordinary failure, not a gate
assert _git(repo, "rev-parse", "HEAD").stdout.strip() == before_head
assert _git(repo, "status", "--porcelain").stdout == ""
assert not (repo / ".git" / "rebase-merge").exists()
assert not (repo / ".git" / "rebase-apply").exists()
def test_publish_reactive_retry_survives_a_genuine_race(repo, monkeypatch):
"""The narrow window this whole module exists to close: something lands on the remote
between publish's own pre-push reconcile and the push itself. One retry, no loop."""
writer = _clone_writer(repo)
raced = {"done": False}
real_run = git_publish._run
def racy_run(args):
if args[:2] == ["git", "push"] and not raced["done"]:
raced["done"] = True
_push_from_writer(writer, "mid-race.md", "landed during the push\n")
return real_run(args)
monkeypatch.setattr(git_publish, "_run", racy_run)
(repo / "kb/local.md").write_text("local\n", encoding="utf-8")
_publish(message="race") # must not raise - one reconcile-and-retry resolves it
remote_log = subprocess.run(
["git", "log", "--oneline"], cwd=_remote_for(repo), capture_output=True, text=True,
).stdout
assert "mid-race.md" in remote_log
assert "local" in subprocess.run(
["git", "log", "--name-only", "--pretty=format:"], cwd=_remote_for(repo),
capture_output=True, text=True,
).stdout
def test_rebase_review_gate_emits_matchable_telemetry(repo):
from chemenu.telemetry import reader
(repo / "shared.md").write_text("\n".join(str(i) for i in range(1, 21)) + "\n",
encoding="utf-8")
_git(repo, "add", "-A")
_git(repo, "commit", "-m", "add shared.md")
_git(repo, "push", "origin", "main")
writer = _clone_writer(repo)
lines = (writer / "shared.md").read_text(encoding="utf-8").splitlines()
lines[0] = "1-writer"
(writer / "shared.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
_git(writer, "add", "-A")
_git(writer, "commit", "-m", "writer edits top of shared.md")
_git(writer, "push", "origin", "main")
lines = (repo / "shared.md").read_text(encoding="utf-8").splitlines()
lines[-1] = "20-local"
(repo / "shared.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
_git(repo, "add", "-A")
_git(repo, "commit", "-m", "local edits bottom of shared.md")
with pytest.raises(typer.Exit):
_sync()
token = _rebase_token_for(repo)
_sync(confirm_rebase=token)
records = reader.read_trace("test-session")
refused = [r for r in records if r["event"] == "gate.refused" and r["attrs"].get("gate") == "rebase-review"]
cleared = [r for r in records if r["event"] == "gate.cleared" and r["attrs"].get("gate") == "rebase-review"]
assert refused and cleared
assert cleared[-1]["attrs"]["token"] == refused[-1]["attrs"]["token"]
def test_numstat_survives_a_non_ascii_filename(repo):
"""`git status --porcelain -z` emits raw paths, but `git diff --numstat`
quotes non-ASCII ones ("ausw\\303\\274rfeln"). When the two disagree the
numstat lookup misses and the file falls through to the untracked path,
which reports every line as an addition - a rewrite shown as a pure
insertion, hiding the removals a reviewer most needs to see."""
name = "kb/Wörterbuch.md"
(repo / name).write_text("eins\nzwei\ndrei\n", encoding="utf-8")
_git(repo, "add", "-A")
_git(repo, "commit", "-m", "add")
(repo / name).write_text("eins\nvier\n", encoding="utf-8")
change = next(c for c in collect_changes([]) if c.path == name)
assert change.status == "modified"
assert (change.added, change.removed) == (1, 2)