dist upgrade: --take-release nimmt fuer einen lokal geaenderten Pfad die Release-Fassung (#107)
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:
@@ -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 "")
|
||||
+ "."
|
||||
|
||||
Reference in New Issue
Block a user