7263f85936
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
1021 lines
40 KiB
Python
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)
|