dist upgrade: --take-release nimmt fuer einen lokal geaenderten Pfad die Release-Fassung (#107)
CI / verify (push) Successful in 45s
Release / release (push) Successful in 37s

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
This commit is contained in:
torben committed 2026-09-16 17:35:14 +02:00
1 parent 0e09cf41ea
commit 72d01beef8
6 files changed
+292 -38

No files matched your search

+100 -18
View File
@@ -41,7 +41,7 @@ import tempfile
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, NamedTuple, Optional, Union
from typing import Callable, NamedTuple, Optional, Sequence, Union
import typer
@@ -659,8 +659,11 @@ def run_export(target: Path, dry_run: bool = False, origin: Optional[Origin] = N
# (`ownership.is_export_stub`, `ownership.is_upgrade_preserved`), plus the
# stamp itself. Every candidate path is classified against the *old* stamp's
# recorded digest - unchanged, locally modified, or locally deleted - and a
# modified/deleted file is never silently overwritten. This never calls a
# release feed; the caller supplies an already-downloaded tree or archive.
# modified/deleted file is never silently overwritten: the run aborts unless
# `--keep-local` keeps it or `--take-release <path>` names it, which is the
# difference between a file the instance means to carry and one that drifted.
# This never calls a release feed; the caller supplies an already-downloaded
# tree or archive.
@dataclass(frozen=True)
@@ -802,12 +805,68 @@ def _git_working_tree_status() -> Optional[str]:
return result.stdout if result.returncode == 0 else None
def _resolve_take_release(
take_release: Optional[Sequence[str]], classification: FileClassification
) -> set[str]:
"""The blocked paths `--take-release` names, refusing any that is not
actually blocked.
A path that silently does nothing is the worse answer: the operator asked
for a local change to be discarded and would be told the upgrade went
fine, having kept it. Checked before `--dry-run` returns, so a typo
surfaces in the preview rather than in the writing run."""
if not take_release:
return set()
blocked = set(classification.blocked)
wanted = {path.strip() for path in take_release if path.strip()}
unknown = sorted(wanted - blocked)
if unknown:
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"{listed}"
)
return set() # unreachable: fail() raises typer.Exit
return wanted
def _refusal_for_blocked(
source: Path, undecided: list[str], classification: FileClassification
) -> str:
"""The abort text for blocked paths no flag has answered for.
It spells all three answers out with a ready-to-paste command line -
the same shape the Mass-Update Gate uses for its `--confirm` line -
because the one thing a reader must not take away is that any of them is
the default. A run on a real instance read the old wording, which named
only `--keep-local` and "reconcile by hand", as "the default takes the
release's version" and called the command with no flag at all."""
paths = " ".join(undecided)
kept_again = (
"they are reported again on every future upgrade"
if len(classification.blocked) > 1
else "it is reported again on every future upgrade"
)
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" - take the release's version and discard the local change:\n"
f" dist upgrade {rel_path(source)} --take-release {paths}\n"
f" - keep every local change and upgrade around them ({kept_again}):\n"
f" dist upgrade {rel_path(source)} --keep-local\n"
f" - reconcile them by hand first, then re-run."
)
def _report_plan(
classification: FileClassification,
migration_chain: list["kb_state.Migration"],
boundary_crossing: bool,
local_version: "version_mod.Version",
new_version: "version_mod.Version",
taken: set[str] = frozenset(),
) -> None:
console.print(f"{local_version} -> {new_version}")
if boundary_crossing:
@@ -821,14 +880,18 @@ def _report_plan(
f"{len(classification.blocked)} locally changed, {len(classification.removed)} removed "
"from the release."
)
def _mark(relative: str) -> str:
return " [cyan](--take-release: overwritten from the release)[/cyan]" if relative in taken else ""
if classification.modified:
console.print(f"[bold]Locally modified ({len(classification.modified)}):[/bold]")
for relative in classification.modified:
console.print(f" - {relative}")
console.print(f" - {relative}{_mark(relative)}")
if classification.deleted:
console.print(f"[bold]Locally deleted ({len(classification.deleted)}):[/bold]")
for relative in classification.deleted:
console.print(f" - {relative}")
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:
@@ -855,6 +918,13 @@ def upgrade_command(
False, "--keep-local",
help="Proceed even with locally changed files - leave each one untouched rather than aborting",
),
take_release: list[str] = typer.Option(
None, "--take-release",
help="Overwrite this locally changed path with the release's version, discarding the local "
"change. Repeatable, and each path must be one this run reports as locally changed. The "
"counterpart to --keep-local, which keeps the change and reports it again on every future "
"upgrade",
),
prune: bool = typer.Option(
False, "--prune",
help="Also delete files the new release no longer ships, if they are unchanged since install",
@@ -872,13 +942,20 @@ def upgrade_command(
against the *old* stamp's recorded digest: unchanged files are
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. Reports the migration chain the new
`--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
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`, or a dirty working tree. Never touches git.
source without `--pre`, a dirty working tree, or a `--take-release` path
that is not locally changed. Never touches git.
See Gitea #7 and `INSTALL.md` § "Eine Instanz aktualisieren"."""
run_upgrade(
source, dry_run=dry_run, keep_local=keep_local, prune=prune, allow_pre=allow_pre
source,
dry_run=dry_run,
keep_local=keep_local,
take_release=take_release,
prune=prune,
allow_pre=allow_pre,
)
@@ -886,6 +963,7 @@ def run_upgrade(
source: Path,
dry_run: bool = False,
keep_local: bool = False,
take_release: Optional[Sequence[str]] = None,
prune: bool = False,
allow_pre: bool = False,
) -> None:
@@ -993,26 +1071,29 @@ def run_upgrade(
)
boundary_crossing = local_version.compat_key != new_version.compat_key
_report_plan(classification, migration_chain, boundary_crossing, local_version, new_version)
taken = _resolve_take_release(take_release, classification)
_report_plan(
classification, migration_chain, boundary_crossing, local_version, new_version, taken
)
# Dry-run's whole purpose is to preview this classification - including
# the blocked list - without raising, so it must be checked before the
# abort below rather than after: a blocked file must never turn
# `--dry-run` into a non-zero exit, or the flag stops being safe to run
# freely.
# freely. A bad `--take-release` path is the other way round: it is a
# mistake in the *argument*, not a state of the tree, so it is resolved
# above this line and does exit non-zero here - catching a typo in the
# preview is the whole point of previewing.
if dry_run:
success(f"Dry run: would upgrade {local_version} -> {new_version}. Nothing written.")
return
if classification.blocked and not keep_local:
fail(
f"{len(classification.blocked)} locally changed file(s) (listed above) would be "
"silently overwritten. Pass --keep-local to upgrade anyway and leave every one of "
"them untouched, or reconcile them by hand first. Nothing was written."
)
undecided = [path for path in classification.blocked if path not in taken]
if undecided and not keep_local:
fail(_refusal_for_blocked(source, undecided, classification))
return
to_write = sorted(classification.unchanged + classification.new)
to_write = sorted(classification.unchanged + classification.new + sorted(taken))
for relative in to_write:
src = new_root / relative
dst = config.ROOT / relative
@@ -1034,9 +1115,10 @@ def run_upgrade(
target.unlink()
pruned.append(relative)
skipped = classification.blocked if keep_local else []
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 "")
+ "."
+134
View File
@@ -176,6 +176,140 @@ def test_keep_local_proceeds_and_leaves_the_changed_file_untouched(instance, tmp
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 -----------------------------------------------------------