feat!: installation only from a release, into an empty folder; upstream merge/verify and private-instance.md removed, dist adopt, shell-neutral instructions (#153)
CI / verify (push) Successful in 5m19s
CI / pwsh (push) Successful in 1m55s
Release / release (push) Successful in 36s

Files changed:
- .gitea/workflows/ci.yml
- .gitea/workflows/release.yml
- AGENTS.md
- CHANGES.md
- DEVELOPMENT.md
- EVALS.md
- INSTALL.md
- README.md
- VERSION
- docs/ownership-and-templates.md
- instructions/CONTRACT.md
- instructions/bootstrap.md
- instructions/dev/dev-setup.md
- instructions/dev/stack-dev/SKILL.md
- instructions/gates.md
- instructions/ingest-large-tree.md
- instructions/kb-profiles.md
- instructions/mcp-read-server.md
- instructions/migrations/3.0.0-authoring-conventions.md
- instructions/preflight.md
- instructions/private-instance.md
- instructions/session-setup.md
- instructions/setup-instance.md
- instructions/upgrade-instance.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/cli.py
- tools/chemenu/cli_contract.py
- tools/chemenu/commands/dist_cmd.py
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/git_publish.py
- tools/chemenu/commands/upstream_cmd.py
- tools/chemenu/commands/work_cmd.py
- tools/chemenu/config.py
- tools/chemenu/ownership.py
- tools/chemenu/tests/test_cli.py
- tools/chemenu/tests/test_dist_cmd.py
- tools/chemenu/tests/test_instructions_shell.py
- tools/chemenu/tests/test_preflight.py
- tools/chemenu/tests/test_preflight_pwsh.py
- tools/chemenu/tests/test_run_budget.py
- tools/chemenu/tests/test_upstream_cmd.py
- tools/chemenu/toc.py
- tools/preflight.ps1
- tools/preflight.sh
This commit is contained in:
torben committed 2026-10-01 22:12:09 +02:00
1 parent d0f08d1fba
commit a6d07f97c4
46 files changed
+1314 -1936

No files matched your search

+1 -2
View File
@@ -254,7 +254,7 @@ def test_top_level_help_is_the_index_without_frames(monkeypatch):
line.split(" ")[0].strip() for line in text.splitlines() if " non-idempotent " in line
}
assert {
"new", "log append", "publish", "upstream merge",
"new", "log append", "publish",
"version bump", "version release", "migrate done",
} <= non_idempotent
@@ -344,7 +344,6 @@ def test_network_yes_is_exactly_the_commands_that_can_reach_outside_this_checkou
expected = {
"sync",
"publish",
"upstream merge",
"version check",
"version notes",
"dist upgrade",
+61
View File
@@ -600,3 +600,64 @@ def test_validate_markers_rejects_nested_starts():
dist_cmd._validate_markers(
"<!-- dist:strip-start --><!-- dist:strip-start --><!-- dist:strip-end -->", "x"
)
# --- dist adopt ----------------------------------------------------------------
@pytest.fixture
def instance(repo):
"""A tree in the shape `dist export` leaves behind: the collection contract
and the page type-spec with its schema only as `.template`."""
(repo / "kb" / "entities" / "COLLECTION.md").rename(
repo / "kb" / "entities" / "COLLECTION.md.template"
)
for name in ("entity.md", "entity.schema.yaml"):
(repo / "types" / name).rename(repo / "types" / f"{name}.template")
return repo
def test_adopt_without_a_path_copies_every_collection_and_type_template(instance):
dist_cmd.run_adopt([])
for relative in ("kb/entities/COLLECTION.md", "types/entity.md", "types/entity.schema.yaml"):
adopted = instance / relative
template = instance / f"{relative}.template"
assert adopted.read_bytes() == template.read_bytes()
# Not in scope: the conventions template carries a sentinel and is filled, not copied.
assert (instance / "kb" / "CONVENTIONS.md").read_text(encoding="utf-8").startswith("---")
def test_adopt_never_overwrites_an_existing_target(instance):
own = instance / "types" / "entity.md"
own.write_text("# this instance's own entity\n", encoding="utf-8")
dist_cmd.run_adopt([])
assert own.read_text(encoding="utf-8") == "# this instance's own entity\n"
assert (instance / "types" / "entity.schema.yaml").exists()
def test_adopt_with_paths_copies_only_those(instance):
dist_cmd.run_adopt([Path("types/entity.md.template")])
assert (instance / "types" / "entity.md").exists()
assert not (instance / "types" / "entity.schema.yaml").exists()
assert not (instance / "kb" / "entities" / "COLLECTION.md").exists()
@pytest.mark.parametrize(
"given", ["kb/CONVENTIONS.md.template", "USER.md.template", "types/missing.md.template"]
)
def test_adopt_refuses_a_path_outside_its_set(instance, given):
with pytest.raises(typer.Exit):
dist_cmd.run_adopt([Path(given)])
assert not (instance / "kb" / "entities" / "COLLECTION.md").exists()
def test_adopt_dry_run_writes_nothing(instance):
dist_cmd.run_adopt([], dry_run=True)
assert not (instance / "types" / "entity.md").exists()
assert not (instance / "kb" / "entities" / "COLLECTION.md").exists()
@@ -0,0 +1,121 @@
"""The stack's shipped instructions read the same in bash, Git Bash and PowerShell 7.
`instructions/CONTRACT.md` § Writing an instruction states the rule; this is the check behind it.
An instruction is run by whichever harness the operator uses - Claude Code under Git Bash on
Windows, Copilot under PowerShell 7 - so a command block that only one shell reads sends the
other agent off to translate it, which is where the Weg-D install went wrong (Gitea #140, #153).
Scope: every instruction `dist export` ships, minus `instructions/migrations/`, which belong to
the release they shipped with. A test rather than an `instructions verify` rule because the rule
binds what the stack ships; an instance's own instructions are its own decision.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[3]
INSTRUCTIONS = REPO / "instructions"
# The info strings of a block an agent runs; a json/yaml/markdown block is content, not a command.
COMMAND_FENCES = {"", "bash", "sh", "shell", "console", "powershell", "pwsh"}
FORBIDDEN = (
("heredoc", re.compile(r"<<")),
("export", re.compile(r"(^|[;&|]\s*)export\s")),
("command substitution", re.compile(r"\$\(")),
("shell variable", re.compile(r"\$\{|\$[A-Z_][A-Z0-9_]*\b")),
("inline environment", re.compile(r"^[A-Z_][A-Z0-9_]*=\S*\s+\S")),
("&&", re.compile(r"&&")),
("for loop", re.compile(r"^\s*for\s.*;\s*do\b")),
("cp", re.compile(r"(^|[;&|]\s*)cp\s")),
("cat >", re.compile(r"\bcat\s+>")),
("sha256sum", re.compile(r"\bsha256sum\b")),
("curl", re.compile(r"\bcurl\b")),
("tar", re.compile(r"(^|[;&|]\s*)tar\s")),
)
# The two sanctioned exceptions, each one line per shell (instructions/CONTRACT.md): the session
# id (D26) and the preflight download before an instance exists (E2). Keyed by exact line, so a
# second use of the same construct elsewhere still fails.
ALLOWED = {
("session-setup.md", 'export WIKITOOL_SESSION_ID="wiki-20261001-1430"'),
("session-setup.md", "$env:WIKITOOL_SESSION_ID = 'wiki-20261001-1430'"),
("setup-instance.md", "curl -fLO <browser_download_url of preflight.sh>"),
}
def shipped_instructions() -> list[Path]:
return sorted(
path
for path in INSTRUCTIONS.rglob("*.md")
if not {"dev", "migrations"} & set(path.relative_to(INSTRUCTIONS).parts[:-1])
)
def command_lines(path: Path):
"""`(line number, line)` for every line inside a command fence."""
fence = None
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
stripped = line.strip()
if stripped.startswith("```"):
fence = None if fence is not None else stripped[3:].strip()
continue
if fence is not None and fence in COMMAND_FENCES and stripped:
yield number, stripped
def test_the_scope_is_not_empty():
names = {path.name for path in shipped_instructions()}
assert {"setup-instance.md", "session-setup.md", "upgrade-instance.md"} <= names
assert not any("migrations" in path.parts for path in shipped_instructions())
@pytest.mark.parametrize("path", shipped_instructions(), ids=lambda p: str(p.relative_to(INSTRUCTIONS)))
def test_a_shipped_instruction_uses_no_shell_specific_syntax(path):
found = [
f"{path.relative_to(REPO)}:{number}: {name}: {line}"
for number, line in command_lines(path)
if (path.name, line) not in ALLOWED
for name, pattern in FORBIDDEN
if pattern.search(line)
]
assert not found, "\n".join(found)
def test_every_exception_is_still_in_use():
"""An allowance nobody uses any more is a gap waiting for the next construct."""
used = {
(path.name, line)
for path in shipped_instructions()
for _, line in command_lines(path)
}
assert ALLOWED <= used, ALLOWED - used
SHIPPED_DOCS = ("AGENTS.md", "README.md", "INSTALL.md", "EVALS.md")
# A line that starts the PowerShell preflight - bare (`.\tools\preflight.ps1`, `& preflight.ps1`)
# or through pwsh - as opposed to one that merely names the file (a comment, a download).
PREFLIGHT_CALL = re.compile(r"^(?:&\s*)?(?:\.[\\/])?(?:tools[\\/])?preflight\.ps1\b|^pwsh\b.*preflight\.ps1")
BYPASS = "pwsh -NoProfile -ExecutionPolicy Bypass -File "
@pytest.mark.parametrize(
"path",
[REPO / name for name in SHIPPED_DOCS] + shipped_instructions(),
ids=lambda p: str(p.relative_to(REPO)),
)
def test_every_powershell_preflight_call_carries_the_bypass(path):
"""Copilot started `.\\tools\\preflight.ps1` bare in the #151 hand check; in a checkout that
carries a Mark of the Web that call fails with PowerShell's own refusal and no guidance."""
if not path.is_file():
pytest.skip(f"{path.name} is not in this tree")
bare = [
f"{number}: {line}"
for number, line in command_lines(path)
if PREFLIGHT_CALL.match(line) and not line.startswith(BYPASS)
]
assert not bare, "\n".join(bare)
+42 -21
View File
@@ -421,29 +421,29 @@ def build_release(base: Path, *, limit: int | None = None, tops: tuple[str, ...]
class AssetMachine(Machine):
"""The release asset on a machine with nothing unpacked yet: just the script, alone in a folder."""
"""The release asset on a machine with nothing unpacked yet: just the script, alone in
the folder the wiki is to be installed in."""
def __init__(self, base: Path, *, filled: bool = False, **release):
def __init__(self, base: Path, *, filled: bool = False, script: str = "preflight.sh", **release):
super().__init__(base)
self.tarball = build_release(base, **release)
self.download = base / "download"
self.download.mkdir()
for name, pairs in PLACEHOLDERS.items():
text = (TOOLS / name).read_text(encoding="utf-8")
for empty, full in pairs:
assert empty in text, f"{name} lost its placeholder {empty}"
if filled:
text = text.replace(empty, full)
(self.download / name).write_text(text, encoding="utf-8")
self.script = self.download / "preflight.sh"
self.root = self.download / "chemenu"
text = (TOOLS / script).read_text(encoding="utf-8")
for empty, full in PLACEHOLDERS[script]:
assert empty in text, f"{script} lost its placeholder {empty}"
if filled:
text = text.replace(empty, full)
self.script = self.download / script
self.script.write_text(text, encoding="utf-8")
self.root = self.download
self.tools = self.root / "tools"
self.cwd = self.download
self.extra_env.update({"RELEASE_DIR": str(self.tarball.parent), "CURL_LOG": str(base / "curl.log")})
self.standard()
def unpacked(self, root: Path | None = None) -> bool:
return (root or self.root).exists()
return ((root or self.root) / "tools").exists()
def leftovers(self) -> list[str]:
found = [str(path) for path in self.base.rglob(".chemenu-unpack.*")]
@@ -457,16 +457,28 @@ def asset(tmp_path: Path) -> AssetMachine:
@pytest.mark.parametrize("shell", SHELLS)
def test_asset_unpacks_next_to_itself_and_runs_the_tree_copy(asset, shell):
def test_asset_unpacks_into_its_own_folder_and_runs_the_tree_copy(asset, shell):
result = asset.run("--archive", str(asset.tarball), shell=shell)
assert result.returncode == 0, result.stdout + result.stderr
assert "sha256 OK" in result.stdout and "Preflight passed" in result.stdout
assert (asset.tools / "preflight.sh").is_file() and (asset.tools / "prerequisites.txt").is_file()
assert asset.recorded()["complete"] is True
assert (asset.tools / ".venv").is_dir()
# The asset itself is gone, so the first commit holds the stack and nothing else.
assert not asset.script.exists()
assert not asset.leftovers()
@pytest.mark.parametrize("shell", SHELLS)
def test_an_empty_clone_is_an_empty_folder(asset, shell):
(asset.root / ".git").mkdir()
(asset.root / ".git" / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
result = asset.run("--archive", str(asset.tarball), shell=shell)
assert result.returncode == 0, result.stdout + result.stderr
assert (asset.root / ".git" / "HEAD").read_text(encoding="utf-8") == "ref: refs/heads/main\n"
assert (asset.tools / "preflight.sh").is_file()
@pytest.mark.parametrize("shell", SHELLS)
def test_into_chooses_the_target_and_creates_missing_parents(asset, shell):
target = asset.base / "deep" / "er" / "wiki"
@@ -475,6 +487,8 @@ def test_into_chooses_the_target_and_creates_missing_parents(asset, shell):
assert (target / "tools" / "preflight.sh").is_file()
assert (target / ".wikitool-tools.json").is_file()
assert not asset.unpacked()
# Not in the target, so not the instance's to keep out of a commit.
assert asset.script.exists()
@pytest.mark.parametrize("shell", SHELLS)
@@ -486,13 +500,18 @@ def test_a_relative_into_resolves_against_the_working_directory(asset, shell):
@pytest.mark.parametrize("shell", SHELLS)
def test_an_existing_target_is_refused_and_left_alone(asset, shell):
asset.root.mkdir()
(asset.root / "mine.txt").write_text("keep", encoding="utf-8")
result = asset.run("--archive", str(asset.tarball), shell=shell)
@pytest.mark.parametrize("into", [False, True])
def test_an_occupied_target_is_refused_and_left_alone(asset, shell, into):
target = asset.base / "occupied" if into else asset.root
target.mkdir(exist_ok=True)
(target / "mine.txt").write_text("keep", encoding="utf-8")
before = sorted(path.name for path in target.iterdir())
args = ("--into", str(target)) if into else ()
result = asset.run("--archive", str(asset.tarball), *args, shell=shell)
assert result.returncode == 1
assert "already exists" in result.stderr and "--into" in result.stderr
assert [path.name for path in asset.root.iterdir()] == ["mine.txt"]
assert "is not empty" in result.stderr and "--into" in result.stderr
assert sorted(path.name for path in target.iterdir()) == before
assert asset.script.exists()
assert not asset.leftovers()
@@ -618,7 +637,8 @@ def test_the_folder_limit_is_judged_at_the_final_target_from_the_archive(
result = asset.run("--archive", str(asset.tarball), "--into", str(target), shell=shell)
assert result.returncode == expected, result.stdout + result.stderr
if expected == 42:
assert_guidance(result.stdout, f"too long ({length} characters, at most {limit or 95})")
assert_guidance(result.stdout, f"too long ({length} characters, at most {limit or 95})",
"such as C:\\Chemenu - put this script there", "--into C:\\Chemenu.")
assert not asset.unpacked(target) and not asset.leftovers()
else:
assert (target / "tools" / "preflight.sh").is_file()
@@ -643,7 +663,8 @@ def test_the_release_workflow_fills_exactly_the_placeholders_the_scripts_keep():
expression = f"s|^{as_written_in_the_workflow(empty)}|{as_written_in_the_workflow(filled)}|"
assert expression in text, f"release.yml does not run {expression}"
assert 'download="${PUBLIC_BASE_URL}/${GITHUB_REPOSITORY}/releases/download/${TAG}"' in text
assert 'for asset in "${NAME}.tar.gz" "${NAME}.tar.gz.sha256" preflight.sh preflight.ps1; do' in text
assert ('for asset in "${NAME}.tar.gz" "${NAME}.tar.gz.sha256" preflight.sh preflight.ps1 '
'setup-instance.md preflight.md; do') in text
# --- the launcher -----------------------------------------------------------------
+21 -12
View File
@@ -216,41 +216,49 @@ def _asset_run(asset: AssetMachine, *args: str) -> subprocess.CompletedProcess:
**asset.extra_env,
}
return subprocess.run(
[PWSH, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(asset.download / "preflight.ps1"), *args],
[PWSH, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(asset.script), *args],
capture_output=True, text=True, env=env, timeout=120, cwd=asset.cwd,
)
@pytest.fixture
def asset(tmp_path: Path) -> AssetMachine:
return AssetMachine(tmp_path.resolve())
return AssetMachine(tmp_path.resolve(), script="preflight.ps1")
def test_asset_unpacks_next_to_itself_and_runs_the_tree_copy(asset):
def test_asset_unpacks_into_its_own_folder_and_runs_the_tree_copy(asset):
result = _asset_run(asset, "--archive", str(asset.tarball))
assert result.returncode == 0, result.stdout + result.stderr
assert "sha256 OK" in result.stdout and "Preflight passed" in result.stdout
assert (asset.tools / "preflight.ps1").is_file()
assert asset.recorded()["complete"] is True
assert not asset.script.exists()
assert not asset.leftovers()
def test_an_empty_clone_is_an_empty_folder(asset):
(asset.root / ".git").mkdir()
result = _asset_run(asset, "--archive", str(asset.tarball))
assert result.returncode == 0, result.stdout + result.stderr
assert (asset.root / ".git").is_dir() and (asset.tools / "preflight.ps1").is_file()
def test_into_chooses_the_target_and_a_relative_one_follows_the_working_directory(asset):
far = asset.base / "deep" / "er" / "wiki"
assert _asset_run(asset, "--archive", str(asset.tarball), "--into", str(far)).returncode == 0
assert (far / "tools" / "preflight.ps1").is_file() and not asset.unpacked()
assert asset.script.exists()
asset.cwd = asset.base
assert _asset_run(asset, "--archive", str(asset.tarball), "--into", "here").returncode == 0
assert (asset.base / "here" / "tools" / "preflight.ps1").is_file()
def test_an_existing_target_is_refused_and_left_alone(asset):
asset.root.mkdir()
def test_an_occupied_target_is_refused_and_left_alone(asset):
(asset.root / "mine.txt").write_text("keep", encoding="utf-8")
result = _asset_run(asset, "--archive", str(asset.tarball))
assert result.returncode == 1
assert "already exists" in result.stderr and "--into" in result.stderr
assert [path.name for path in asset.root.iterdir()] == ["mine.txt"]
assert "is not empty" in result.stderr and "--into" in result.stderr
assert sorted(path.name for path in asset.root.iterdir()) == ["mine.txt", "preflight.ps1"]
def test_a_wrong_sha256_exits_1_and_unpacks_nothing(asset):
@@ -270,7 +278,7 @@ def test_archive_without_a_checksum_file_exits_1(asset):
def test_an_archive_with_two_top_level_folders_exits_1(tmp_path):
asset = AssetMachine(tmp_path.resolve(), tops=("chemenu-stack-9.9.9", "stray"))
asset = AssetMachine(tmp_path.resolve(), script="preflight.ps1", tops=("chemenu-stack-9.9.9", "stray"))
result = _asset_run(asset, "--archive", str(asset.tarball))
assert result.returncode == 1 and "exactly one top-level folder" in result.stderr
assert not asset.unpacked() and not asset.leftovers()
@@ -296,7 +304,7 @@ def test_set_and_the_exit_code_pass_through_to_the_tree_copy(asset):
assert_guidance(refused.stdout, "The path given for ripgrep (rg) does not work")
assert (asset.tools / "preflight.ps1").is_file() and not asset.tools_file.exists()
second = AssetMachine(asset.base / "second")
second = AssetMachine(asset.base / "second", script="preflight.ps1")
elsewhere = second.stub("rg", "#!/bin/sh\necho 'ripgrep 14.1.1'\n", asset.base / "opt")
ok = _asset_run(second, "--archive", str(second.tarball), "--set", f"rg={elsewhere}")
assert ok.returncode == 0, ok.stdout
@@ -321,14 +329,15 @@ def test_the_folder_limit_is_judged_at_the_final_target_from_the_archive(
base = tmp_path.resolve()
name_length = length - len(str(base / "download")) - 1
assert name_length > 0, "tmp_path is too long for this test"
asset = AssetMachine(base, limit=limit)
asset = AssetMachine(base, script="preflight.ps1", limit=limit)
asset.standard(pwsh=True)
asset.extra_env.update({"CHEMENU_PREFLIGHT_PLATFORM": "windows", "CHEMENU_PREFLIGHT_LONGPATHS": longpaths})
target = asset.download / ("t" * name_length)
result = _asset_run(asset, "--archive", str(asset.tarball), "--into", str(target))
assert result.returncode == expected, result.stdout + result.stderr
if expected == 42:
assert_guidance(result.stdout, f"too long ({length} characters, at most {limit or 95})")
assert_guidance(result.stdout, f"too long ({length} characters, at most {limit or 95})",
"such as C:\\Chemenu - put this script there", "--into C:\\Chemenu.")
assert not asset.unpacked(target) and not asset.leftovers()
else:
assert (target / "tools" / "preflight.ps1").is_file()
@@ -340,7 +349,7 @@ def release_server(asset):
handler.log_message = lambda *args: None
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
script = asset.download / "preflight.ps1"
script = asset.script
script.write_text(
(TOOLS / "preflight.ps1").read_text(encoding="utf-8").replace(
"$ReleaseArchiveUrl = \'\'", f"$ReleaseArchiveUrl = \'http://127.0.0.1:{server.server_port}/{asset.tarball.name}\'").replace(
+1 -1
View File
@@ -265,7 +265,7 @@ def test_exempt_budget_matches_the_pre_121_skip_sets():
"search", "doctor", "review", # old SKIP_COMMANDS
"budget status", "eval score", "eval sessions", "cite id", "links show",
"version show", "version check", "version notes",
"migrate list", "migrate status", "migrate verify", "upstream verify",
"migrate list", "migrate status", "migrate verify",
}
actual = {
path
-470
View File
@@ -1,470 +0,0 @@
"""Tests for `wikitool upstream merge`/`upstream verify` - the code procedure
that replaces private-instance.md's prose merge script (Gitea #30).
Two real git repos stand in for a private instance (`repo`, remote name
`upstream`) and the public repo it takes updates from (`upstream`, a plain
repo committed to directly - a fetch-only remote does not need to be bare for
`git fetch` to work against it). Each scenario diverges the two by committing
independently on each side, exactly like a real fetch-only upstream would.
"""
from __future__ import annotations
import subprocess
import pytest
import typer
from chemenu import config, ownership
from chemenu.commands import git_publish, upstream_cmd
def _git(root, *args):
result = subprocess.run(["git", *args], cwd=root, capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return result
def _write(root, relative, content):
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
def _commit(root, message):
_git(root, "add", "-A")
_git(root, "commit", "-m", message)
@pytest.fixture
def two_repos(tmp_path, monkeypatch):
"""`repo`, a private instance, with a fetch-only `upstream` remote pointing
at a second, independent repo. Both start from the same seed commit -
kb/CONTRACT.md, kb/CONVENTIONS.md(.template), kb/entities/COLLECTION.md,
raw/CONTRACT.md, work/CONTRACT.md, reports/CONTRACT.md, and one tools/
file - which is what a private instance looks like right after the
private-instance.md setup: the tracked machinery, plus its own filled
instance files layered on top.
"""
seed = tmp_path / "seed"
seed.mkdir()
_git(seed, "init", "-b", "main")
_git(seed, "config", "user.name", "Seed")
_git(seed, "config", "user.email", "seed@example.com")
# .wikitool-remotes.json is gitignored in the real repo (it is per-checkout,
# see config.PUBLISH_REMOTES_FILENAME) - without this, dropping one into the
# fixture during a test would show up as an untracked file and trip the
# dirty-working-tree precondition for a reason that has nothing to do with
# what that test is checking.
# Mirrors the real .gitignore in the two ways that matter here:
# `.wikitool-remotes.json` is per-checkout (dropping one in during a test
# must not read as a dirty tree), and `reports/` is derived output that is
# ignored except for its contract - which is what makes a content stage
# able to hold local, non-recomputable data a merge must not touch.
_write(
seed,
".gitignore",
f"/{config.PUBLISH_REMOTES_FILENAME}\n/reports/*\n!/reports/CONTRACT.md\n",
)
_write(seed, "kb/CONTRACT.md", "stack kb contract v1\n")
_write(seed, "kb/CONVENTIONS.md.template", "template v1\n")
_write(seed, "kb/CONVENTIONS.md", "instance conventions v1\n")
_write(seed, "kb/entities/COLLECTION.md", "instance collection contract v1\n")
_write(seed, "kb/Both.md", "page both sides delete\n")
_write(seed, "kb/ToDelete.md", "page the instance will delete\n")
_write(seed, "kb/RegularPage.md", "an ordinary page neither side has touched yet\n")
_write(seed, "raw/CONTRACT.md", "raw contract v1\n")
_write(seed, "work/CONTRACT.md", "work contract v1\n")
_write(seed, "reports/CONTRACT.md", "reports contract v1\n")
_write(seed, "tools/wikitool.py", "line one\nline two\nline three\n")
_commit(seed, "seed")
upstream = tmp_path / "upstream"
subprocess.run(["git", "clone", str(seed), str(upstream)], check=True, capture_output=True)
_git(upstream, "config", "user.name", "Upstream")
_git(upstream, "config", "user.email", "upstream@example.com")
# Cloned from `upstream`, not from `seed` directly: the remote (renamed
# below) must resolve to the path this fixture actually commits new
# upstream state into, or a later `git fetch upstream main` silently
# fetches from `seed` instead and never sees anything new.
repo = tmp_path / "repo"
subprocess.run(["git", "clone", str(upstream), str(repo)], check=True, capture_output=True)
_git(repo, "config", "user.name", "Test")
_git(repo, "config", "user.email", "test@example.com")
_git(repo, "remote", "rename", "origin", "upstream")
monkeypatch.setattr(config, "ROOT", repo)
monkeypatch.setenv("WIKITOOL_SESSION_ID", "test-session")
return upstream, repo
def _merge(**overrides):
kwargs = dict(remote="upstream", branch="main", no_fetch=False)
kwargs.update(overrides)
upstream_cmd.merge_command(**kwargs)
# --- the four restbefund regressions, plus the baseline table from the issue ---
def test_upstream_edit_of_a_page_the_instance_deleted_does_not_land(two_repos):
upstream, repo = two_repos
_git(repo, "rm", "-q", "kb/ToDelete.md")
_commit(repo, "instance deletes ToDelete")
_write(upstream, "kb/ToDelete.md", "upstream edited it after the instance deleted it\n")
_commit(upstream, "upstream edits ToDelete")
_merge()
assert not (repo / "kb/ToDelete.md").exists()
def test_upstream_new_page_does_not_land(two_repos):
upstream, repo = two_repos
_write(upstream, "kb/NewPage.md", "a demo page the upstream added\n")
_commit(upstream, "upstream adds NewPage")
_merge()
assert not (repo / "kb/NewPage.md").exists()
def test_page_deleted_on_both_sides_is_a_noop(two_repos):
upstream, repo = two_repos
_git(repo, "rm", "-q", "kb/Both.md")
_commit(repo, "instance deletes Both")
_git(upstream, "rm", "-q", "kb/Both.md")
_commit(upstream, "upstream deletes Both")
_merge() # must not raise
assert not (repo / "kb/Both.md").exists()
def test_kb_contract_change_lands(two_repos):
upstream, repo = two_repos
_write(upstream, "kb/CONTRACT.md", "stack kb contract v2\n")
_commit(upstream, "upstream changes kb/CONTRACT.md")
_merge()
assert (repo / "kb/CONTRACT.md").read_text(encoding="utf-8") == "stack kb contract v2\n"
def test_conventions_template_change_lands_local_conventions_untouched(two_repos):
upstream, repo = two_repos
_write(upstream, "kb/CONVENTIONS.md.template", "template v2\n")
_commit(upstream, "upstream changes the conventions template")
_merge()
assert (repo / "kb/CONVENTIONS.md.template").read_text(encoding="utf-8") == "template v2\n"
assert (repo / "kb/CONVENTIONS.md").read_text(encoding="utf-8") == "instance conventions v1\n"
def test_collection_contract_change_does_not_land(two_repos):
"""A COLLECTION.md is instance-owned since #39 - one level deeper than
`<stage>/CONTRACT.md`, so `is_stack_owned` must say no to it."""
upstream, repo = two_repos
_write(repo, "kb/entities/COLLECTION.md", "instance collection contract v2 (local)\n")
_commit(repo, "instance rewrites its own collection contract")
_write(upstream, "kb/entities/COLLECTION.md", "upstream collection contract v2\n")
_commit(upstream, "upstream changes the default collection contract")
_merge()
assert (repo / "kb/entities/COLLECTION.md").read_text(encoding="utf-8") == (
"instance collection contract v2 (local)\n"
)
def test_upstream_deletion_of_a_contract_file_lands(two_repos):
"""Restbefund 2: a machinery file the upstream deleted must not silently
survive because `git checkout MERGE_HEAD -- <path>` has nothing to check
out."""
upstream, repo = two_repos
_git(upstream, "rm", "-q", "raw/CONTRACT.md")
_commit(upstream, "upstream drops raw/CONTRACT.md")
_merge()
assert not (repo / "raw/CONTRACT.md").exists()
def test_new_stack_template_under_a_content_stage_lands(two_repos):
"""Restbefund 4: a brand-new stack-owned path the local tree has never
seen must still be recognised by the predicate, not by a literal list."""
upstream, repo = two_repos
_write(upstream, "kb/GLOSSARY.md.template", "a stack-owned template that never existed before\n")
_commit(upstream, "upstream adds a new template")
_merge()
assert (repo / "kb/GLOSSARY.md.template").read_text(encoding="utf-8") == (
"a stack-owned template that never existed before\n"
)
def test_open_workshop_run_files_do_not_land(two_repos):
upstream, repo = two_repos
_write(upstream, "work/some-run/README.md", "an in-progress workshop run\n")
_commit(upstream, "upstream ships an open work/ run")
_merge()
assert not (repo / "work/some-run").exists()
def test_merge_keeps_ignored_local_data_under_a_content_stage(two_repos):
"""`reports/` is gitignored except its contract, so a content stage's
working tree holds local data that is in no git tree and is not
recomputable - the telemetry traces `eval score` reads, saved eval
reports, past lint reports. Forcing the stage back to the local side must
not take those out as collateral: this instance had 497 trace directories
under reports/telemetry/ when the first version of this command wiped the
stage wholesale."""
upstream, repo = two_repos
_write(repo, "reports/telemetry/session-a/trace.jsonl", '{"event": "local"}\n')
_write(repo, "reports/Lint Report 2026-09-04.md", "a local lint report\n")
assert _git(repo, "status", "--porcelain").stdout == "" # ignored, so the tree is clean
_write(upstream, "kb/CONTRACT.md", "stack kb contract v2\n")
_commit(upstream, "upstream changes kb/CONTRACT.md")
_merge()
assert (repo / "reports/telemetry/session-a/trace.jsonl").read_text(encoding="utf-8") == (
'{"event": "local"}\n'
)
assert (repo / "reports/Lint Report 2026-09-04.md").exists()
assert (repo / "reports/CONTRACT.md").read_text(encoding="utf-8") == "reports contract v1\n"
def test_upstream_content_under_a_stage_absent_from_head_does_not_land(two_repos):
"""The stage guard must not rest on the local side happening to track
something under that stage: an instance whose `work/` holds no tracked
file at all must still not receive the upstream's open run."""
upstream, repo = two_repos
_git(repo, "rm", "-q", "work/CONTRACT.md")
_commit(repo, "instance has nothing tracked under work/")
_write(upstream, "work/some-run/README.md", "an in-progress workshop run\n")
_commit(upstream, "upstream ships an open work/ run")
_merge()
assert not (repo / "work/some-run").exists()
def test_one_upstream_commit_mixing_every_case_at_once(two_repos):
"""The acceptance test from the issue: a single upstream commit that edits
a page the instance deleted, adds a new page, deletes an untouched page,
changes a stack contract, changes a template, and deletes a different
stack contract - all at once, all restored or discarded correctly by one
`upstream merge` call."""
upstream, repo = two_repos
_git(repo, "rm", "-q", "kb/ToDelete.md")
_commit(repo, "instance deletes ToDelete")
_write(upstream, "kb/ToDelete.md", "upstream edited it after the instance deleted it\n")
_write(upstream, "kb/BrandNewPage.md", "a demo page the upstream added\n")
_git(upstream, "rm", "-q", "kb/RegularPage.md")
_write(upstream, "kb/CONTRACT.md", "stack kb contract v2\n")
_write(upstream, "kb/CONVENTIONS.md.template", "template v2\n")
_git(upstream, "rm", "-q", "raw/CONTRACT.md")
_commit(upstream, "one upstream commit: edit + add + delete + contract + template + contract-delete")
_merge()
assert not (repo / "kb/ToDelete.md").exists()
assert not (repo / "kb/BrandNewPage.md").exists()
assert (repo / "kb/RegularPage.md").exists()
assert (repo / "kb/CONTRACT.md").read_text(encoding="utf-8") == "stack kb contract v2\n"
assert (repo / "kb/CONVENTIONS.md.template").read_text(encoding="utf-8") == "template v2\n"
assert (repo / "kb/CONVENTIONS.md").read_text(encoding="utf-8") == "instance conventions v1\n"
assert not (repo / "raw/CONTRACT.md").exists()
def test_real_conflict_in_tools_leaves_the_merge_open(two_repos):
upstream, repo = two_repos
_write(repo, "tools/wikitool.py", "line one\nLOCAL CHANGE\nline three\n")
_commit(repo, "local edits tools/wikitool.py")
before = _git(repo, "rev-parse", "HEAD").stdout.strip()
_write(upstream, "tools/wikitool.py", "line one\nUPSTREAM CHANGE\nline three\n")
_commit(upstream, "upstream edits the same line")
with pytest.raises(typer.Exit) as excinfo:
_merge()
assert excinfo.value.exit_code == 1
assert _git(repo, "rev-parse", "HEAD").stdout.strip() == before
assert (repo / ".git" / "MERGE_HEAD").exists()
def test_a_merge_git_refuses_to_open_deletes_nothing(two_repos, tmp_path):
"""The failure mode with the worst blast radius if it is not guarded:
without MERGE_HEAD, every stack-owned path in HEAD reads as "the upstream
deleted it", and the restore loop would remove kb/CONTRACT.md,
raw/CONTRACT.md and every template. A merge git refuses to start must stop
before that, with the tree untouched."""
upstream, repo = two_repos
unrelated = tmp_path / "unrelated"
unrelated.mkdir()
_git(unrelated, "init", "-b", "main")
_git(unrelated, "config", "user.name", "Unrelated")
_git(unrelated, "config", "user.email", "unrelated@example.com")
_write(unrelated, "somefile.md", "no shared history with the instance\n")
_commit(unrelated, "unrelated root commit")
_git(repo, "remote", "set-url", "upstream", str(unrelated))
before = _git(repo, "rev-parse", "HEAD").stdout.strip()
with pytest.raises(typer.Exit) as excinfo:
_merge()
assert excinfo.value.exit_code == 1
assert _git(repo, "rev-parse", "HEAD").stdout.strip() == before
assert (repo / "kb/CONTRACT.md").read_text(encoding="utf-8") == "stack kb contract v1\n"
assert (repo / "raw/CONTRACT.md").exists()
assert (repo / "kb/CONVENTIONS.md.template").exists()
assert _git(repo, "status", "--porcelain").stdout == ""
def test_success_message_reports_what_changed_not_what_was_restored(two_repos, capsys):
"""Restoring every stack-owned path from MERGE_HEAD touches all of them
whether or not the upstream moved any, so the report has to ask git what
changed - otherwise a one-file update is announced as five."""
upstream, repo = two_repos
_write(upstream, "kb/CONTRACT.md", "stack kb contract v2\n")
_commit(upstream, "upstream changes exactly one stack path")
_merge()
out = capsys.readouterr().out
assert "Stack paths changed (1)" in out
assert "kb/CONTRACT.md" in out
assert "kb/CONVENTIONS.md.template" not in out
def test_dirty_working_tree_is_refused_untouched(two_repos):
upstream, repo = two_repos
before = _git(repo, "rev-parse", "HEAD").stdout.strip()
(repo / "kb/CONTRACT.md").write_text("uncommitted local edit\n", encoding="utf-8")
_write(upstream, "kb/CONTRACT.md", "stack kb contract v2\n")
_commit(upstream, "upstream changes kb/CONTRACT.md")
with pytest.raises(typer.Exit) as excinfo:
_merge()
assert excinfo.value.exit_code == 1
assert _git(repo, "rev-parse", "HEAD").stdout.strip() == before
assert (repo / "kb/CONTRACT.md").read_text(encoding="utf-8") == "uncommitted local edit\n"
def test_already_up_to_date_is_a_noop(two_repos):
upstream, repo = two_repos
before = _git(repo, "rev-parse", "HEAD").stdout.strip()
_merge() # nothing new upstream at all
assert _git(repo, "rev-parse", "HEAD").stdout.strip() == before
def test_merge_warns_when_the_publish_remote_gate_is_unarmed(two_repos, capsys):
upstream, repo = two_repos
assert git_publish.read_allowed_push_urls() is None # no .wikitool-remotes.json in this repo
_write(upstream, "kb/CONTRACT.md", "stack kb contract v2\n")
_commit(upstream, "upstream changes kb/CONTRACT.md")
_merge()
captured = capsys.readouterr()
assert "WARN" in captured.out
assert ".wikitool-remotes.json" in captured.out
def test_merge_stays_silent_when_the_publish_remote_gate_is_armed(two_repos, capsys):
upstream, repo = two_repos
(repo / config.PUBLISH_REMOTES_FILENAME).write_text(
'{"schema": 1, "allowed_push_urls": ["ssh://example/test.git"]}\n', encoding="utf-8"
)
_write(upstream, "kb/CONTRACT.md", "stack kb contract v2\n")
_commit(upstream, "upstream changes kb/CONTRACT.md")
_merge()
captured = capsys.readouterr()
assert "WARN" not in captured.out
def test_dist_cmd_contract_only_stages_agree_with_ownership(two_repos):
"""Consistency guard for the ownership refactor: `dist_cmd`'s own list of
stage-contract paths and `ownership.is_stack_owned` must not be able to
name a different set of stages - both are sourced from
`ownership.CONTENT_STAGES` now, so a stage added to one and not the other
fails this rather than only surfacing in a real merge."""
from chemenu.commands import dist_cmd
assert dist_cmd.CONTRACT_ONLY_STAGES # sanity: the derivation still yields entries
for relative in dist_cmd.CONTRACT_ONLY_STAGES:
assert ownership.is_stack_owned(relative)
# --- upstream verify --------------------------------------------------------
def test_verify_is_clean_on_a_stack_owned_only_change(two_repos):
upstream, repo = two_repos
since = _git(repo, "rev-parse", "HEAD").stdout.strip()
_write(repo, "kb/CONTRACT.md", "stack kb contract v2\n")
_commit(repo, "advance kb/CONTRACT.md")
upstream_cmd.verify_command(since=since, until="HEAD") # must not raise
def test_verify_fails_on_a_hand_botched_merge(two_repos, capsys):
upstream, repo = two_repos
since = _git(repo, "rev-parse", "HEAD").stdout.strip()
_write(repo, "kb/SneakedIn.md", "content that arrived outside a stack-owned path\n")
_commit(repo, "a hand-resolved merge that let content through")
with pytest.raises(typer.Exit) as excinfo:
upstream_cmd.verify_command(since=since, until="HEAD")
assert excinfo.value.exit_code == 1
captured = capsys.readouterr()
assert "kb/SneakedIn.md" in captured.out
# --- ownership predicate, exercised directly ---------------------------------
@pytest.mark.parametrize(
"relative,expected",
[
("kb/CONTRACT.md", True),
("raw/CONTRACT.md", True),
("work/CONTRACT.md", True),
("reports/CONTRACT.md", True),
("kb/CONVENTIONS.md.template", True),
("kb/entities/COLLECTION.md.template", True),
("kb/GLOSSARY.md.template", True),
("kb/CONVENTIONS.md", False),
("kb/entities/COLLECTION.md", False),
("kb/concepts/Some Page.md", False),
("raw/notes/x.md", False),
("tools/CONTRACT.md", False), # not a content stage
("kb/log.md", False), # export stub, not stack-owned
],
)
def test_is_stack_owned(relative, expected):
assert ownership.is_stack_owned(relative) == expected