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:
2026-09-16 17:35:14 +02:00
parent 0e09cf41ea
commit 72d01beef8
6 changed files with 292 additions and 38 deletions
+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 "")
+ "."