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:
+2
-2
@@ -185,7 +185,7 @@ tools/wikitool <command> --help
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `dist export <target> [--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 `<target>` directory: `AGENTS.md`/`README.md`/`EVALS.md` with any `<!-- dist:strip-start -->...<!-- dist:strip-end -->` 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/<name>/COLLECTION.md.template` (the templates ship; the filled `USER.md`/`SOUL.md`/`kb/CONVENTIONS.md`/`kb/<name>/COLLECTION.md`/`types/<page-type>.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 <source> [--dry-run] [--keep-local] [--prune] [--pre]` | Apply a stack update `dist export` produced - the write half of `version check`. Never downloads anything: `<source>` 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 <source> [--dry-run] [--keep-local] [--take-release <path>]... [--prune] [--pre]` | Apply a stack update `dist export` produced - the write half of `version check`. Never downloads anything: `<source>` 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 <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 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 `<target>` 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 <path>` 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 |
|
||||
|
||||
@@ -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 "")
|
||||
+ "."
|
||||
|
||||
@@ -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 -----------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user