Befund 3 aus dem getraceten 5.0.0-auf-6.0.0-Upgrade-Lauf. --keep-local behielt die Drift und meldete sie bei jedem kuenftigen Upgrade erneut, der andere Weg "reconcile by hand" hatte kein Werkzeug und kostete Handkopie, Vorbedingungs- Commit und damit einen rohen git commit an Invariante 5 vorbei. --take-release <pfad> ist wiederholbar, komponiert pro Pfad mit --keep-local, lehnt einen nicht blockierten Pfad auch im --dry-run ab und beendet die Drift statt sie zu uebergehen. Die Abbruchmeldung nennt jetzt alle drei Antworten mit eingesetzter Kommandozeile und sagt, dass keine der Default ist. Files changed: - CHANGES.md - VERSION - instructions/upgrade-instance.md - tools/CONTRACT.md - tools/chemenu/commands/dist_cmd.py - tools/chemenu/tests/test_dist_upgrade.py
589 lines
24 KiB
Python
589 lines
24 KiB
Python
"""Tests for `wikitool dist upgrade`: classification against the locally
|
|
installed release stamp, the write set, migration-chain reporting without
|
|
execution, and every refusal before anything is written. See Gitea #7."""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import subprocess
|
|
import tarfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import typer
|
|
|
|
from chemenu import config, kb_state, version as version_mod
|
|
from chemenu.commands import dist_cmd
|
|
|
|
|
|
def _digest(text: str) -> str:
|
|
return "sha256:" + hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def _write_stamp(path: Path, version: str, files: dict[str, str]) -> None:
|
|
path.write_text(
|
|
json.dumps(
|
|
{
|
|
"schema": version_mod.STAMP_SCHEMA,
|
|
"version": version,
|
|
"exported_at": "2026-01-01",
|
|
"source_repo": None,
|
|
"source_commit": None,
|
|
"release_url": None,
|
|
"update_url": version_mod.DEFAULT_UPDATE_URL,
|
|
"files": files,
|
|
},
|
|
indent=2,
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def write_migration(directory: Path, target: str, slug: str) -> None:
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
(directory / f"{target}-{slug}.md").write_text(
|
|
"---\n"
|
|
"type: types/instruction.md\n"
|
|
f"name: {target}-{slug}\n"
|
|
f"description: Migration to {target}.\n"
|
|
"manual: true\n"
|
|
f"migrates_to: {target}\n"
|
|
"migration_kind: assisted\n"
|
|
"obligation: required\n"
|
|
"---\n\n# Migration\n\nSteps.\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def set_kb_version(root: Path, version: str) -> None:
|
|
(root / kb_state.KB_STATE_FILENAME).write_text(
|
|
json.dumps({"schema": 1, "kb_version": version, "applied": []}), encoding="utf-8"
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def instance(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
"""A minimal tarball instance: VERSION 1.0.0, a local release stamp
|
|
recording two tracked files exactly as installed, content stays at
|
|
1.0.0 with nothing outstanding, and no .git directory (the WARN path
|
|
for the dirty-tree check)."""
|
|
root = tmp_path / "instance"
|
|
root.mkdir()
|
|
monkeypatch.setattr(config, "ROOT", root)
|
|
|
|
(root / "VERSION").write_text("1.0.0\n", encoding="utf-8")
|
|
(root / "AGENTS.md").write_text("core\n", encoding="utf-8")
|
|
(root / "tools").mkdir()
|
|
(root / "tools" / "wikitool").write_text("#!/bin/sh\n", encoding="utf-8")
|
|
|
|
files = {
|
|
"AGENTS.md": _digest("core\n"),
|
|
"tools/wikitool": _digest("#!/bin/sh\n"),
|
|
}
|
|
_write_stamp(root / version_mod.RELEASE_STAMP_FILENAME, "1.0.0", files)
|
|
set_kb_version(root, "1.0.0")
|
|
return root
|
|
|
|
|
|
def _release(tmp_path: Path, name: str, version: str, files_content: dict[str, str]) -> Path:
|
|
"""A second, independent tree shaped like a `dist export` output: VERSION,
|
|
a release stamp whose `files` block matches `files_content` exactly, and
|
|
the files themselves."""
|
|
root = tmp_path / name
|
|
root.mkdir()
|
|
(root / "VERSION").write_text(f"{version}\n", encoding="utf-8")
|
|
files: dict[str, str] = {}
|
|
for relative, content in files_content.items():
|
|
path = root / relative
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(content, encoding="utf-8")
|
|
files[relative] = _digest(content)
|
|
_write_stamp(root / version_mod.RELEASE_STAMP_FILENAME, version, files)
|
|
return root
|
|
|
|
|
|
# --- classification / dry-run -----------------------------------------------
|
|
|
|
|
|
def test_dry_run_classifies_every_case_and_writes_nothing(instance, tmp_path):
|
|
(instance / "AGENTS.md").write_text("locally edited\n", encoding="utf-8")
|
|
(instance / "tools" / "wikitool").unlink()
|
|
|
|
release = _release(
|
|
tmp_path, "release", "1.1.0",
|
|
{
|
|
"AGENTS.md": "core\n", # locally modified
|
|
"tools/wikitool": "#!/bin/sh\n", # locally deleted
|
|
"types/entity.md": "new page type\n", # new in the release
|
|
},
|
|
)
|
|
|
|
dist_cmd.run_upgrade(release, dry_run=True)
|
|
|
|
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "locally edited\n"
|
|
assert not (instance / "tools" / "wikitool").exists()
|
|
assert not (instance / "types" / "entity.md").exists()
|
|
assert json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())["version"] == "1.0.0"
|
|
|
|
|
|
def test_removed_file_is_reported_and_left_alone_without_prune(instance, tmp_path):
|
|
release = _release(tmp_path, "release", "1.1.0", {"AGENTS.md": "core\n"})
|
|
# tools/wikitool is in the old stamp but absent from the new one.
|
|
dist_cmd.run_upgrade(release, dry_run=True)
|
|
assert (instance / "tools" / "wikitool").is_file()
|
|
|
|
|
|
# --- local changes are never silently overwritten ---------------------------
|
|
|
|
|
|
def test_locally_modified_file_blocks_the_upgrade_by_default(instance, tmp_path):
|
|
(instance / "AGENTS.md").write_text("locally edited\n", encoding="utf-8")
|
|
release = _release(
|
|
tmp_path, "release", "1.1.0",
|
|
{"AGENTS.md": "core v2\n", "tools/wikitool": "#!/bin/sh\n"},
|
|
)
|
|
with pytest.raises(typer.Exit) as excinfo:
|
|
dist_cmd.run_upgrade(release)
|
|
assert excinfo.value.exit_code == 1
|
|
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "locally edited\n"
|
|
assert json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())["version"] == "1.0.0"
|
|
|
|
|
|
def test_locally_deleted_file_blocks_the_upgrade_by_default(instance, tmp_path):
|
|
(instance / "tools" / "wikitool").unlink()
|
|
release = _release(
|
|
tmp_path, "release", "1.1.0",
|
|
{"AGENTS.md": "core\n", "tools/wikitool": "#!/bin/sh v2\n"},
|
|
)
|
|
with pytest.raises(typer.Exit):
|
|
dist_cmd.run_upgrade(release)
|
|
assert not (instance / "tools" / "wikitool").exists()
|
|
|
|
|
|
def test_keep_local_proceeds_and_leaves_the_changed_file_untouched(instance, tmp_path):
|
|
(instance / "AGENTS.md").write_text("locally edited\n", encoding="utf-8")
|
|
release = _release(
|
|
tmp_path, "release", "1.1.0",
|
|
{"AGENTS.md": "core v2\n", "tools/wikitool": "#!/bin/sh\n", "types/entity.md": "new\n"},
|
|
)
|
|
dist_cmd.run_upgrade(release, keep_local=True)
|
|
|
|
# The locally changed file is untouched...
|
|
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "locally edited\n"
|
|
# ...but everything unchanged/new was still written.
|
|
assert (instance / "tools" / "wikitool").read_text(encoding="utf-8") == "#!/bin/sh\n"
|
|
assert (instance / "types" / "entity.md").read_text(encoding="utf-8") == "new\n"
|
|
assert json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())["version"] == "1.1.0"
|
|
|
|
|
|
def test_refusal_names_all_three_answers_with_a_pasteable_take_release_line(
|
|
instance, tmp_path, capsys
|
|
):
|
|
"""The abort a run actually reads has to rule out "the default takes the
|
|
release's version" - a real 5.0.0 -> 6.0.0 run on an instance announced
|
|
exactly that belief and then called `dist upgrade` with no flag. So the
|
|
text names all three answers, says none of them is the default, and carries
|
|
the `--take-release` line with the blocked paths already filled in."""
|
|
(instance / "AGENTS.md").write_text("locally edited\n", encoding="utf-8")
|
|
release = _release(
|
|
tmp_path, "release", "1.1.0",
|
|
{"AGENTS.md": "core v2\n", "tools/wikitool": "#!/bin/sh\n"},
|
|
)
|
|
with pytest.raises(typer.Exit):
|
|
dist_cmd.run_upgrade(release)
|
|
|
|
out = " ".join(capsys.readouterr().out.split()) # rich wraps; rejoin first
|
|
assert "none of these three is the default" in out
|
|
assert "--take-release AGENTS.md" in out
|
|
assert "--keep-local" in out
|
|
assert "reconcile them by hand" in out
|
|
|
|
|
|
# --- --take-release: the other answer to a locally changed file ---------------
|
|
|
|
|
|
def test_take_release_overwrites_the_named_path_and_clears_the_drift(instance, tmp_path):
|
|
"""The point of the flag, in one run rather than three hand steps: the
|
|
local change is gone, and because the new stamp records the release digest
|
|
for a file that now *matches* it, the path is no longer divergent - unlike
|
|
`--keep-local`, which reports it again on every future upgrade."""
|
|
(instance / "AGENTS.md").write_text("locally edited\n", encoding="utf-8")
|
|
release = _release(
|
|
tmp_path, "release", "1.1.0",
|
|
{"AGENTS.md": "core v2\n", "tools/wikitool": "#!/bin/sh\n"},
|
|
)
|
|
dist_cmd.run_upgrade(release, take_release=["AGENTS.md"])
|
|
|
|
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "core v2\n"
|
|
stamp = json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())
|
|
assert stamp["version"] == "1.1.0"
|
|
# The recorded digest and the file on disk agree again, so a second run
|
|
# classifies it as unchanged rather than blocked.
|
|
assert kb_state.compare_against_stamp({"AGENTS.md": stamp["files"]["AGENTS.md"]}) == {
|
|
"AGENTS.md": kb_state.UNCHANGED
|
|
}
|
|
|
|
|
|
def test_take_release_recreates_a_locally_deleted_file(instance, tmp_path):
|
|
(instance / "tools" / "wikitool").unlink()
|
|
release = _release(
|
|
tmp_path, "release", "1.1.0",
|
|
{"AGENTS.md": "core\n", "tools/wikitool": "#!/bin/sh v2\n"},
|
|
)
|
|
dist_cmd.run_upgrade(release, take_release=["tools/wikitool"])
|
|
assert (instance / "tools" / "wikitool").read_text(encoding="utf-8") == "#!/bin/sh v2\n"
|
|
|
|
|
|
def test_take_release_and_keep_local_compose_per_path(instance, tmp_path):
|
|
"""The mixed case is the one a blanket flag could not express: two changed
|
|
files, one to reset and one to keep."""
|
|
(instance / "AGENTS.md").write_text("locally edited\n", encoding="utf-8")
|
|
(instance / "tools" / "wikitool").write_text("#!/bin/zsh\n", encoding="utf-8")
|
|
release = _release(
|
|
tmp_path, "release", "1.1.0",
|
|
{"AGENTS.md": "core v2\n", "tools/wikitool": "#!/bin/sh v2\n"},
|
|
)
|
|
dist_cmd.run_upgrade(release, take_release=["AGENTS.md"], keep_local=True)
|
|
|
|
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "core v2\n"
|
|
assert (instance / "tools" / "wikitool").read_text(encoding="utf-8") == "#!/bin/zsh\n"
|
|
|
|
|
|
def test_a_blocked_path_not_named_by_take_release_still_aborts(instance, tmp_path):
|
|
"""Without `--keep-local` the run must say something about every blocked
|
|
path, not just the ones it happened to name."""
|
|
(instance / "AGENTS.md").write_text("locally edited\n", encoding="utf-8")
|
|
(instance / "tools" / "wikitool").write_text("#!/bin/zsh\n", encoding="utf-8")
|
|
release = _release(
|
|
tmp_path, "release", "1.1.0",
|
|
{"AGENTS.md": "core v2\n", "tools/wikitool": "#!/bin/sh v2\n"},
|
|
)
|
|
with pytest.raises(typer.Exit) as excinfo:
|
|
dist_cmd.run_upgrade(release, take_release=["AGENTS.md"])
|
|
assert excinfo.value.exit_code == 1
|
|
# Nothing was written, including the path that *was* named.
|
|
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "locally edited\n"
|
|
assert json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())["version"] == "1.0.0"
|
|
|
|
|
|
def test_take_release_refuses_a_path_that_is_not_locally_changed(instance, tmp_path, capsys):
|
|
"""A typo that silently did nothing would report a successful upgrade while
|
|
keeping the change the operator asked to discard."""
|
|
(instance / "AGENTS.md").write_text("locally edited\n", encoding="utf-8")
|
|
release = _release(
|
|
tmp_path, "release", "1.1.0",
|
|
{"AGENTS.md": "core v2\n", "tools/wikitool": "#!/bin/sh\n"},
|
|
)
|
|
with pytest.raises(typer.Exit) as excinfo:
|
|
dist_cmd.run_upgrade(release, take_release=["AGENT.md"])
|
|
assert excinfo.value.exit_code == 1
|
|
out = " ".join(capsys.readouterr().out.split())
|
|
assert "AGENT.md" in out # the typo, named back
|
|
assert "AGENTS.md" in out # and the list of what *is* blocked
|
|
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "locally edited\n"
|
|
|
|
|
|
def test_a_bad_take_release_path_fails_in_the_dry_run_too(instance, tmp_path):
|
|
"""The one thing that turns `--dry-run` non-zero: not a state of the tree
|
|
(a blocked file must never do that), but a mistake in the argument, which
|
|
is exactly what a preview is for."""
|
|
(instance / "AGENTS.md").write_text("locally edited\n", encoding="utf-8")
|
|
release = _release(
|
|
tmp_path, "release", "1.1.0",
|
|
{"AGENTS.md": "core v2\n", "tools/wikitool": "#!/bin/sh\n"},
|
|
)
|
|
with pytest.raises(typer.Exit) as excinfo:
|
|
dist_cmd.run_upgrade(release, dry_run=True, take_release=["types/entity.md"])
|
|
assert excinfo.value.exit_code == 1
|
|
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "locally edited\n"
|
|
|
|
|
|
def test_dry_run_marks_the_paths_take_release_would_overwrite(instance, tmp_path, capsys):
|
|
(instance / "AGENTS.md").write_text("locally edited\n", encoding="utf-8")
|
|
release = _release(
|
|
tmp_path, "release", "1.1.0",
|
|
{"AGENTS.md": "core v2\n", "tools/wikitool": "#!/bin/sh\n"},
|
|
)
|
|
dist_cmd.run_upgrade(release, dry_run=True, take_release=["AGENTS.md"])
|
|
out = " ".join(capsys.readouterr().out.split())
|
|
assert "--take-release" in out
|
|
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "locally edited\n"
|
|
|
|
|
|
# --- the write set -----------------------------------------------------------
|
|
|
|
|
|
def test_unchanged_and_new_files_are_written_silently(instance, tmp_path):
|
|
release = _release(
|
|
tmp_path, "release", "1.1.0",
|
|
{"AGENTS.md": "core\n", "tools/wikitool": "#!/bin/sh\n", "types/entity.md": "new\n"},
|
|
)
|
|
dist_cmd.run_upgrade(release)
|
|
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "core\n"
|
|
assert (instance / "types" / "entity.md").read_text(encoding="utf-8") == "new\n"
|
|
stamp = json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())
|
|
assert stamp["version"] == "1.1.0"
|
|
assert stamp["files"]["types/entity.md"] == _digest("new\n")
|
|
|
|
|
|
def test_closing_report_points_at_the_upgrade_instruction(instance, tmp_path, capsys):
|
|
"""Everything after the swap has exactly one written order, and it is not
|
|
this line: the report names the instruction that carries it and the command
|
|
the run resumes at, rather than a second copy of the list that drifts
|
|
(AGENTS.md invariant 8). A run that reads only this output must still be
|
|
able to find the rest."""
|
|
release = _release(
|
|
tmp_path, "release", "1.1.0",
|
|
{"AGENTS.md": "core\n", "tools/wikitool": "#!/bin/sh\n"},
|
|
)
|
|
|
|
dist_cmd.run_upgrade(release)
|
|
|
|
out = " ".join(capsys.readouterr().out.split()) # rich wraps; rejoin first
|
|
assert "instructions/upgrade-instance.md" in out
|
|
assert "instructions sync" in out
|
|
|
|
|
|
@pytest.mark.parametrize("preserved", [".wikitool-kb.json", "CHANGES.md", "kb/log.md", "raw/notes/.gitkeep"])
|
|
def test_seeded_once_paths_are_never_written_even_if_the_release_stamp_lists_them(
|
|
instance, tmp_path, preserved
|
|
):
|
|
"""The write set is the new stamp's `files` block minus what an export
|
|
re-seeds every time or seeds once and the instance owns from then on -
|
|
this is the AGENTS.md invariant 8 test: no separate literal list here,
|
|
only `chemenu.ownership`."""
|
|
(instance / "CHANGES.md").write_text("instance's own changelog\n", encoding="utf-8")
|
|
(instance / kb_state.KB_STATE_FILENAME).write_text(
|
|
json.dumps({"schema": 1, "kb_version": "1.0.0", "applied": [{"migration": "x"}]}),
|
|
encoding="utf-8",
|
|
)
|
|
release = _release(
|
|
tmp_path, "release", "1.1.0",
|
|
{
|
|
"AGENTS.md": "core\n",
|
|
"tools/wikitool": "#!/bin/sh\n",
|
|
preserved: "a fresh stub from the new release\n",
|
|
},
|
|
)
|
|
dist_cmd.run_upgrade(release)
|
|
if preserved == "CHANGES.md":
|
|
assert (instance / preserved).read_text(encoding="utf-8") == "instance's own changelog\n"
|
|
elif preserved == ".wikitool-kb.json":
|
|
state = json.loads((instance / preserved).read_text())
|
|
assert state["applied"] == [{"migration": "x"}]
|
|
else:
|
|
assert not (instance / preserved).exists()
|
|
|
|
|
|
# --- a root:kb type-spec's guidance half upgrades like any other file -------
|
|
#
|
|
# Gitea #104: before the split, `types/<name>.md` carried both the
|
|
# instance-owned template and the stack-owned authoring prose in one file, so
|
|
# an instance that had adopted it (renamed the `.template`) never received a
|
|
# prose improvement again - `dist upgrade` only ever wrote the `.template`
|
|
# beside the adopted file, never the file itself. Splitting the prose into a
|
|
# sibling `.guidance.md` that ships verbatim (never `.template`-sourced) means
|
|
# it upgrades through the ordinary unchanged/new path below, even though the
|
|
# type-spec it documents is never in the stamp at all and therefore never
|
|
# touched.
|
|
|
|
|
|
def test_upgrade_writes_improved_guidance_prose_over_an_adopted_type_spec(instance, tmp_path):
|
|
(instance / "types").mkdir()
|
|
(instance / "types" / "entity.md").write_text(
|
|
# Adopted from `types/entity.md.template` at some earlier setup - this
|
|
# file was never part of any release stamp and `dist upgrade` must
|
|
# never touch it.
|
|
"---\ntype: types/type-spec.md\nname: entity\ndescription: d\n"
|
|
"schema: types/entity.schema.yaml\nbase_dir: entities\n"
|
|
"guidance: types/entity.guidance.md\n---\n\n# Entity\n",
|
|
encoding="utf-8",
|
|
)
|
|
(instance / "types" / "entity.guidance.md").write_text("old guidance\n", encoding="utf-8")
|
|
stamp = json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())
|
|
stamp["files"]["types/entity.guidance.md"] = _digest("old guidance\n")
|
|
(instance / version_mod.RELEASE_STAMP_FILENAME).write_text(json.dumps(stamp), encoding="utf-8")
|
|
|
|
release = _release(
|
|
tmp_path, "release", "1.1.0",
|
|
{
|
|
"AGENTS.md": "core\n",
|
|
"tools/wikitool": "#!/bin/sh\n",
|
|
"types/entity.guidance.md": "improved guidance\n",
|
|
},
|
|
)
|
|
|
|
dist_cmd.run_upgrade(release)
|
|
|
|
assert (instance / "types" / "entity.guidance.md").read_text(encoding="utf-8") == (
|
|
"improved guidance\n"
|
|
)
|
|
# The adopted type-spec itself was never in either stamp, so it is
|
|
# completely untouched by the upgrade.
|
|
assert "guidance: types/entity.guidance.md" in (
|
|
instance / "types" / "entity.md"
|
|
).read_text(encoding="utf-8")
|
|
|
|
|
|
# --- migration chain: reported, never run -----------------------------------
|
|
|
|
|
|
def test_migration_chain_is_reported_but_never_executed(instance, tmp_path):
|
|
release = _release(
|
|
tmp_path, "release", "1.1.0",
|
|
{"AGENTS.md": "core\n", "tools/wikitool": "#!/bin/sh\n"},
|
|
)
|
|
write_migration(release / "instructions" / "migrations", "1.1.0", "some-change")
|
|
|
|
dist_cmd.run_upgrade(release)
|
|
|
|
# The chain is reported, not applied: kb_version has not moved.
|
|
assert kb_state.read_kb_version().base == version_mod.Version(1, 0, 0)
|
|
|
|
|
|
def test_outstanding_local_migration_blocks_before_touching_the_source(instance, tmp_path):
|
|
"""A migration owed against the *installed* machinery must be finished
|
|
first - the source is never even opened."""
|
|
migrations = instance / "instructions" / "migrations"
|
|
write_migration(migrations, "1.0.0", "not-yet-done")
|
|
monkey_target = instance / "does-not-exist" # never read if this check fires first
|
|
|
|
with pytest.raises(typer.Exit):
|
|
dist_cmd.run_upgrade(monkey_target)
|
|
|
|
|
|
# --- preconditions -----------------------------------------------------------
|
|
|
|
|
|
def test_missing_local_stamp_blocks(instance, tmp_path):
|
|
(instance / version_mod.RELEASE_STAMP_FILENAME).unlink()
|
|
release = _release(tmp_path, "release", "1.1.0", {"AGENTS.md": "core\n"})
|
|
with pytest.raises(typer.Exit):
|
|
dist_cmd.run_upgrade(release)
|
|
|
|
|
|
def test_missing_kb_version_blocks(instance, tmp_path):
|
|
(instance / kb_state.KB_STATE_FILENAME).unlink()
|
|
release = _release(tmp_path, "release", "1.1.0", {"AGENTS.md": "core\n"})
|
|
with pytest.raises(typer.Exit):
|
|
dist_cmd.run_upgrade(release)
|
|
|
|
|
|
def test_prerelease_source_is_refused_without_pre_flag(instance, tmp_path):
|
|
release = _release(tmp_path, "release", "1.1.0-beta.1", {"AGENTS.md": "core\n"})
|
|
with pytest.raises(typer.Exit):
|
|
dist_cmd.run_upgrade(release)
|
|
assert json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())["version"] == "1.0.0"
|
|
|
|
|
|
def test_pre_flag_allows_a_prerelease_source(instance, tmp_path):
|
|
release = _release(
|
|
tmp_path, "release", "1.1.0-beta.1",
|
|
{"AGENTS.md": "core\n", "tools/wikitool": "#!/bin/sh\n"},
|
|
)
|
|
dist_cmd.run_upgrade(release, allow_pre=True)
|
|
assert json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())["version"] == "1.1.0-beta.1"
|
|
|
|
|
|
def test_downgrade_is_refused(instance, tmp_path):
|
|
release = _release(tmp_path, "release", "0.9.0", {"AGENTS.md": "core\n"})
|
|
with pytest.raises(typer.Exit):
|
|
dist_cmd.run_upgrade(release)
|
|
|
|
|
|
def test_equal_version_is_a_noop(instance, tmp_path):
|
|
release = _release(tmp_path, "release", "1.0.0", {"AGENTS.md": "core\n"})
|
|
dist_cmd.run_upgrade(release) # must not raise
|
|
assert json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())["version"] == "1.0.0"
|
|
|
|
|
|
def test_dirty_working_tree_blocks(instance, tmp_path):
|
|
subprocess.run(["git", "init", "-q", "-b", "main"], cwd=instance, check=True)
|
|
subprocess.run(["git", "config", "user.name", "Fixture Author"], cwd=instance, check=True)
|
|
subprocess.run(["git", "config", "user.email", "fixture@example.invalid"], cwd=instance, check=True)
|
|
(instance / "untracked.txt").write_text("dirty\n", encoding="utf-8")
|
|
|
|
release = _release(tmp_path, "release", "1.1.0", {"AGENTS.md": "core\n"})
|
|
with pytest.raises(typer.Exit):
|
|
dist_cmd.run_upgrade(release)
|
|
assert not (instance / "types").exists()
|
|
|
|
|
|
def test_source_that_does_not_exist_is_refused(instance, tmp_path):
|
|
with pytest.raises(typer.Exit):
|
|
dist_cmd.run_upgrade(tmp_path / "nowhere")
|
|
|
|
|
|
# --- prune -------------------------------------------------------------------
|
|
|
|
|
|
def test_prune_removes_only_removed_files_still_unchanged_since_install(instance, tmp_path):
|
|
(instance / "extra.txt").write_text("shipped once, edited since\n", encoding="utf-8")
|
|
old_stamp = json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())
|
|
old_stamp["files"]["extra.txt"] = _digest("shipped once, unedited\n") # deliberately stale
|
|
old_stamp["files"]["gone.txt"] = _digest("also shipped once\n")
|
|
(instance / version_mod.RELEASE_STAMP_FILENAME).write_text(json.dumps(old_stamp), encoding="utf-8")
|
|
(instance / "gone.txt").write_text("also shipped once\n", encoding="utf-8")
|
|
|
|
release = _release(
|
|
tmp_path, "release", "1.1.0",
|
|
{"AGENTS.md": "core\n", "tools/wikitool": "#!/bin/sh\n"},
|
|
)
|
|
dist_cmd.run_upgrade(release, prune=True)
|
|
|
|
# gone.txt matched its recorded digest -> pruned.
|
|
assert not (instance / "gone.txt").exists()
|
|
# extra.txt was locally edited relative to its recorded digest -> kept.
|
|
assert (instance / "extra.txt").read_text(encoding="utf-8") == "shipped once, edited since\n"
|
|
|
|
|
|
# --- tarball sources ----------------------------------------------------------
|
|
|
|
|
|
def _pack(release_dir: Path, archive: Path) -> None:
|
|
with tarfile.open(archive, "w:gz") as tf:
|
|
tf.add(release_dir, arcname=release_dir.name)
|
|
|
|
|
|
def test_tarball_with_more_than_one_top_level_entry_is_refused(instance, tmp_path):
|
|
scratch = tmp_path / "scratch"
|
|
(scratch / "a").mkdir(parents=True)
|
|
(scratch / "b").mkdir(parents=True)
|
|
(scratch / "a" / "x.txt").write_text("x\n", encoding="utf-8")
|
|
(scratch / "b" / "y.txt").write_text("y\n", encoding="utf-8")
|
|
archive = tmp_path / "bad.tar.gz"
|
|
with tarfile.open(archive, "w:gz") as tf:
|
|
tf.add(scratch / "a", arcname="a")
|
|
tf.add(scratch / "b", arcname="b")
|
|
|
|
with pytest.raises(typer.Exit):
|
|
dist_cmd.run_upgrade(archive)
|
|
|
|
|
|
def test_tarball_source_is_extracted_and_applied(instance, tmp_path):
|
|
release_dir = _release(
|
|
tmp_path, "chemenu-stack-1.1.0", "1.1.0",
|
|
{"AGENTS.md": "core\n", "tools/wikitool": "#!/bin/sh\n"},
|
|
)
|
|
archive = tmp_path / "chemenu-stack-1.1.0.tar.gz"
|
|
_pack(release_dir, archive)
|
|
|
|
dist_cmd.run_upgrade(archive)
|
|
|
|
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "core\n"
|
|
assert json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())["version"] == "1.1.0"
|
|
|
|
|
|
def test_tarball_sha256_sidecar_mismatch_is_refused(instance, tmp_path):
|
|
release_dir = _release(
|
|
tmp_path, "chemenu-stack-1.1.0", "1.1.0", {"AGENTS.md": "core\n"}
|
|
)
|
|
archive = tmp_path / "chemenu-stack-1.1.0.tar.gz"
|
|
_pack(release_dir, archive)
|
|
(archive.with_name(archive.name + ".sha256")).write_text(
|
|
"0" * 64 + " chemenu-stack-1.1.0.tar.gz\n", encoding="utf-8"
|
|
)
|
|
|
|
with pytest.raises(typer.Exit):
|
|
dist_cmd.run_upgrade(archive)
|