From 72d01beef8a20e8a34dd8d19f79c8be46e96d017 Mon Sep 17 00:00:00 2001 From: Torben Nehmer Date: Wed, 16 Sep 2026 17:35:14 +0200 Subject: [PATCH] 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 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 --- CHANGES.md | 37 ++++++- VERSION | 2 +- instructions/upgrade-instance.md | 35 +++--- tools/CONTRACT.md | 4 +- tools/chemenu/commands/dist_cmd.py | 118 +++++++++++++++++--- tools/chemenu/tests/test_dist_upgrade.py | 134 +++++++++++++++++++++++ 6 files changed, 292 insertions(+), 38 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 0bb9026..023bf1b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -59,13 +59,14 @@ concern - readable here, never shipped as something to parse. --- -## 6.1.0-beta.2 - 2026-09-16 - Migrationsdokument prueft gegen eine festgehaltene Vorher-Ausgabe, Beispielverweis auf die .template-Form +## 6.1.0-beta.3 - 2026-09-16 - dist upgrade: --take-release nimmt fuer einen lokal geaenderten Pfad die Release-Fassung **Author:** Torben Nehmer - Upgrade-Prozedur als eigene Instruktion statt als Prosa in INSTALL.md - Migrationsdokument prueft gegen eine festgehaltene Vorher-Ausgabe, Beispielverweis auf die .template-Form +- dist upgrade: --take-release nimmt fuer einen lokal geaenderten Pfad die Release-Fassung ### Upgrade-Prozedur als eigene Instruktion statt als Prosa in INSTALL.md @@ -173,6 +174,40 @@ einmal. Fuer sie lohnt der eine Befehl, mit dem der Schaden hier gefunden wurde: `grep -c '^## Authoring guidance'` ueber `types describe ` fuer alle vier Typen, drei bedeutet einen Rest-Abschnitt im eigenen Type-Spec. +### dist upgrade: --take-release nimmt fuer einen lokal geaenderten Pfad die Release-Fassung + +`dist upgrade` kannte zwei Antworten auf eine lokal geaenderte Datei und die dritte, die man +eigentlich will, war keine davon. `--keep-local` *behaelt* die Aenderung - und weil der neue Stamp +die Release-Digest trotzdem schreibt, wird dieselbe Datei bei jedem kuenftigen Upgrade erneut +gemeldet. Fuer eine Datei, die der Instanz gar nicht gehoert, ist das der dauerhaft falsche +Zustand. Der andere angebotene Weg, "reconcile them by hand first", hatte kein Werkzeug: im +getraceten 5.0.0-auf-6.0.0-Lauf war eine `kb/CONTRACT.md` durch ein Format-on-Save um +Tabellen-Whitespace verschoben, und das kostete eine Handkopie aus dem entpackten Tarball, einen +Commit nur zur Herstellung der Clean-Tree-Vorbedingung des naechsten Kommandos - und damit einen +rohen `git commit`, an `AGENTS.md` Invariante 5 vorbei, die fuer "ist ja nur eine Vorbedingung" +keine Ausnahme kennt. + +`--take-release ` ist die fehlende Antwort: schreibe fuer diesen Pfad die Release-Fassung, +statt abzubrechen. Wiederholbar, weil der Pfad die Entscheidung *benennt* - `--keep-local` verliert +nichts, `--take-release` verwirft eine lokale Aenderung, und die zwei sind darum nicht symmetrisch +genug fuer ein pauschales Flag. Beide gelten pro Pfad und komponieren auf einem Aufruf, was der +gemischte Fall braucht: eine Datei zuruecksetzen, eine andere behalten. Ohne `--keep-local` bricht +ein blockierter Pfad, zu dem nichts gesagt wurde, weiter ab; ein `--take-release`-Pfad, der gar +nicht blockiert ist, wird abgelehnt - auch im `--dry-run`, denn das ist ein Fehler im *Argument* +und nicht ein Zustand des Baums, und ein still ignorierter Tippfehler haette ein erfolgreiches +Upgrade gemeldet und die Aenderung behalten, die verworfen werden sollte. + +Anders als bei `--keep-local` ist die Drift danach **weg** und nicht bloss uebergangen: die Datei +stimmt wieder mit der Digest ueberein, die der Stamp fuehrt, und verschwindet aus der Meldung. + +Dazu die Abbruchmeldung selbst, die den Fehlgriff mitverursacht hat. Sie nannte `--keep-local` und +"reconcile by hand", sagte aber nicht, dass es zu `--keep-local` kein Gegenstueck gibt - der Lauf +kuendigte woertlich an, *"I'll let the upgrade take the release's version"*, und rief das Kommando +ohne Flag auf. Jetzt nennt sie alle drei Antworten mit fertig eingesetzter Kommandozeile, im Muster +des Mass-Update-Gates, und sagt ausdruecklich, dass keine davon der Default ist. + +`instructions/upgrade-instance.md` Schritt 6 traegt entsprechend nicht mehr die Drei-Schritt-Handreparatur, sondern die Entscheidung und den Dry-Run, mit dem man sie vorher sieht. + --- ## 6.0.1 - 2026-09-16 - docs toc/verify erreichen die .template-Form einer Referenzdatei diff --git a/VERSION b/VERSION index c56d949..834a14a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.1.0-beta.2 +6.1.0-beta.3 diff --git a/instructions/upgrade-instance.md b/instructions/upgrade-instance.md index cebe658..e51ac13 100644 --- a/instructions/upgrade-instance.md +++ b/instructions/upgrade-instance.md @@ -19,8 +19,8 @@ a clone carries an `upstream` remote pointing at the origin. **Two things this file deliberately does not know.** First, the copy you are reading shipped with the release this instance is *leaving*, not the one it is going to - so nothing specific to a particular jump is written here. That belongs to the release notes (step 2) and to the -migration documents that arrive inside the tarball. Second, two steps below work around gaps in -the tooling rather than describing its intended shape; each says so, and says what makes it +migration documents that arrive inside the tarball. Second, one step below works around a gap in +the tooling rather than describing its intended shape; it says so, and says what makes it unnecessary. @@ -99,30 +99,33 @@ fresh clone ([bootstrap.md](bootstrap.md)), and not for the clone-with-upstream | Whose file | What to do | |---|---| | The instance's own | Cannot appear here, which is worth knowing so a report that looks like it is read again rather than acted on: a file the instance owns either ships only as `.template` (`kb/CONVENTIONS.md`, each `COLLECTION.md`, `USER.md`/`SOUL.md`/`ENVIRONMENT.md`) and is never classified at all, or is seeded once and then kept out of the write set (`.wikitool-kb.json`, `CHANGES.md`) | - | Machinery (a `CONTRACT.md`, anything under `tools/`, `types/`, `instructions/`, `AGENTS.md`, and every `.template` beside an owned file) | It should not have local changes at all. Take the release's version, per the recipe below | + | Machinery (a `CONTRACT.md`, anything under `tools/`, `types/`, `instructions/`, `AGENTS.md`, and every `.template` beside an owned file) | It should not have local changes at all. Take the release's version: `--take-release `, one per file | | Machinery this instance changed **on purpose** | `--keep-local` keeps every listed file untouched - but the new stamp records the release digest anyway, so the same file is reported again at every future upgrade. That is the right answer only for a difference the instance intends to carry indefinitely | - Taking the release's version has no flag today, so it is three moves - and the middle one - exists only because the repair dirties the tree that the next command requires clean: + The decision is per path, and the two flags compose - which is what a mixed report needs, one + file reset and another kept. Preview it before it writes: ```bash - tar -xzf -C - cp // - tools/wikitool publish --message "stack: revert local drift on " --no-push - tools/wikitool dist upgrade --dry-run # now 0 locally changed + tools/wikitool dist upgrade --dry-run --take-release [--take-release ] ``` - **The commit goes through `publish`, not `git commit`** - AGENTS.md invariant 5 has no - exception for "it is only a precondition", and `--no-push` is what keeps this from being a - push nobody asked for. This whole recipe disappears the day `dist upgrade` grows a - counterpart to `--keep-local` that writes the release's version instead of aborting. + The preview marks every named path as one it would overwrite from the release, and a path that + is not actually in the locally-changed list is refused *here* rather than in the writing run. + Nothing else is needed: no copy out of the unpacked tarball by hand, and no commit made only + to satisfy the next command's clean-tree precondition. Carry the flags you settled on into + step 7. -7. **Swap the machinery.** Note the commit the instance is on first - step 13 compares against - it: + **Where `--keep-local` answers for some paths and `--take-release` for others, both go on the + same call.** Without `--keep-local`, a locally changed path that no `--take-release` names + still aborts the run: every one of them has to be answered for, and the abort's own text + names the three answers with the command line already filled in. + +7. **Swap the machinery**, with whatever step 6 settled on. Note the commit the instance is on + first - step 13 compares against it: ```bash git rev-parse --short HEAD # the pre-swap commit; keep it - tools/wikitool dist upgrade + tools/wikitool dist upgrade [--take-release ] [--keep-local] ``` It writes, and commits nothing. diff --git a/tools/CONTRACT.md b/tools/CONTRACT.md index d931f6a..22d3831 100644 --- a/tools/CONTRACT.md +++ b/tools/CONTRACT.md @@ -185,7 +185,7 @@ tools/wikitool --help | Command | Purpose | |---------|---------| | `dist export [--dry-run] [--source-repo U] [--source-commit SHA] [--release-url U] [--update-url U]` | Write a contentless, distributable copy of this repo's machinery into an empty `` directory: `AGENTS.md`/`README.md`/`EVALS.md` with any `...` region removed, `instructions/` (minus `instructions/dev/`), `types/` (the `root: kb` page type-specs and their schemas re-keyed as `.template`, the stack's own verbatim), `docs/` verbatim, `tools/` (no venv/caches), the `.github/hooks/`+`.vibe/` session-tracing config plus `.claude/settings.json`, `kb/CONTRACT.md` (no pages, no areas), the two flat anchors `raw/.gitkeep` and `incoming/.gitkeep` (both roots are flat now that a file's location under `raw/` is a date shard rather than a hand-picked type, so a fresh export no longer creates any type subdirectories under either root; `incoming/.gitkeep` is trackable and survives becoming a git repository, so a plain clone gets the directory without any bootstrap step re-creating it), `VERSION`, `USER.md.template`/`SOUL.md.template` plus `kb/CONVENTIONS.md.template` and each collection's contract re-keyed as `kb//COLLECTION.md.template` (the templates ship; the filled `USER.md`/`SOUL.md`/`kb/CONVENTIONS.md`/`kb//COLLECTION.md`/`types/.md` never do - all of them bind their instance and none are the stack's to decide, and `find_leaks` refuses a plan carrying one), and a generated `.wikitool-release.json` stamp (version, export date, origin, and a sha256 per exported file - the base a later upgrade would compare against). The four origin options only fill stamp fields: `export` never calls git and cannot discover them. Refuses a non-empty target, and a tree with no `VERSION`. See `instructions/setup-instance.md`. One-way: there is no command that reconstructs a distributed instance into a dev instance - work on the stack in the origin repo (or a new dev instance exported from it) instead | -| `dist upgrade [--dry-run] [--keep-local] [--prune] [--pre]` | Apply a stack update `dist export` produced - the write half of `version check`. Never downloads anything: `` is an already-fetched export directory or `.tar.gz` release archive (verified against a sibling `.sha256` if one is present; WARNs, does not block, if it is absent), which must unpack to exactly one top-level directory - the shape `.gitea/workflows/release.yml` packs. The write set is exactly the *new* `.wikitool-release.json`'s `files` block, minus what an export re-seeds from a blank template every time (`kb/log.md`, `raw/.gitkeep` - `chemenu.ownership.is_export_stub`) or seeds once and the instance owns from then on (`.wikitool-kb.json`, `CHANGES.md` - `chemenu.ownership.is_upgrade_preserved`), plus the stamp itself, always rewritten. Every candidate path is classified against the *local* `.wikitool-release.json`'s recorded digest for it: unchanged is overwritten silently, absent from the old stamp is created, and locally modified or locally deleted is **never** silently overwritten - the run aborts with the full list unless `--keep-local` says to proceed and leave every one of them untouched. After a `--keep-local` run the new stamp is still written whole, so it records the release's digest for files that were deliberately *not* written: the stamp is the baseline for the next comparison, not a literal inventory of what is on disk. That is what keeps a skipped file diverging - and therefore reported - on every later run, rather than quietly reading as current once it has been skipped once. 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. Reports the migration chain the new machinery would owe (`chemenu.kb_state.chain` over the *new* tree's `instructions/migrations/`, read via a `directory` argument to `load_migrations`) but never runs any of it - there is no `migrate run`. Refuses before touching the source at all when: `VERSION` or `.wikitool-release.json` (with a `files` block) is missing locally, `.wikitool-kb.json` is missing, a migration is already outstanding against the *installed* machinery, or the working tree is dirty (not being a git repository at all is a WARN, not a refusal). Refuses after reading the source when: it carries no `VERSION`/`.wikitool-release.json`/`files` block, its version is older than or equal to the installed one (equal is a no-op success), or it is a pre-release (`-beta.N`) without `--pre`. Reports, but does not block on, a crossed compatibility boundary. Never touches git - no commit, no push (invariant 5). The closing report carries no step list of its own: everything after the swap is one order, written in `instructions/upgrade-instance.md`, which the report names and which resumes at `instructions sync`. What a human decides *before* the swap - which release, whether to take it, where the tarball comes from - is `INSTALL.md` § "Version und Updates" | +| `dist upgrade [--dry-run] [--keep-local] [--take-release ]... [--prune] [--pre]` | Apply a stack update `dist export` produced - the write half of `version check`. Never downloads anything: `` is an already-fetched export directory or `.tar.gz` release archive (verified against a sibling `.sha256` if one is present; WARNs, does not block, if it is absent), which must unpack to exactly one top-level directory - the shape `.gitea/workflows/release.yml` packs. The write set is exactly the *new* `.wikitool-release.json`'s `files` block, minus what an export re-seeds from a blank template every time (`kb/log.md`, `raw/.gitkeep` - `chemenu.ownership.is_export_stub`) or seeds once and the instance owns from then on (`.wikitool-kb.json`, `CHANGES.md` - `chemenu.ownership.is_upgrade_preserved`), plus the stamp itself, always rewritten. Every candidate path is classified against the *local* `.wikitool-release.json`'s recorded digest for it: unchanged is overwritten silently, absent from the old stamp is created, and locally modified or locally deleted is **never** silently overwritten - the run aborts with the full list, and its text names the three answers with the command line already filled in, so that no reader takes any of them for the default. `--keep-local` proceeds and leaves every one of them untouched; `--take-release ` (repeatable) writes the release's version over the named path, discarding the local change, and re-creates it if it was locally deleted. 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 `--take-release` path that this run does not report as locally changed is refused, in a `--dry-run` as well as a writing run - it is a mistake in the argument rather than a state of the tree, and a path that silently did nothing would report a successful upgrade while keeping the change it was asked to discard. After a `--keep-local` run the new stamp is still written whole, so it records the release's digest for files that were deliberately *not* written: the stamp is the baseline for the next comparison, not a literal inventory of what is on disk. That is what keeps a skipped file diverging - and therefore reported - on every later run, rather than quietly reading as current once it has been skipped once. A path taken with `--take-release` is the opposite case and the reason the flag exists: it was written, so it matches the digest the stamp records and stops being reported at all. 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. Reports the migration chain the new machinery would owe (`chemenu.kb_state.chain` over the *new* tree's `instructions/migrations/`, read via a `directory` argument to `load_migrations`) but never runs any of it - there is no `migrate run`. Refuses before touching the source at all when: `VERSION` or `.wikitool-release.json` (with a `files` block) is missing locally, `.wikitool-kb.json` is missing, a migration is already outstanding against the *installed* machinery, or the working tree is dirty (not being a git repository at all is a WARN, not a refusal). Refuses after reading the source when: it carries no `VERSION`/`.wikitool-release.json`/`files` block, its version is older than or equal to the installed one (equal is a no-op success), it is a pre-release (`-beta.N`) without `--pre`, or `--take-release` names a path this run does not classify as locally changed. Reports, but does not block on, a crossed compatibility boundary. Never touches git - no commit, no push (invariant 5). The closing report carries no step list of its own: everything after the swap is one order, written in `instructions/upgrade-instance.md`, which the report names and which resumes at `instructions sync`. What a human decides *before* the swap - which release, whether to take it, where the tarball comes from - is `INSTALL.md` § "Version und Updates" | | `version show [--json]` | Print this instance's stack version and where it came from (development tree, or a distribution with its export date and origin). Bare `wikitool version` is an alias for this. Read-only, offline, and **exempt from the Iteration Budget Gate** | | `version check [--url U] [--timeout S] [--json]` | Ask the origin's release feed whether a newer stack exists, and whether the step crosses a compatibility boundary (`state: current\|update\|migration\|ahead`). **The only command in `wikitool` that makes a network call** - never reached implicitly from another command, needs no key, times out, and reports an unreachable feed as an error rather than as "up to date". The feed is `$WIKITOOL_UPDATE_URL`, else the release stamp's, else the built-in origin; `$WIKITOOL_UPDATE_TOKEN` is only needed if that feed is not readable anonymously. Read-only and exempt from the budget gate | | `version notes [--version X.Y.Z]` | Print one version's `CHANGES.md` entry, for use as release notes (default: this tree's `VERSION`). Read-only and exempt from the budget gate | @@ -396,7 +396,7 @@ is atomic, and whether a retry is safe. | Command | Exit 1 means | Atomic? | Retry policy | |---------|--------------|---------|--------------| | `dist export` | Target exists and is not empty, is not a directory, or the tree has no readable `VERSION` | Yes - nothing is written until every file is planned | Point `` at an empty (or new) directory and retry. Never merge into a non-empty one by hand | -| `dist upgrade` | Missing local `VERSION`/`.wikitool-release.json`(`files`)/`.wikitool-kb.json`, a migration already outstanding against the installed machinery, a dirty working tree, a source with no `VERSION`/stamp/`files` block, a source version that is older than, equal to, or (without `--pre`) a pre-release relative to the installed one, or one or more locally changed files without `--keep-local` | **Yes for the refusal cases above - nothing is written.** Once writing starts it is a plain sequential file copy with no partial-state cleanup: an interruption mid-copy (killed process, disk full) can leave the tree part-old, part-new | For every refusal above: fix the named precondition and retry - none of them are transient. For locally changed files: reconcile them by hand and retry, or re-run with `--keep-local` to proceed and leave them untouched (repeatable - it reports the same files again on every subsequent run until they stop diverging). An interrupted write is not resumed automatically; compare the tree against the printed classification and finish or revert by hand | +| `dist upgrade` | Missing local `VERSION`/`.wikitool-release.json`(`files`)/`.wikitool-kb.json`, a migration already outstanding against the installed machinery, a dirty working tree, a source with no `VERSION`/stamp/`files` block, a source version that is older than, equal to, or (without `--pre`) a pre-release relative to the installed one, a `--take-release` path that is not classified as locally changed (the one refusal a `--dry-run` also raises), or one or more locally changed files that neither `--keep-local` nor a `--take-release` answers for | **Yes for the refusal cases above - nothing is written.** Once writing starts it is a plain sequential file copy with no partial-state cleanup: an interruption mid-copy (killed process, disk full) can leave the tree part-old, part-new | For every refusal above: fix the named precondition and retry - none of them are transient. For a rejected `--take-release` path: correct it against the locally-changed list the refusal prints. For locally changed files, the refusal names all three answers with the re-run line filled in - `--take-release ` to write the release's version over it (which ends the divergence), `--keep-local` to leave them untouched (repeatable, and it reports the same files again on every subsequent run until they stop diverging), or reconcile by hand and retry. An interrupted write is not resumed automatically; compare the tree against the printed classification and finish or revert by hand | | `version show` | `VERSION` is missing or unparseable | Read-only | Fix `VERSION` and retry | | `version check` | The feed could not be reached, answered non-JSON, or carried no `tag_name`. **Never** answers "up to date" for a question it could not ask | Read-only, no local writes | A network failure is transient - retry once, then report it. HTTP 401/403 names `$WIKITOOL_UPDATE_TOKEN`; 404 means no release exists yet or the URL points at the wrong repo | | `version notes` | An unparseable `--version`, an unreadable `VERSION` when `--version` is omitted, a missing `CHANGES.md`, or no entry naming the requested version | Read-only | Fix the named argument or file, then retry. Safe to retry | diff --git a/tools/chemenu/commands/dist_cmd.py b/tools/chemenu/commands/dist_cmd.py index f89bf41..e2d0d8d 100644 --- a/tools/chemenu/commands/dist_cmd.py +++ b/tools/chemenu/commands/dist_cmd.py @@ -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 ` 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 ` 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 "") + "." diff --git a/tools/chemenu/tests/test_dist_upgrade.py b/tools/chemenu/tests/test_dist_upgrade.py index 71ef75c..05e23e6 100644 --- a/tools/chemenu/tests/test_dist_upgrade.py +++ b/tools/chemenu/tests/test_dist_upgrade.py @@ -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 -----------------------------------------------------------