feat: the test suite no longer ships, and dist upgrade deletes what a release stops shipping (#113)
CI / verify (push) Successful in 5m26s
CI / pwsh (push) Successful in 2m7s
Release / release (push) Successful in 35s

dist export leaves out tools/chemenu/tests/, tools/pytest.ini and tools/.coveragerc by exact
path - the suite tests the origin repository, and 257 of its tests failed in a fresh export.
dist upgrade now deletes a no-longer-shipped file that is unchanged since install, with any
directory that leaves empty, and blocks one changed since install like any local change
(--take-release deletes it, --keep-local keeps it). --prune is accepted and ignored.

Files changed:
- CHANGES.md
- EVALS.md
- VERSION
- instructions/mcp-read-server.md
- instructions/upgrade-instance.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/commands/dist_cmd.py
- tools/chemenu/tests/test_dist_cmd.py
- tools/chemenu/tests/test_dist_upgrade.py
- tools/requirements-mcp.txt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
This commit is contained in:
torbenandClaude Opus 5.5 committed 2026-10-03 20:12:32 +02:00
1 parent dd565d249f
commit 0d3499ab04
11 files changed
+479 -87

No files matched your search

+201 -50
View File
@@ -25,7 +25,9 @@ shapes of "does not belong in someone else's instance":
- Build output under `tools/` is dropped, by directory (`TOOLS_EXCLUDE_DIRS`)
where it has one, and by filename (`_is_coverage_output`) where it does not.
Not dev-only but *derived*: recomputable, and measured against this repo's
own test run rather than the receiving instance's.
own test run rather than the receiving instance's. The test suite and its
configuration (`TOOLS_DEV_ONLY`) go the same way, by exact path: dev-only
whole files again, like `instructions/dev/`, only under `tools/`.
"""
from __future__ import annotations
@@ -122,10 +124,20 @@ HOOK_DIRS = (".github/hooks", ".vibe")
# tools/ subpaths never copied - build/venv/cache artifacts, not machinery.
# `htmlcov/` is coverage.py's HTML report: derived output, and a large tree of
# it, measured against the source repo's own test run. `.coveragerc` beside it
# *does* ship, the same way `pytest.ini` does - it is configuration, not output.
# it, measured against the source repo's own test run. Matched by directory
# name at any depth, which is right for a cache and wrong for anything with an
# ordinary name - that is what `TOOLS_DEV_ONLY` below is for.
TOOLS_EXCLUDE_DIRS = {".venv", "__pycache__", ".pytest_cache", ".wikitool_session", "htmlcov"}
# tools/-relative paths that are dev-only rather than derived: the test suite
# and the two files that configure running and measuring it. The suite tests
# *this* repository - it reads the shipped `types/`, `kb/CONVENTIONS.md` and
# collections as this repo has them, where an instance carries templates or its
# own decisions - so in an exported tree a quarter of it fails, and `pytest` is
# not in requirements.txt to run it with anyway. Matched by exact path, not by
# name: a directory called `tests` elsewhere under tools/ would be machinery.
TOOLS_DEV_ONLY = frozenset({"chemenu/tests", "pytest.ini", ".coveragerc"})
# The rest of coverage's output lands beside the code rather than in a directory
# of its own - `.coverage`, `coverage.xml`, and `.coverage.<host>.<pid>` under a
# parallel run - so a directory exclusion cannot reach it. Same argument as
@@ -243,22 +255,31 @@ def _copy_tree(
dest_prefix: str,
exclude_dirs: frozenset[str],
exclude_file: Optional[Callable[[str], bool]] = None,
exclude_paths: frozenset[str] = frozenset(),
) -> dict[str, PlannedFile]:
"""Every file under source_root, marker-stripped, keyed by its
destination-relative path. Excluded directories are pruned during the
walk rather than filtered after, so a large `.venv/` is never read.
`exclude_file` drops individual files by name, for output that lands
beside the code instead of in a directory a prune could catch."""
beside the code instead of in a directory a prune could catch.
`exclude_paths` drops files or whole directories by their exact
source_root-relative path, for one whose name alone says nothing."""
files: dict[str, PlannedFile] = {}
if not source_root.is_dir():
return files
for dirpath, dirnames, filenames in os.walk(source_root):
dirnames[:] = sorted(d for d in dirnames if d not in exclude_dirs)
here = Path(dirpath).relative_to(source_root)
dirnames[:] = sorted(
d for d in dirnames
if d not in exclude_dirs and (here / d).as_posix() not in exclude_paths
)
for filename in sorted(filenames):
if exclude_file is not None and exclude_file(filename):
continue
path = Path(dirpath) / filename
relative = path.relative_to(source_root).as_posix()
if relative in exclude_paths:
continue
dest_rel = f"{dest_prefix}/{relative}"
files[dest_rel] = _read_planned_file(path, dest_rel)
return files
@@ -435,7 +456,8 @@ def build_plan(origin: Optional[Origin] = None) -> dict[str, PlannedFile]:
plan.update(_copy_tree(config.INSTRUCTIONS_DIR, "instructions", frozenset(INSTRUCTIONS_EXCLUDE_DIRS)))
plan.update(_plan_types())
plan.update(_copy_tree(
config.ROOT / "tools", "tools", frozenset(TOOLS_EXCLUDE_DIRS), _is_coverage_output
config.ROOT / "tools", "tools", frozenset(TOOLS_EXCLUDE_DIRS), _is_coverage_output,
TOOLS_DEV_ONLY,
))
for hook_dir in HOOK_DIRS:
plan.update(_copy_tree(config.ROOT / hook_dir, hook_dir, frozenset()))
@@ -607,7 +629,9 @@ def _write_plan(target: Path, plan: dict[str, PlannedFile]) -> None:
"Ships `AGENTS.md`/`README.md`/`EVALS.md` with any `dist:strip-start`...`dist:strip-end` "
"marker region removed, `instructions/` (minus `instructions/dev/`), `types/` (the "
"`root: kb` page type-specs, their schemas and their subtype templates "
"`types/<type>.<value>.md` re-keyed as `.template`, the stack's own verbatim), `docs/` verbatim, `tools/` (no venv/caches), the "
"`types/<type>.<value>.md` re-keyed as `.template`, the stack's own verbatim), `docs/` verbatim, `tools/` (no "
"venv/caches, and not the dev-only test suite `tools/chemenu/tests/` with its "
"`pytest.ini`/`.coveragerc`), the "
"`.github/hooks/`+`.vibe/` session-tracing config plus `.claude/settings.json`, "
"`kb/CONTRACT.md` (no pages, no areas) and `VERSION`.",
"Ships `raw/` and `incoming/` as flat roots, each with a `.gitkeep` and no "
@@ -878,20 +902,27 @@ def run_adopt(templates: Sequence[Path], dry_run: bool = False) -> None:
@dataclass(frozen=True)
class FileClassification:
"""The four-way split of every path `dist upgrade` would touch, plus the
fifth direction (`removed`) that has no write set of its own."""
"""The four-way split of every path `dist upgrade` would write, plus the
paths the new release no longer ships (`retired`), split the same way.
A retired file still unchanged since install is deleted, as silently as an
unchanged file is overwritten. A retired file changed since install is
blocked like any other local change. Neither can be left for later: the new
stamp no longer names the path, so the next upgrade could not tell it from a
file the instance wrote itself."""
unchanged: list[str]
modified: list[str]
deleted: list[str]
new: list[str]
removed: list[str]
retired: list[str]
retired_modified: list[str]
@property
def blocked(self) -> list[str]:
"""Locally changed paths - modified or deleted - which are never
silently overwritten."""
return sorted(self.modified + self.deleted)
"""Locally changed paths - modified, deleted, or modified and no
longer shipped - which are never silently overwritten or removed."""
return sorted(self.modified + self.deleted + self.retired_modified)
def _write_candidates(new_files: dict) -> set[str]:
@@ -930,8 +961,84 @@ def _classify_files(old_files: dict, new_files: dict) -> FileClassification:
else:
deleted.append(relative)
removed = sorted(set(old_files) - set(new_files))
return FileClassification(unchanged, modified, deleted, new, removed)
# The same exemptions as the write set, for the same reason: a path an
# export seeds and the instance owns from then on is the instance's to keep,
# whether or not a later release still seeds it.
gone = {
relative: digest
for relative, digest in old_files.items()
if relative not in new_files
and not ownership.is_export_stub(Path(relative).name)
and not ownership.is_upgrade_preserved(relative)
}
retired: list[str] = []
retired_modified: list[str] = []
for relative, status in kb_state.compare_against_stamp(gone).items():
if status == kb_state.UNCHANGED:
retired.append(relative)
elif status == kb_state.MODIFIED:
retired_modified.append(relative)
# DELETED: already gone, nothing left to decide.
return FileClassification(
unchanged, modified, deleted, new, sorted(retired), sorted(retired_modified)
)
# Everything in a `__pycache__/` is derived from the sources beside it and
# gitignored, so it never keeps an otherwise emptied directory alive.
_DERIVED_DIR = "__pycache__"
def _only_bytecode(directory: Path) -> bool:
return all(entry.is_file() and entry.suffix == ".pyc" for entry in directory.iterdir())
def _emptied_directories(root: Path, deleted: Sequence[str]) -> list[Path]:
"""The directories that hold nothing once `deleted` (root-relative files)
are gone, deepest first - walking up from each deleted file, never as far
as `root` itself.
"Nothing" allows exactly one thing: a `__pycache__/` holding only `*.pyc`.
Any other entry - a file no stamp knows about, a subdirectory still in use -
keeps the directory and every parent above it. Computed before anything is
removed, so `--dry-run` reports the same list the writing run acts on."""
gone: set[Path] = {root / relative for relative in deleted}
emptied: list[Path] = []
candidates = sorted(
{parent for path in gone for parent in path.parents if root in parent.parents},
key=lambda p: len(p.parts),
reverse=True,
)
for directory in candidates:
if not directory.is_dir():
continue
removable = True
for entry in directory.iterdir():
if entry in gone:
continue
if entry.name == _DERIVED_DIR and entry.is_dir() and _only_bytecode(entry):
continue
removable = False
break
if removable:
gone.add(directory)
emptied.append(directory)
return emptied
def _remove_emptied(directory: Path) -> bool:
"""Remove one directory `_emptied_directories` named, re-checking rather
than trusting the plan: its bytecode cache goes only while it still holds
nothing but `*.pyc`, and `rmdir` refuses a directory that is not empty, so
nothing that appeared since the plan was made is ever taken with it."""
cache = directory / _DERIVED_DIR
if cache.is_dir() and _only_bytecode(cache):
shutil.rmtree(cache)
try:
directory.rmdir()
except OSError:
return False
return True
def _verify_sha256_sidecar(archive: Path) -> None:
@@ -1116,8 +1223,9 @@ def _resolve_take_release(
listed = "\n".join(f" - {path}" for path in classification.blocked) or " (none)"
fail(
f"--take-release names {len(unknown)} path(s) that are not locally changed: "
f"{', '.join(unknown)}. Only a path this run reports as locally modified or "
f"locally deleted can be taken from the release. Reported as locally changed:\n"
f"{', '.join(unknown)}. Only a path this run reports as locally modified, "
f"locally deleted, or locally modified and no longer shipped can be taken from the "
f"release. Reported as locally changed:\n"
f"{listed}"
)
return set() # unreachable: fail() raises typer.Exit
@@ -1141,15 +1249,24 @@ def _refusal_for_blocked(
if len(classification.blocked) > 1
else "it is reported again on every future upgrade"
)
retired = [path for path in undecided if path in classification.retired_modified]
retired_note = (
f" A file the release no longer ships ({len(retired)} of them) has no release "
"version: taking it deletes it, and keeping it makes it this instance's own file - no "
"later upgrade reports it again, because no stamp names it any more.\n"
if retired
else ""
)
return (
f"{len(undecided)} locally changed file(s) (listed above) would be silently "
f"overwritten. Nothing was written, and none of these three is the default:\n"
f"overwritten or removed. Nothing was written, and none of these three is the default:\n"
f" - take the release's version and discard the local change:\n"
f" dist upgrade {target} --take-release {paths}\n"
f" - keep every local change and upgrade around them ({kept_again}):\n"
f" dist upgrade {target} --keep-local\n"
f" - reconcile them by hand first, then re-run."
)
f" - reconcile them by hand first, then re-run.\n"
f"{retired_note}"
).rstrip("\n")
def _report_plan(
@@ -1159,6 +1276,7 @@ def _report_plan(
local_version: "version_mod.Version",
new_version: "version_mod.Version",
taken: set[str] = frozenset(),
emptied: Sequence[Path] = (),
) -> None:
console.print(f"{local_version} -> {new_version}")
if boundary_crossing:
@@ -1169,12 +1287,16 @@ def _report_plan(
)
console.print(
f"{len(classification.unchanged)} unchanged, {len(classification.new)} new, "
f"{len(classification.blocked)} locally changed, {len(classification.removed)} removed "
f"{len(classification.blocked)} locally changed, {len(classification.retired)} removed "
"from the release."
)
def _mark(relative: str) -> str:
return " [cyan](--take-release: overwritten from the release)[/cyan]" if relative in taken else ""
if relative not in taken:
return ""
if relative in classification.retired_modified:
return " [cyan](--take-release: deleted, as the release no longer ships it)[/cyan]"
return " [cyan](--take-release: overwritten from the release)[/cyan]"
if classification.modified:
console.print(f"[bold]Locally modified ({len(classification.modified)}):[/bold]")
@@ -1184,10 +1306,23 @@ def _report_plan(
console.print(f"[bold]Locally deleted ({len(classification.deleted)}):[/bold]")
for relative in classification.deleted:
console.print(f" - {relative}{_mark(relative)}")
if classification.removed:
console.print("[dim]No longer part of the release, not written or removed by default:[/dim]")
for relative in classification.removed:
console.print(f" [dim]- {relative}[/dim]")
if classification.retired_modified:
console.print(
f"[bold]Locally modified, no longer shipped ({len(classification.retired_modified)}):[/bold]"
)
for relative in classification.retired_modified:
console.print(f" - {relative}{_mark(relative)}")
if classification.retired:
console.print(
f"No longer part of the release, unchanged since install - deleted "
f"({len(classification.retired)}):"
)
for relative in classification.retired:
console.print(f" - {relative}")
if emptied:
console.print(f"Directories left empty by that, removed ({len(emptied)}):")
for directory in emptied:
console.print(f" - {rel_path(directory)}/")
if migration_chain:
console.print(
f"[cyan]{len(migration_chain)} migration(s) will be outstanding after this "
@@ -1248,13 +1383,22 @@ def _report_plan(
"not written, so a skipped file keeps diverging and is reported again on every later "
"run.",
"`--take-release <path>` (repeatable) writes the release's version over the named "
"path, discarding the local change, and re-creates it if it was locally deleted. The "
"path then matches the stamp and stops being reported.",
"path, discarding the local change, re-creates it if it was locally deleted, and "
"deletes it if the release no longer ships it. The path then matches the stamp and "
"stops being reported.",
"The two are decided per path and compose on one call: without `--keep-local`, a "
"locally changed path that no `--take-release` names still aborts the run.",
"A path in the old stamp but not the new one is reported as no longer part of the "
"release and left alone, unless `--prune` is passed, which removes it only if it is "
"still unchanged since installation.",
"A path in the old stamp but not the new one is no longer part of the release. Still "
"unchanged since installation, it is deleted, as silently as an unchanged file is "
"overwritten; changed since, it is locally changed like any other and needs one of the "
"same answers - `--take-release` deletes it, `--keep-local` keeps it as the instance's "
"own file, which no later upgrade reports again. Either way it is decided in this run: "
"the new stamp no longer names the path. What the instance owns (`kb/log.md`, "
"`CHANGES.md`, `.wikitool-kb.json`, ...) is never a candidate, and neither is any file "
"the old stamp does not name.",
"A directory left holding nothing - at most a `__pycache__/` of `*.pyc` - once those "
"files are deleted is removed with them, up to but never including the instance root. "
"`--prune` is accepted and ignored; it used to switch the deletion on.",
"Reports the migration chain the new machinery would owe, but never runs any of it - "
"there is no `migrate run`.",
"Reports, but does not block on, a crossed compatibility boundary.",
@@ -1403,7 +1547,8 @@ def upgrade_command(
),
prune: bool = typer.Option(
False, "--prune",
help="Also delete files the new release no longer ships, if they are unchanged since install",
help="Accepted and ignored: files the new release no longer ships are now deleted by "
"default when unchanged since install",
),
allow_pre: bool = typer.Option(
False, "--pre", help="Allow a pre-release (-beta.N) source tree - release.yml never publishes one",
@@ -1422,7 +1567,9 @@ def upgrade_command(
overwritten silently, new files are created, and a locally modified or
deleted file is never silently overwritten - `dist upgrade` aborts unless
`--keep-local` says to leave it alone or `--take-release <path>` names it
as one to overwrite from the release. Reports the migration chain the new
as one to overwrite from the release. A file the release no longer ships
is deleted if unchanged since install, and blocked like any local change
otherwise; `--prune` is accepted and ignored. Reports the migration chain the new
machinery would owe without running any of it (there is no `migrate run`).
Refuses on a missing local release stamp, a downgrade, a pre-release
source without `--pre`, a dirty working tree, or a `--take-release` path
@@ -1445,7 +1592,7 @@ def run_upgrade(
dry_run: bool = False,
keep_local: bool = False,
take_release: Optional[Sequence[str]] = None,
prune: bool = False,
prune: bool = False, # accepted and ignored - retired files are always classified now
allow_pre: bool = False,
latest: bool = False,
expect: Optional[str] = None,
@@ -1582,8 +1729,13 @@ def run_upgrade(
boundary_crossing = local_version.compat_key != new_version.compat_key
taken = _resolve_take_release(take_release, classification)
to_delete = sorted(
classification.retired + [p for p in classification.retired_modified if p in taken]
)
emptied = _emptied_directories(config.ROOT, to_delete)
_report_plan(
classification, migration_chain, boundary_crossing, local_version, new_version, taken
classification, migration_chain, boundary_crossing, local_version, new_version, taken,
emptied,
)
# Dry-run's whole purpose is to preview this classification - including
@@ -1603,7 +1755,10 @@ def run_upgrade(
fail(_refusal_for_blocked(target, undecided, classification))
return
to_write = sorted(classification.unchanged + classification.new + sorted(taken))
to_write = sorted(
classification.unchanged + classification.new
+ [p for p in taken if p not in classification.retired_modified]
)
for relative in to_write:
src = new_root / relative
dst = config.ROOT / relative
@@ -1617,26 +1772,22 @@ def run_upgrade(
# digest recorded here and stops being reported at all.
shutil.copy2(stamp_path, config.ROOT / version_mod.RELEASE_STAMP_FILENAME)
pruned: list[str] = []
if prune:
for relative in classification.removed:
digest = old_files.get(relative)
if digest is None:
continue
status = kb_state.compare_against_stamp({relative: digest}).get(relative)
if status != kb_state.UNCHANGED:
continue
target = config.ROOT / relative
if target.is_file():
target.unlink()
pruned.append(relative)
# Retired files go regardless of `--prune`, which is accepted and ignored: an
# unchanged retired file is deleted as silently as an unchanged file is
# overwritten, and only now - the new stamp just written no longer names it, so
# no later run could find it again. Only paths classified above are touched;
# a file neither stamp names is never a candidate.
for relative in to_delete:
(config.ROOT / relative).unlink(missing_ok=True)
removed_dirs = [directory for directory in emptied if _remove_emptied(directory)]
skipped = undecided if keep_local else []
summary = (
f"Upgraded {local_version} -> {new_version}: {len(to_write)} file(s) written"
+ (f", {len(taken)} taken from the release (--take-release)" if taken else "")
+ (f", {len(skipped)} left untouched (--keep-local)" if skipped else "")
+ (f", {len(pruned)} pruned" if pruned else "")
+ (f", {len(to_delete)} no longer shipped and deleted" if to_delete else "")
+ (f", {len(removed_dirs)} emptied director(ies) removed" if removed_dirs else "")
+ "."
)
if migration_chain:
+28 -2
View File
@@ -281,8 +281,34 @@ def test_plan_excludes_coverage_output(repo):
assert "tools/.coverage.host.4242" not in plan
assert "tools/coverage.xml" not in plan
assert not any(relative.startswith("tools/htmlcov/") for relative in plan)
# Configuration is machinery and ships, the way pytest.ini does.
assert "tools/.coveragerc" in plan
# Its configuration is dev-only with the suite it measures - see the next test.
assert "tools/.coveragerc" not in plan
def test_plan_excludes_the_test_suite_and_its_configuration_by_exact_path(repo):
"""The suite tests this repository, not an instance: in an exported tree it
reads templates where it expects this repo's own type-specs and conventions,
and a quarter of it fails. It goes by exact path - a directory that merely
shares the name `tests` elsewhere under tools/ is machinery and still ships."""
tools_dir = repo / "tools"
suite = tools_dir / "chemenu" / "tests"
(suite / "fixtures" / "sp").mkdir(parents=True)
(suite / "__init__.py").write_text("", encoding="utf-8")
(suite / "test_something.py").write_text("def test_x():\n pass\n", encoding="utf-8")
(suite / "fixtures" / "sp" / "tasks.json").write_text("[]\n", encoding="utf-8")
(tools_dir / "pytest.ini").write_text("[pytest]\n", encoding="utf-8")
(tools_dir / ".coveragerc").write_text("[run]\n", encoding="utf-8")
(tools_dir / "chemenu" / "evals" / "tests").mkdir(parents=True)
(tools_dir / "chemenu" / "evals" / "tests" / "keep.py").write_text("", encoding="utf-8")
(tools_dir / "chemenu" / "tests.py").write_text("", encoding="utf-8")
plan = dist_cmd.build_plan()
assert not any(relative.startswith("tools/chemenu/tests/") for relative in plan)
assert "tools/pytest.ini" not in plan
assert "tools/.coveragerc" not in plan
assert "tools/chemenu/evals/tests/keep.py" in plan
assert "tools/chemenu/tests.py" in plan
def test_plan_ships_the_environment_template_but_not_the_filled_file(repo):
+177 -16
View File
@@ -130,11 +130,17 @@ def test_dry_run_classifies_every_case_and_writes_nothing(instance, tmp_path):
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):
def test_dry_run_reports_a_retired_file_and_its_emptied_directory_and_keeps_both(
instance, tmp_path, capsys
):
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()
out = capsys.readouterr().out
assert "tools/wikitool" in out
assert "tools/" in out.split("Directories left empty")[1]
# --- local changes are never silently overwritten ---------------------------
@@ -520,27 +526,182 @@ def test_source_that_does_not_exist_is_refused(instance, tmp_path):
dist_cmd.run_upgrade(tmp_path / "nowhere")
# --- prune -------------------------------------------------------------------
# --- files the release no longer ships ---------------------------------------
#
# Decided in the run that sees them go: the new stamp no longer names such a
# path, so no later run could tell it from a file the instance wrote itself.
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")
def _record_in_stamp(root: Path, files: dict[str, str]) -> None:
"""Add `files` (relative -> content as installed) to the local stamp, and
put each on disk with that content."""
stamp_path = root / version_mod.RELEASE_STAMP_FILENAME
stamp = json.loads(stamp_path.read_text())
for relative, content in files.items():
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
stamp["files"][relative] = _digest(content)
stamp_path.write_text(json.dumps(stamp), encoding="utf-8")
_CURRENT = {"AGENTS.md": "core\n", "tools/wikitool": "#!/bin/sh\n"}
def test_retired_file_unchanged_since_install_is_deleted_without_any_flag(instance, tmp_path):
_record_in_stamp(instance, {"gone.txt": "shipped once\n"})
release = _release(tmp_path, "release", "1.1.0", _CURRENT)
dist_cmd.run_upgrade(release)
assert not (instance / "gone.txt").exists()
assert (instance / "tools" / "wikitool").is_file()
def test_prune_is_still_accepted_and_changes_nothing(instance, tmp_path):
_record_in_stamp(instance, {"gone.txt": "shipped once\n"})
release = _release(tmp_path, "release", "1.1.0", _CURRENT)
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"
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "core\n"
def test_directory_emptied_by_a_retired_file_goes_with_its_bytecode_cache(instance, tmp_path):
_record_in_stamp(instance, {
"tools/chemenu/tests/__init__.py": "",
"tools/chemenu/tests/fixtures/sp/api/tasks.json": "[]\n",
})
cache = instance / "tools" / "chemenu" / "tests" / "__pycache__"
cache.mkdir()
(cache / "__init__.cpython-313.pyc").write_bytes(b"\x00bytecode")
release = _release(tmp_path, "release", "1.1.0", _CURRENT)
dist_cmd.run_upgrade(release)
assert not (instance / "tools" / "chemenu" / "tests").exists()
# tools/chemenu held nothing else, so it went too; tools/ still holds wikitool.
assert not (instance / "tools" / "chemenu").exists()
assert (instance / "tools" / "wikitool").is_file()
def test_a_file_neither_stamp_names_keeps_itself_and_its_directory(instance, tmp_path):
_record_in_stamp(instance, {"instructions/retired.md": "old\n"})
own = instance / "instructions" / "my-own.md"
own.write_text("the instance wrote this\n", encoding="utf-8")
stray_cache = instance / "instructions" / "__pycache__"
stray_cache.mkdir()
(stray_cache / "notes.txt").write_text("not bytecode\n", encoding="utf-8")
release = _release(tmp_path, "release", "1.1.0", _CURRENT)
dist_cmd.run_upgrade(release)
assert not (instance / "instructions" / "retired.md").exists()
assert own.read_text(encoding="utf-8") == "the instance wrote this\n"
assert (stray_cache / "notes.txt").is_file()
def test_a_directory_with_a_non_bytecode_cache_entry_is_kept(instance, tmp_path):
_record_in_stamp(instance, {"lib/old.py": "x = 1\n"})
cache = instance / "lib" / "__pycache__"
cache.mkdir()
(cache / "old.cpython-313.pyc").write_bytes(b"\x00")
(cache / "keep.txt").write_text("someone put this here\n", encoding="utf-8")
release = _release(tmp_path, "release", "1.1.0", _CURRENT)
dist_cmd.run_upgrade(release)
assert not (instance / "lib" / "old.py").exists()
assert (cache / "keep.txt").is_file()
def test_instance_owned_paths_are_never_retired(instance, tmp_path):
"""`CHANGES.md` and `kb/log.md` sit in the old stamp because an export seeds
them - and belong to the instance from then on, shipped again or not."""
_record_in_stamp(instance, {"CHANGES.md": "seeded\n", "kb/log.md": "seeded\n"})
release = _release(tmp_path, "release", "1.1.0", _CURRENT)
dist_cmd.run_upgrade(release)
assert (instance / "CHANGES.md").read_text(encoding="utf-8") == "seeded\n"
assert (instance / "kb" / "log.md").read_text(encoding="utf-8") == "seeded\n"
def test_retired_file_changed_since_install_blocks_and_nothing_is_written(instance, tmp_path):
_record_in_stamp(instance, {"gone.txt": "shipped once\n", "also-gone.txt": "same\n"})
(instance / "gone.txt").write_text("edited since\n", encoding="utf-8")
release = _release(
tmp_path, "release", "1.1.0", {**_CURRENT, "types/new.md": "new\n"}
)
with pytest.raises(typer.Exit):
dist_cmd.run_upgrade(release)
assert (instance / "gone.txt").read_text(encoding="utf-8") == "edited since\n"
# Neither the unchanged retired file nor a new one was touched by the refusal.
assert (instance / "also-gone.txt").is_file()
assert not (instance / "types").exists()
assert json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())["version"] == "1.0.0"
def test_keep_local_keeps_a_changed_retired_file(instance, tmp_path):
_record_in_stamp(instance, {"gone.txt": "shipped once\n"})
(instance / "gone.txt").write_text("edited since\n", encoding="utf-8")
release = _release(tmp_path, "release", "1.1.0", _CURRENT)
dist_cmd.run_upgrade(release, keep_local=True)
assert (instance / "gone.txt").read_text(encoding="utf-8") == "edited since\n"
assert "gone.txt" not in json.loads(
(instance / version_mod.RELEASE_STAMP_FILENAME).read_text()
)["files"]
def test_take_release_deletes_a_changed_retired_file(instance, tmp_path):
_record_in_stamp(instance, {"sub/gone.txt": "shipped once\n"})
(instance / "sub" / "gone.txt").write_text("edited since\n", encoding="utf-8")
release = _release(tmp_path, "release", "1.1.0", _CURRENT)
dist_cmd.run_upgrade(release, take_release=["sub/gone.txt"])
assert not (instance / "sub").exists()
def test_refusal_says_a_kept_retired_file_becomes_the_instances_own(instance, tmp_path, capsys):
_record_in_stamp(instance, {"gone.txt": "shipped once\n"})
(instance / "gone.txt").write_text("edited since\n", encoding="utf-8")
release = _release(tmp_path, "release", "1.1.0", _CURRENT)
with pytest.raises(typer.Exit):
dist_cmd.run_upgrade(release)
out = " ".join(capsys.readouterr().out.split())
assert "no longer shipped" in out
assert "taking it deletes it" in out
def test_dry_run_leaves_the_tree_byte_identical(instance, tmp_path):
_record_in_stamp(instance, {
"gone.txt": "shipped once\n",
"deep/er/gone.txt": "also\n",
"edited.txt": "shipped\n",
})
(instance / "edited.txt").write_text("edited\n", encoding="utf-8")
before = {
p.relative_to(instance).as_posix(): p.read_bytes()
for p in sorted(instance.rglob("*")) if p.is_file()
}
release = _release(tmp_path, "release", "1.1.0", {"AGENTS.md": "core v2\n"})
dist_cmd.run_upgrade(release, dry_run=True)
after = {
p.relative_to(instance).as_posix(): p.read_bytes()
for p in sorted(instance.rglob("*")) if p.is_file()
}
assert after == before
assert (instance / "deep" / "er").is_dir()
# --- tarball sources ----------------------------------------------------------