Files
chemenu/tools/chemenu/tests/test_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

1021 lines
40 KiB
Python

import json
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)
# --- Publish-Remote Gate -----------------------------------------------------
def _allowlist(root, *urls):
(root / config.PUBLISH_REMOTES_FILENAME).write_text(
json.dumps({"schema": 1, "allowed_push_urls": list(urls)}), encoding="utf-8"
)
def test_no_allowlist_means_unrestricted(repo):
"""Absence is a legitimate state: a checkout with nothing private in it
should not have to declare anything to publish at all."""
assert git_publish.read_allowed_push_urls() is None
assert git_publish.publish_remote_refusal("origin", "main") is None
def test_allowed_url_passes_the_gate(repo):
_allowlist(repo, git_publish.push_url_for("origin"))
assert git_publish.publish_remote_refusal("origin", "main") is None
def test_gate_refuses_a_remote_not_on_the_list(repo):
_git(repo, "remote", "add", "upstream", "https://example.com/public.git")
_allowlist(repo, git_publish.push_url_for("origin"))
refusal = git_publish.publish_remote_refusal("upstream", "main")
assert refusal is not None
assert "https://example.com/public.git" in refusal
assert "Nothing was committed or pushed" in refusal
def test_gate_matches_the_url_not_the_remote_name(repo):
"""A name-based list would pass a repointed `origin`, which is the failure
this gate exists to catch."""
_allowlist(repo, "ssh://git@example.com/only-this.git")
assert git_publish.publish_remote_refusal("origin", "main") is not None
def test_gate_reads_pushurl_when_the_remote_sets_one(repo):
"""`git push` writes to `pushurl` when present, so that is the value that
has to be checked - not the fetch URL beside it."""
_git(repo, "remote", "set-url", "--push", "origin", "https://example.com/elsewhere.git")
_allowlist(repo, "https://example.com/elsewhere.git")
assert git_publish.push_url_for("origin") == "https://example.com/elsewhere.git"
assert git_publish.publish_remote_refusal("origin", "main") is None
def test_gate_refuses_when_the_fetch_url_is_listed_but_the_pushurl_is_not(repo):
fetch_url = git_publish.push_url_for("origin")
_git(repo, "remote", "set-url", "--push", "origin", "https://example.com/elsewhere.git")
_allowlist(repo, fetch_url)
assert git_publish.publish_remote_refusal("origin", "main") is not None
def test_publish_exits_42_and_commits_nothing_when_the_remote_is_refused(repo):
_git(repo, "remote", "add", "upstream", "https://example.com/public.git")
_allowlist(repo, git_publish.push_url_for("origin"))
before = _git(repo, "rev-parse", "HEAD").stdout.strip()
(repo / "kb" / "secret.md").write_text("private\n", encoding="utf-8")
with pytest.raises(typer.Exit) as excinfo:
_publish(remote="upstream")
assert excinfo.value.exit_code == EXIT_NEEDS_CLEARANCE
# Nothing committed, and the file is still sitting there unstaged - the
# refusal message promises both. (`git status --porcelain` collapses the
# wholly-untracked `kb/` to one entry, so check the index directly.)
assert _git(repo, "rev-parse", "HEAD").stdout.strip() == before
assert (repo / "kb" / "secret.md").exists()
assert "secret.md" not in _git(repo, "ls-files").stdout
def test_no_push_skips_the_gate(repo):
"""`--no-push` publishes nowhere, so there is no wrong target to protect
against - and a local commit must stay possible."""
_allowlist(repo, "ssh://git@example.com/only-this.git")
(repo / "kb" / "page.md").write_text("local\n", encoding="utf-8")
_publish(push=False)
assert "page.md" in _git(repo, "show", "--name-only", "HEAD").stdout
def test_unreadable_allowlist_fails_instead_of_falling_open(repo):
"""A broken file must not be read as 'no restriction' - that would turn a
corrupted safeguard into a silently disabled one."""
(repo / config.PUBLISH_REMOTES_FILENAME).write_text("{not json", encoding="utf-8")
with pytest.raises(typer.Exit):
git_publish.read_allowed_push_urls()
def test_allowlist_without_a_usable_list_fails(repo):
(repo / config.PUBLISH_REMOTES_FILENAME).write_text(
json.dumps({"schema": 1, "allowed_push_urls": "not-a-list"}), encoding="utf-8"
)
with pytest.raises(typer.Exit):
git_publish.read_allowed_push_urls()
def test_empty_allowlist_refuses_everything(repo):
"""An empty list is a deliberate 'publish nowhere', not an oversight that
should behave like an absent file."""
_allowlist(repo)
assert git_publish.publish_remote_refusal("origin", "main") is not None
def test_gate_has_no_flag_that_opens_it(repo):
"""The other two gates clear with a token; this one deliberately does not,
because the right fix is a deliberate edit by the user."""
import inspect
params = inspect.signature(publish_command).parameters
assert not any("remote" in name and "confirm" in name for name in params)