feat!: installation only from a release, into an empty folder; upstream merge/verify and private-instance.md removed, dist adopt, shell-neutral instructions (#153)
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:
1 parent
d0f08d1fba
commit
a6d07f97c4
46 files changed
+1314
-1936
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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 -----------------------------------------------------------------
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user