feat!: installation only from a release, into an empty folder; upstream merge/verify and private-instance.md removed, dist adopt, shell-neutral instructions (#153)
CI / verify (push) Successful in 5m19s
CI / pwsh (push) Successful in 1m55s
Release / release (push) Successful in 36s

Files changed:
- .gitea/workflows/ci.yml
- .gitea/workflows/release.yml
- AGENTS.md
- CHANGES.md
- DEVELOPMENT.md
- EVALS.md
- INSTALL.md
- README.md
- VERSION
- docs/ownership-and-templates.md
- instructions/CONTRACT.md
- instructions/bootstrap.md
- instructions/dev/dev-setup.md
- instructions/dev/stack-dev/SKILL.md
- instructions/gates.md
- instructions/ingest-large-tree.md
- instructions/kb-profiles.md
- instructions/mcp-read-server.md
- instructions/migrations/3.0.0-authoring-conventions.md
- instructions/preflight.md
- instructions/private-instance.md
- instructions/session-setup.md
- instructions/setup-instance.md
- instructions/upgrade-instance.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/cli.py
- tools/chemenu/cli_contract.py
- tools/chemenu/commands/dist_cmd.py
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/git_publish.py
- tools/chemenu/commands/upstream_cmd.py
- tools/chemenu/commands/work_cmd.py
- tools/chemenu/config.py
- tools/chemenu/ownership.py
- tools/chemenu/tests/test_cli.py
- tools/chemenu/tests/test_dist_cmd.py
- tools/chemenu/tests/test_instructions_shell.py
- tools/chemenu/tests/test_preflight.py
- tools/chemenu/tests/test_preflight_pwsh.py
- tools/chemenu/tests/test_run_budget.py
- tools/chemenu/tests/test_upstream_cmd.py
- tools/chemenu/toc.py
- tools/preflight.ps1
- tools/preflight.sh
This commit is contained in:
torben committed 2026-10-01 22:12:09 +02:00
1 parent d0f08d1fba
commit a6d07f97c4
46 files changed
+1314 -1936

No files matched your search

+55 -119
View File
@@ -42,7 +42,6 @@ file end to end is for changing the CLI itself.
- [Telemetry](#telemetry)
- [Distribution and versioning](#distribution-and-versioning)
- [Content migrations](#content-migrations)
- [Private instances](#private-instances)
- [Instance health](#instance-health)
- [Design notes](#design-notes)
- [Tests](#tests)
@@ -137,6 +136,7 @@ docs contract write idempotent budget:counted exit:0,1
eval sessions read idempotent budget:exempt exit:0 List the sessions that have a trace under `reports/telemetry/`.
eval score read idempotent budget:exempt exit:0,1 Score one traced session.
dist export write idempotent budget:counted exit:0,1 Write a contentless, distributable copy of this repo's machinery.
dist adopt write idempotent budget:counted exit:0,1 Take shipped templates as this instance's own: copy each to its unsuffixed name.
dist upgrade write non-idempotent budget:counted exit:0,1 Apply a stack update `dist export` produced - the write half of `version check`.
version show read idempotent budget:exempt exit:0,1 Print this instance's stack version and where it came from.
version check read idempotent budget:exempt exit:0,1 Ask the origin's release feed whether a newer stack exists.
@@ -149,8 +149,6 @@ migrate status read idempotent budget:exempt exit:0,1
migrate verify read idempotent budget:exempt exit:0,1 Compare `kb/` against a git revision on the invariants a content migration must not change.
migrate done write non-idempotent budget:counted exit:0,1 Record one migration as applied, advancing `kb_version` in `.wikitool-kb.json`.
migrate baseline write idempotent budget:counted exit:0,1 Declare `kb_version` once, for an instance predating `.wikitool-kb.json`.
upstream merge write non-idempotent budget:counted exit:0,1 Take a stack update into a private instance's branch, machinery only.
upstream verify read idempotent budget:exempt exit:0,1 Compare two revisions: did anything under a content stage change except through a stack-owned path?
doctor read idempotent budget:exempt exit:0,1 Check that this instance is correctly configured.
```
@@ -2437,15 +2435,66 @@ Write a contentless, distributable copy of this repo's machinery.
- Ships templates, never the filled files: `USER.md.template`/`SOUL.md.template`, `kb/CONVENTIONS.md.template`, and each collection's contract re-keyed as `kb/<name>/COLLECTION.md.template`. The filled `USER.md`/`SOUL.md`/`kb/CONVENTIONS.md`/`kb/<name>/COLLECTION.md`/`types/<page-type>.md` bind their instance; `find_leaks` refuses a plan carrying one.
- Writes a generated `.wikitool-release.json` stamp: version, export date, origin, and a sha256 per exported file - the base a later upgrade compares against.
- The four origin options only fill stamp fields: `export` never calls git and cannot discover them.
- One-way: no command reconstructs a distributed instance into a dev instance - work on the stack in the origin repo, or in a new dev instance exported from it.
- A build and test tool: every release is an export packed as a tarball, and an instance is installed from such a release, never from an export directly.
- One-way: no command reconstructs a distributed instance into a dev instance - work on the stack in a clone of the origin repo.
- `--dry-run` lists every file it would write, and writes nothing.
**SEE ALSO**
- `instructions/setup-instance.md` - what comes after the export
- `instructions/setup-instance.md` - installs a release, which is this export as a tarball
- `wikitool dist upgrade` - applies a later export to an existing instance
- `wikitool version show` - reads the stamp this writes
#### `dist adopt`
Take shipped templates as this instance's own: copy each to its unsuffixed name.
**SYNOPSIS**
- `wikitool dist adopt [<template>...] [--dry-run]`
**PROPERTIES**
- effect: write
- idempotent: yes
- atomic: No - files are copied one by one; a re-run completes an interrupted one
- budget: counted
- network: no
**EXAMPLES**
- `tools/wikitool dist adopt`
- `tools/wikitool dist adopt types/project.md.template types/project.schema.yaml.template`
- `tools/wikitool dist adopt --dry-run`
**EXIT STATUS**
- 0 success
- 1 A named path does not exist, or is not a collection contract or page type-spec template
**ON FAILURE**
- A named path does not exist, or is not a collection contract or page type-spec template -> Not transient - name a template from the set in NOTES, or call it without a path
**NEVER**
- Never delete a target to make `dist adopt` replace it - a filled file is the instance's own work.
**NOTES**
- Copies `<name>.template` to `<name>` byte for byte; the template stays where it is, as the base the next `dist upgrade` compares against.
- Without a path: every `kb/<collection>/COLLECTION.md.template` and every `types/*.template` (the `root: kb` page type-specs and their schemas).
- With paths: exactly those templates, each of which has to be one of the set above.
- Never overwrites: a target that already exists is reported as kept and left untouched.
- Not for `kb/CONVENTIONS.md.template` or the personalization templates - those carry a sentinel and are filled in, not copied.
- `--dry-run` lists what it would copy and keep, and writes nothing.
**SEE ALSO**
- `instructions/setup-instance.md` - adopts every template on a fresh instance
- `instructions/upgrade-instance.md` - adopts a template a release added
- `wikitool dist export` - re-keys these files as `.template` in the first place
#### `dist upgrade`
Apply a stack update `dist export` produced - the write half of `version check`.
@@ -2492,7 +2541,7 @@ Apply a stack update `dist export` produced - the write half of `version check`.
**ON FAILURE**
- Both `<source>` and `--latest`, or neither; or `--expect` without `--latest` -> Not transient - name exactly one source, and pass `--expect` only with `--latest`
- Local `VERSION` missing, or no local `.wikitool-release.json` with a `files` block -> Not transient - fix the named precondition and retry. A checkout with shared git history takes stack updates with `wikitool upstream merge` instead
- Local `VERSION` missing, or no local `.wikitool-release.json` with a `files` block -> Not transient - fix the named precondition and retry. A clone of the origin repo is a development checkout and takes no `dist upgrade` at all
- `.wikitool-kb.json` is missing -> Run `wikitool migrate baseline <version>`, then retry
- A migration is already outstanding against the *installed* machinery -> Finish it first - `wikitool migrate status` names it - then retry
- The working tree is dirty -> Commit or stash first, then retry
@@ -2537,7 +2586,6 @@ Apply a stack update `dist export` produced - the write half of `version check`.
- `wikitool version notes` - the notes of the release `--expect` should name
- `instructions/upgrade-instance.md` - the order after the swap
- `INSTALL.md` § "Version und Updates" - which release, whether to take it, where the tarball comes from
- `wikitool upstream merge` - the update path for a checkout with shared git history
- `wikitool migrate status` - the migrations the report names
#### `version show`
@@ -3081,118 +3129,6 @@ Declare `kb_version` once, for an instance predating `.wikitool-kb.json`.
- `wikitool migrate done` - advances the version after a migration
- `wikitool migrate status` - what is owed from the declared version
### Private instances
#### `upstream merge`
Take a stack update into a private instance's branch, machinery only.
**SYNOPSIS**
- `wikitool upstream merge [--remote upstream] [--branch main] [--no-fetch]`
**PROPERTIES**
- effect: write
- idempotent: no
- atomic: **No** - can leave an open, uncommitted merge behind on refusal after fetching
- budget: counted
- network: yes
**EXAMPLES**
- `tools/wikitool upstream merge`
- `tools/wikitool upstream merge --remote upstream --branch main --no-fetch`
**EXIT STATUS**
- 0 success
- 1 Dirty working tree, or a merge already in progress
- 1 The remote does not resolve, the fetch failed, or `HEAD` does not resolve
- 1 git refused to open the merge at all (unrelated histories); nothing was touched
- 1 A real conflict remains in `tools/`/`types/`/`instructions/` after the content stages and stack-owned paths were restored; the merge is left open
- 1 A git step failed inside the open merge (`git checkout MERGE_HEAD -- <path>` or `git commit --no-edit`)
- 1 The postcheck after the commit found a leak; the merge commit already exists
**ON FAILURE**
- Dirty working tree, or a merge already in progress -> Fix the named precondition and retry once
- The remote does not resolve, the fetch failed, or `HEAD` does not resolve -> Fix `--remote`/`--branch` or the repository state, then retry once
- git refused to open the merge at all (unrelated histories); nothing was touched -> Do not retry unchanged - report it to the user
- A real conflict remains in `tools/`/`types/`/`instructions/` after the content stages and stack-owned paths were restored; the merge is left open -> **Do not retry, do not force** - resolve the named paths by hand (take the upstream side, or re-file the local change as an issue against the public repo per `instructions/private-instance.md`) and either `git commit --no-edit` yourself or `git merge --abort`
- A git step failed inside the open merge (`git checkout MERGE_HEAD -- <path>` or `git commit --no-edit`) -> Do not retry unchanged - inspect the open merge by hand
- The postcheck after the commit found a leak; the merge commit already exists -> It is **not** rolled back automatically - inspect it by hand; this is a bug report, not a retry
**NEVER**
- Never retry a failed merge unchanged, and never force.
**NOTES**
- Refuses on a dirty working tree, a merge already in progress, or a remote that does not resolve. WARNs (does not block) when `.wikitool-remotes.json` is absent, pointing at the setup step that arms it.
- Fetches `<remote>/<branch>` (unless `--no-fetch`) and reports "already up to date" if nothing new exists.
- Otherwise opens `git merge --no-commit --no-ff <remote>/<branch>`, and stops, untouched, if git refused to open a merge at all (unrelated histories).
- Forces every content stage (`kb/`, `raw/`, `work/`, `reports/`) back to the local side by removing **only the paths tracked in either tree** and checking `HEAD`'s back out - never the stage directory wholesale, so untracked and ignored local data under a stage (telemetry traces, saved eval and lint reports) is never deleted.
- Then restores from the upstream side exactly the machinery paths - `<stage>/CONTRACT.md` and anything ending `.template` under a content stage - including a deletion, if the upstream removed one.
- A real conflict left in `tools/`, `types/` or `instructions/` after that leaves the merge open, uncommitted, and exits 1 rather than guessing.
- Commits with `git commit --no-edit`, then re-checks the resulting range with the `upstream verify` check; a finding there is a loud error, and the merge commit is not rolled back.
- Never pushes.
- Not idempotent, and not safe to retry unchanged.
**SEE ALSO**
- `instructions/private-instance.md` § "Taking a stack update" - the procedure this implements
- `wikitool upstream verify` - the same check on any revision range
- `wikitool publish` - pushes the merge afterwards
#### `upstream verify`
Compare two revisions: did anything under a content stage change except through a stack-owned path?
**SYNOPSIS**
- `wikitool upstream verify --since <rev> [--until HEAD]`
**PROPERTIES**
- effect: read
- idempotent: yes
- atomic: Read-only
- budget: exempt
- network: no
**EXAMPLES**
- `tools/wikitool upstream verify --since HEAD~1`
- `tools/wikitool upstream verify --since v7.0.0 --until HEAD`
**EXIT STATUS**
- 0 success
- 1 A leak: content changed under a content stage through a path that is not stack-owned
- 1 `--since`/`--until` is not a revision in this repository
**ON FAILURE**
- A leak: content changed under a content stage through a path that is not stack-owned -> A finding is not fixed by re-running - it names the paths that leaked
- `--since`/`--until` is not a revision in this repository -> Fix the revision argument and retry
**NEVER**
- Never re-run to make a leak finding go away.
**NOTES**
- Compares `--since` with `--until` (default `HEAD`): did anything under a content stage change except through a stack-owned path?
- The same check `upstream merge` runs after its commit, so a hand-resolved merge conflict, or a `dist upgrade`, can be verified the same way.
- Exits 1 with the offending paths if anything leaked; otherwise reports which stack-owned paths legitimately moved.
- Read-only and exempt from the Iteration Budget Gate.
**SEE ALSO**
- `wikitool upstream merge` - runs this check after its commit
- `instructions/private-instance.md` - the private-instance workflow
### Instance health
#### `doctor`
+2 -2
View File
@@ -71,7 +71,7 @@ tools/
wikitool entry point (POSIX sh): stops with exit 42 until the preflight has passed
wikitool.ps1 the same entry point for PowerShell 7, which resolves `tools/wikitool` to this file first
run_wikitool.py what the launcher runs with the venv's Python - puts chemenu on sys.path without PYTHONPATH, sets stdout/stderr to UTF-8
preflight.sh checks prerequisites.txt, records .wikitool-tools.json, creates .venv (POSIX sh); as the release asset, downloads and unpacks the stack first
preflight.sh checks prerequisites.txt, records .wikitool-tools.json, creates .venv (POSIX sh); as the release asset, downloads the stack and unpacks it into its own (empty) folder first
preflight.ps1 the same for PowerShell 7; also checks the execution policy and the Mark of the Web
prerequisites.txt what the machine needs, one `|`-separated line per tool - read by the preflight and `doctor`
trace-hook what the harness hooks call: trace_ingest.py under the venv's Python
@@ -91,7 +91,7 @@ tools/
links.py labelled edges in `related:` - the graph's semantics as data, not prose
kb_collections.py collection discovery (a directory with COLLECTION.md), and what one declares about itself
conventions.py kb/CONVENTIONS.md: what this instance decided about authoring, as opposed to what the stack enforces
ownership.py the stack-vs-instance boundary under a content stage - one predicate, read by `dist_cmd.py` and `commands/upstream_cmd.py` so the two cannot answer it differently
ownership.py the stack-vs-instance boundary under a content stage - one predicate, so no caller keeps a list of its own
type_resolver.py type-spec loading and schema resolution
catalog.py how the corpus groups into collections and areas, and the shard threshold - with no CLI attached
lint_core.py the lint checks and the report, with no CLI attached
-2
View File
@@ -49,7 +49,6 @@ try:
touch as touch_module,
types_cmd,
upload_cmd,
upstream_cmd,
version_cmd,
work_cmd,
xref,
@@ -178,7 +177,6 @@ app.add_typer(eval_cmd.app, name="eval")
app.add_typer(dist_cmd.app, name="dist")
app.add_typer(version_cmd.app, name="version")
app.add_typer(migrate_cmd.app, name="migrate")
app.add_typer(upstream_cmd.app, name="upstream")
app.add_typer(task_cmd.app, name="task")
app.command("new")(new_page.new_page_command)
app.command("touch")(touch_module.touch_command)
+1 -4
View File
@@ -276,16 +276,13 @@ GROUPS: tuple[tuple[str, tuple[str, ...]], ...] = (
"eval sessions", "eval score",
)),
("Distribution and versioning", (
"dist export", "dist upgrade",
"dist export", "dist adopt", "dist upgrade",
"version show", "version check", "version notes",
"version bump", "version regrade", "version release",
)),
("Content migrations", (
"migrate list", "migrate status", "migrate verify", "migrate done", "migrate baseline",
)),
("Private instances", (
"upstream merge", "upstream verify",
)),
("Instance health", (
"doctor",
)),
+129 -16
View File
@@ -149,8 +149,8 @@ INSTRUCTIONS_EXCLUDE_DIRS = {"dev"}
# - it is a content stage too, but it has collections underneath it, so its
# contract is handled by `build_plan` alongside them rather than as a bare
# stage copy. Derived from `ownership.CONTENT_STAGES` rather than listed
# again, so the set this loop copies and the set `upstream merge` restores
# cannot name a different stage without one of them failing its own test.
# again, so this loop and `ownership.is_stack_owned` cannot name a different
# stage without one of them failing its own test.
CONTRACT_ONLY_STAGES = tuple(
f"{stage}/CONTRACT.md" for stage in ownership.CONTENT_STAGES if stage != "kb"
)
@@ -523,12 +523,9 @@ def build_plan(origin: Optional[Origin] = None) -> dict[str, PlannedFile]:
# though they were the stack's.
#
# What counts as machinery under kb/ or raw/ is no longer a second list here:
# it is `ownership.is_stack_owned`, the same predicate `upstream merge` and
# `upstream verify` restore/check against. Only the export-only stubs
# it is `ownership.is_stack_owned`. Only the export-only stubs
# (`ownership.EXPORT_STUB_NAMES`) are allowed here without also being
# stack-owned - a merge keeps the *local* copy of those, while export writes a
# fresh one regardless of either side, so the two callers genuinely disagree
# about them and each keeps its own allowance for that one case.
# stack-owned - export writes a fresh one rather than shipping this repo's.
_CONTENT_PREFIXES = ("kb/", "raw/")
_INSTANCE_OWNED_KB_FILES = (kb_collections.CONTRACT_NAME, conventions.CONVENTIONS_FILENAME)
@@ -607,8 +604,10 @@ def _write_plan(target: Path, plan: dict[str, PlannedFile]) -> None:
"a sha256 per exported file - the base a later upgrade compares against.",
"The four origin options only fill stamp fields: `export` never calls git and cannot "
"discover them.",
"A build and test tool: every release is an export packed as a tarball, and an instance "
"is installed from such a release, never from an export directly.",
"One-way: no command reconstructs a distributed instance into a dev instance - work on "
"the stack in the origin repo, or in a new dev instance exported from it.",
"the stack in a clone of the origin repo.",
"`--dry-run` lists every file it would write, and writes nothing.",
),
failures=(
@@ -639,7 +638,7 @@ def _write_plan(target: Path, plan: dict[str, PlannedFile]) -> None:
"Never merge an export into a non-empty directory by hand.",
),
see_also=(
"`instructions/setup-instance.md` - what comes after the export",
"`instructions/setup-instance.md` - installs a release, which is this export as a tarball",
"`wikitool dist upgrade` - applies a later export to an existing instance",
"`wikitool version show` - reads the stamp this writes",
),
@@ -725,6 +724,122 @@ def run_export(target: Path, dry_run: bool = False, origin: Optional[Origin] = N
success(f"Exported {len(plan)} file(s) to {rel_path(target)}.")
# --- dist adopt --------------------------------------------------------------
#
# The other half of the `.template` split above: an instance takes the shipped
# default as its own by copying it to the unsuffixed name. Only the templates
# whose shipped text is a working default are in scope - each collection's
# contract and the page type-specs with their schemas. `kb/CONVENTIONS.md` and
# the personalization files ship as templates too, but carry a sentinel and
# exist to be filled in, so a verbatim copy of them would only be a file
# `doctor` refuses; the agent writes those itself.
def adoptable_templates() -> list[Path]:
"""Every template `dist adopt` copies when it is given no path, sorted."""
found = [
path
for path in config.KB_DIR.glob(f"*/{kb_collections.CONTRACT_NAME}{toc.TEMPLATE_SUFFIX}")
if path.is_file()
]
found += [
path for path in config.TYPES_DIR.glob(f"*{toc.TEMPLATE_SUFFIX}") if path.is_file()
]
return sorted(found)
@cli_contract.record(cli_contract.CommandRecord(
path="dist adopt",
summary="Take shipped templates as this instance's own: copy each to its unsuffixed name.",
synopsis=(cli_contract.Variant(usage="dist adopt [<template>...] [--dry-run]"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.YES,
atomic="No - files are copied one by one; a re-run completes an interrupted one",
budget=cli_contract.Budget.COUNTED,
),
notes=(
"Copies `<name>.template` to `<name>` byte for byte; the template stays where it is, as "
"the base the next `dist upgrade` compares against.",
"Without a path: every `kb/<collection>/COLLECTION.md.template` and every "
"`types/*.template` (the `root: kb` page type-specs and their schemas).",
"With paths: exactly those templates, each of which has to be one of the set above.",
"Never overwrites: a target that already exists is reported as kept and left untouched.",
"Not for `kb/CONVENTIONS.md.template` or the personalization templates - those carry a "
"sentinel and are filled in, not copied.",
"`--dry-run` lists what it would copy and keep, and writes nothing.",
),
failures=(
cli_contract.Failure(
cause="A named path does not exist, or is not a collection contract or page type-spec "
"template",
reaction="Not transient - name a template from the set in NOTES, or call it without "
"a path",
),
),
examples=(
"tools/wikitool dist adopt",
"tools/wikitool dist adopt types/project.md.template types/project.schema.yaml.template",
"tools/wikitool dist adopt --dry-run",
),
never=(
"Never delete a target to make `dist adopt` replace it - a filled file is the "
"instance's own work.",
),
see_also=(
"`instructions/setup-instance.md` - adopts every template on a fresh instance",
"`instructions/upgrade-instance.md` - adopts a template a release added",
"`wikitool dist export` - re-keys these files as `.template` in the first place",
),
))
@app.command("adopt")
def adopt_command(
templates: Optional[list[Path]] = typer.Argument(
None, help="Templates to adopt. Default: every collection contract and page type-spec template."
),
dry_run: bool = typer.Option(
False, "--dry-run", help="List what would be copied, without writing anything."
),
):
"""Copy each shipped template to its unsuffixed name, never overwriting a file
that already exists."""
run_adopt(templates or [], dry_run=dry_run)
def run_adopt(templates: Sequence[Path], dry_run: bool = False) -> None:
adoptable = {path.resolve() for path in adoptable_templates()}
if templates:
chosen = []
for given in templates:
path = given if given.is_absolute() else config.ROOT / given
if path.resolve() not in adoptable:
fail(
f"{given.as_posix()} is not a template `dist adopt` copies - it takes "
"`kb/<collection>/COLLECTION.md.template` and `types/*.template` only."
)
return
chosen.append(path)
else:
chosen = sorted(adoptable)
adopted = kept = 0
for template in chosen:
target = template.with_name(template.name[: -len(toc.TEMPLATE_SUFFIX)])
if target.exists():
typer.echo(f"keep {rel_path(target)} (exists)")
kept += 1
continue
typer.echo(f"adopt {rel_path(template)} -> {rel_path(target)}")
if not dry_run:
shutil.copyfile(template, target)
adopted += 1
if dry_run:
success(f"Dry run: would adopt {adopted} template(s), keep {kept}. Nothing written.")
else:
success(f"Adopted {adopted} template(s), kept {kept}.")
# --- dist upgrade ------------------------------------------------------------
#
# Apply a release `dist export` produced, rather than merely detecting one
@@ -1149,8 +1264,8 @@ def _report_plan(
cli_contract.Failure(
cause="Local `VERSION` missing, or no local `.wikitool-release.json` with a `files` "
"block",
reaction="Not transient - fix the named precondition and retry. A checkout with "
"shared git history takes stack updates with `wikitool upstream merge` instead",
reaction="Not transient - fix the named precondition and retry. A clone of the "
"origin repo is a development checkout and takes no `dist upgrade` at all",
),
cli_contract.Failure(
cause="`.wikitool-kb.json` is missing",
@@ -1235,7 +1350,6 @@ def _report_plan(
"`instructions/upgrade-instance.md` - the order after the swap",
"`INSTALL.md` § \"Version und Updates\" - which release, whether to take it, where "
"the tarball comes from",
"`wikitool upstream merge` - the update path for a checkout with shared git history",
"`wikitool migrate status` - the migrations the report names",
),
))
@@ -1347,10 +1461,9 @@ def run_upgrade(
fail(
f"No local {version_mod.RELEASE_STAMP_FILENAME} (or it carries no `files` block). "
"Without it, `dist upgrade` cannot tell a file this instance edited from one it "
"merely received, and it refuses to guess. A checkout with shared git history takes "
"stack updates via `wikitool upstream merge` instead - it has the same information "
"as a merge base. A tarball instance that has lost its stamp has no repair path "
"today; see Gitea #7 \"Bewusst offen gelassen\"."
"merely received, and it refuses to guess. A clone of the origin repo is a "
"development checkout and takes no `dist upgrade` at all. A release instance that "
"has lost its stamp has no repair path today; see Gitea #7 \"Bewusst offen gelassen\"."
)
return
old_files = old_stamp["files"]
+1 -1
View File
@@ -655,7 +655,7 @@ MARKDOWN_LINK_RE = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)")
# spelled again here: that module already decides which files are reference
# material in both their forms, and this check runs over its scope. Not from
# `ownership`, whose own `.template` handling answers a different question
# (which side an upstream merge keeps) over a narrower scope (paths under a
# (which path a release replaces) over a narrower scope (paths under a
# content stage).
TEMPLATE_SUFFIX = toc.TEMPLATE_SUFFIX
+2 -2
View File
@@ -411,8 +411,8 @@ def check_publish_remotes() -> Check:
reports, the way `environment` does.
It does WARN for the case that actually bites: more than one remote
configured and no allowlist. That is the shape a private instance has after
it adds the public upstream, and it is exactly when a wrong `--remote`
configured and no allowlist. That is the shape a private instance has once
it adds a public remote, and it is exactly when a wrong `--remote`
stops being a typo and starts being a disclosure.
Both absent states say **armed** or **not armed** rather than only naming
+2 -2
View File
@@ -92,8 +92,8 @@ def _run(args: list[str]) -> subprocess.CompletedProcess:
# The Mass-Update Gate asks "is this too much to publish?". This one asks the
# question underneath it: "is this the right place to publish to at all?".
#
# A checkout holding private content typically has two remotes - its own, and
# the public upstream it takes stack updates from. Nothing in git distinguishes
# A checkout holding private content can have two remotes - its own, and a
# public one it also works against. Nothing in git distinguishes
# them at push time, so a single wrong `--remote` puts a private corpus on a
# public repository, where a force-push does not take it back: the objects stay
# fetchable by SHA until someone expires the server's reflogs.
-513
View File
@@ -1,513 +0,0 @@
"""`wikitool upstream` - take a stack update from a public upstream into a
private instance's `main` without letting the upstream's own content (a demo
corpus, a workshop run) ride along.
`git merge upstream/main` on its own treats a moved corpus dangerously
asymmetrically: a page the instance deleted and the upstream edited reports as
a conflict, a page the upstream *added* stages silently, and a page both sides
deleted is the only harmless case. `instructions/private-instance.md`'s prose
procedure closes that, by holding the merge open, forcing the content stages
(`ownership.CONTENT_STAGES`) back to the local side, and then restoring only
the paths `ownership.is_stack_owned` recognises as machinery. `upstream merge`
is that procedure in code, so the path set it acts on cannot drift from the
one `dist_cmd.py` ships - both read `chemenu.ownership` - and so a conflict in
the machinery layers, or a machinery file the upstream deleted, gets an
explained stop instead of a silently wrong commit.
`upstream verify` is the other half: given two revisions, did anything change
under a content stage except through a stack-owned path? It shares
`_content_leaks` with the postcheck `upstream merge` runs on itself, so a
hand-resolved merge or a future `dist upgrade` (Gitea #7) can be checked the
same way.
"""
from __future__ import annotations
from pathlib import Path
from typing import Optional
import typer
from chemenu import cli_contract, config, ownership
from chemenu.commands import git_publish
from chemenu.commands._util import console, fail, success
app = typer.Typer(help="Take a stack update from a public upstream, machinery only.")
def _run(args: list[str]):
import subprocess
return subprocess.run(args, cwd=config.ROOT, capture_output=True, text=True, encoding="utf-8")
def _rev_parse(rev: str) -> Optional[str]:
result = _run(["git", "rev-parse", "--verify", "-q", rev])
return result.stdout.strip() if result.returncode == 0 else None
def _git_dir() -> Optional[Path]:
result = _run(["git", "rev-parse", "--git-dir"])
if result.returncode != 0:
return None
path = Path(result.stdout.strip())
return path if path.is_absolute() else config.ROOT / path
def _working_tree_dirty() -> bool:
result = _run(["git", "status", "--porcelain"])
return bool(result.stdout.strip())
def _merge_in_progress() -> bool:
git_dir = _git_dir()
return git_dir is not None and (git_dir / "MERGE_HEAD").exists()
def _remote_resolves(remote: str) -> bool:
return _run(["git", "remote", "get-url", remote]).returncode == 0
def _is_ancestor(ancestor: str, of: str) -> bool:
return _run(["git", "merge-base", "--is-ancestor", ancestor, of]).returncode == 0
def _tree_has_path(rev: str, path: str) -> bool:
return _run(["git", "rev-parse", "--verify", "-q", f"{rev}:{path}"]).returncode == 0
def _tree_paths(rev: str) -> set[str]:
result = _run(["git", "ls-tree", "-r", "--name-only", "-z", rev])
if result.returncode != 0:
return set()
return {p for p in result.stdout.split("\0") if p}
def _content_leaks(since: str, until: str) -> list[str]:
"""Paths under a content stage that changed between `since` and `until`
through something other than a stack-owned path. Shared by `upstream
merge`'s own postcheck and `upstream verify`, so the two cannot disagree
about what a clean update looks like."""
result = _run(["git", "diff", "--name-only", "-z", since, until, "--", *ownership.CONTENT_STAGES])
if result.returncode != 0:
fail(
f"`git diff {since} {until}` failed - is {since} a revision in this repository?\n"
f"{result.stderr}"
)
return []
changed = [p for p in result.stdout.split("\0") if p]
return sorted(p for p in changed if not ownership.is_stack_owned(p))
def _stack_paths_changed(since: str, until: str) -> list[str]:
"""The subset of the same diff that *is* a stack-owned path - the paths
that legitimately moved, for the success message."""
result = _run(["git", "diff", "--name-only", "-z", since, until, "--", *ownership.CONTENT_STAGES])
changed = [p for p in result.stdout.split("\0") if p]
return sorted(p for p in changed if ownership.is_stack_owned(p))
# --- upstream merge ---------------------------------------------------------
def _prune_empty_dirs(stage: str) -> None:
"""Remove directories left empty under `stage` after tracked files were
deleted. git tracks no directories, so an emptied one is invisible to
`git status` and would otherwise linger in the working tree as litter -
an empty `kb/<area>/` that only ever existed in the upstream's corpus.
Never touches a directory that still holds anything, ignored files
included."""
stage_dir = config.ROOT / stage
if not stage_dir.is_dir():
return
for path in sorted(stage_dir.rglob("*"), key=lambda p: len(p.parts), reverse=True):
if path.is_dir() and not any(path.iterdir()):
path.rmdir()
def _restore_stage_to_local(stage: str, tracked_paths: set[str]) -> None:
"""Force one content stage back to the local (HEAD) side, whatever the
merge did to it.
Deletes **only what git tracks on either side** - never the stage
directory wholesale. That distinction is the whole point of this function:
`reports/` is gitignored except its contract (see .gitignore), so a
content stage's working tree legitimately holds local data that is not in
any tree and not recomputable - the telemetry traces `eval score` reads,
saved eval reports, past lint reports. A blanket `rm -rf` of the stage
takes all of it out as collateral for a merge that was never about it.
Handles a stage that exists only in MERGE_HEAD too (the upstream
introduced it): what the merge wrote is removed, and there is simply
nothing to check out from HEAD afterwards.
"""
prefix = f"{stage}/"
stage_paths = [p for p in tracked_paths if p.startswith(prefix)]
if not stage_paths:
return
_run(["git", "rm", "-rq", "--cached", "--ignore-unmatch", stage])
for relative in stage_paths:
target = config.ROOT / relative
if target.is_file() or target.is_symlink():
target.unlink()
_prune_empty_dirs(stage)
if _tree_has_path("HEAD", stage):
_run(["git", "checkout", "HEAD", "--", stage])
def _remote_gate_warning() -> None:
if git_publish.read_allowed_push_urls() is not None:
return
console.print(
"[bold yellow]WARN[/bold yellow] No .wikitool-remotes.json in this checkout - the "
"Publish-Remote Gate is unarmed, so a future `publish` to the wrong remote would not "
"be caught. `upstream merge` never pushes and proceeds regardless, but a checkout that "
"takes stack updates from a public upstream should arm the gate before its next publish "
"- see instructions/private-instance.md step 4."
)
def _precondition_failure(remote: str) -> Optional[str]:
if _working_tree_dirty():
return (
"Working tree is not clean (`git status --porcelain` printed something). "
"`upstream merge` refuses to start on a dirty tree so a refusal never has to "
"guess which changes were already there. Commit or stash first."
)
if _merge_in_progress():
return (
"A merge is already in progress (.git/MERGE_HEAD exists). Resolve or abort it "
"(`git merge --abort`) before running `upstream merge`."
)
if not _remote_resolves(remote):
return f"Remote '{remote}' does not resolve (`git remote get-url {remote}` failed)."
return None
def _unresolved_conflict_message(unresolved: list[str], remote: str, branch: str) -> str:
listed = "\n".join(f" - {p}" for p in unresolved)
return (
f"A real conflict remains in the machinery layers after restoring the content stages "
f"and the stack-owned paths from {remote}/{branch}:\n{listed}\n\n"
"The merge is left open, uncommitted - nothing was written to the branch. Per "
"instructions/private-instance.md's decision points: this means the checkout changed "
"the stack locally, which private instances do not do. Take the upstream side for "
"these paths (`git checkout --theirs -- <path>` then `git add`) and re-file the local "
"change as an issue against the public repo, or resolve deliberately and "
"`git commit --no-edit` yourself. `git merge --abort` gives up the merge entirely."
)
def _postcheck_failure_message(leaks: list[str], before: str) -> str:
listed = "\n".join(f" - {p}" for p in leaks)
return (
f"The merge commit exists (content stages are not what they were before this ran), "
f"but it changed content outside of a stack-owned path:\n{listed}\n\n"
f"This was NOT rolled back - the state belongs in front of you, not behind an automatic "
f"repair the command applies to itself. Compare against the pre-merge commit ({before}) "
"and decide by hand whether to revert the merge commit, cherry-pick around it, or fix "
"forward. This is a bug in `upstream merge` or in `ownership.is_stack_owned` if it "
"reproduces - please report it rather than working around it silently."
)
def _merge_success_message(
changed: list[str], deleted: list[str], remote: str, branch: str
) -> str:
"""What the merge actually did, measured against the pre-merge commit
rather than against what was restored.
`changed` is the real diff - restoring every stack-owned path from
MERGE_HEAD touches each of them whether or not the upstream moved any, so
reporting the restore list would claim seven updates for a merge that
changed one file, and a reader who checks would find the report wrong.
"""
deleted_set = set(deleted)
lines = [
f"Merged {remote}/{branch}. Content stages "
f"({', '.join(ownership.CONTENT_STAGES)}) are unchanged."
]
if changed:
lines.append(f"Stack paths changed ({len(changed)}):")
lines += [
f" - {p}" + (" (deleted, following the upstream)" if p in deleted_set else "")
for p in changed
]
else:
lines.append("No stack-owned path changed.")
return "\n".join(lines)
@cli_contract.record(cli_contract.CommandRecord(
path="upstream merge",
summary="Take a stack update into a private instance's branch, machinery only.",
synopsis=(cli_contract.Variant(
usage="upstream merge [--remote upstream] [--branch main] [--no-fetch]",
),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.NO,
atomic="**No** - can leave an open, uncommitted merge behind on refusal after fetching",
budget=cli_contract.Budget.COUNTED,
network=cli_contract.Network.YES,
),
notes=(
"Refuses on a dirty working tree, a merge already in progress, or a remote that does "
"not resolve. WARNs (does not block) when `.wikitool-remotes.json` is absent, pointing "
"at the setup step that arms it.",
"Fetches `<remote>/<branch>` (unless `--no-fetch`) and reports \"already up to date\" "
"if nothing new exists.",
"Otherwise opens `git merge --no-commit --no-ff <remote>/<branch>`, and stops, "
"untouched, if git refused to open a merge at all (unrelated histories).",
"Forces every content stage (`kb/`, `raw/`, `work/`, `reports/`) back to the local "
"side by removing **only the paths tracked in either tree** and checking `HEAD`'s back "
"out - never the stage directory wholesale, so untracked and ignored local data under "
"a stage (telemetry traces, saved eval and lint reports) is never deleted.",
"Then restores from the upstream side exactly the machinery paths - `<stage>/CONTRACT.md` "
"and anything ending `.template` under a content stage - including a deletion, if the "
"upstream removed one.",
"A real conflict left in `tools/`, `types/` or `instructions/` after that leaves the "
"merge open, uncommitted, and exits 1 rather than guessing.",
"Commits with `git commit --no-edit`, then re-checks the resulting range with the "
"`upstream verify` check; a finding there is a loud error, and the merge commit is not "
"rolled back.",
"Never pushes.",
"Not idempotent, and not safe to retry unchanged.",
),
failures=(
cli_contract.Failure(
cause="Dirty working tree, or a merge already in progress",
reaction="Fix the named precondition and retry once",
),
cli_contract.Failure(
cause="The remote does not resolve, the fetch failed, or `HEAD` does not resolve",
reaction="Fix `--remote`/`--branch` or the repository state, then retry once",
),
cli_contract.Failure(
cause="git refused to open the merge at all (unrelated histories); nothing was "
"touched",
reaction="Do not retry unchanged - report it to the user",
),
cli_contract.Failure(
cause="A real conflict remains in `tools/`/`types/`/`instructions/` after the "
"content stages and stack-owned paths were restored; the merge is left open",
reaction="**Do not retry, do not force** - resolve the named paths by hand (take the "
"upstream side, or re-file the local change as an issue against the public repo per "
"`instructions/private-instance.md`) and either `git commit --no-edit` yourself or "
"`git merge --abort`",
),
cli_contract.Failure(
cause="A git step failed inside the open merge (`git checkout MERGE_HEAD -- <path>` "
"or `git commit --no-edit`)",
reaction="Do not retry unchanged - inspect the open merge by hand",
),
cli_contract.Failure(
cause="The postcheck after the commit found a leak; the merge commit already exists",
reaction="It is **not** rolled back automatically - inspect it by hand; this is a bug "
"report, not a retry",
),
),
examples=(
"tools/wikitool upstream merge",
"tools/wikitool upstream merge --remote upstream --branch main --no-fetch",
),
never=(
"Never retry a failed merge unchanged, and never force.",
),
see_also=(
"`instructions/private-instance.md` § \"Taking a stack update\" - the procedure this "
"implements",
"`wikitool upstream verify` - the same check on any revision range",
"`wikitool publish` - pushes the merge afterwards",
),
))
@app.command("merge")
def merge_command(
remote: str = typer.Option("upstream", "--remote", help="Remote to merge from"),
branch: str = typer.Option("main", "--branch", help="Branch to merge"),
no_fetch: bool = typer.Option(
False, "--no-fetch", help="Skip `git fetch <remote>` - use whatever is already fetched"
),
):
"""Merge `<remote>/<branch>` into the current branch, machinery only:
every path under a content stage (kb/, raw/, work/, reports/) is forced
back to the local side except a stack-owned path (`<stage>/CONTRACT.md`,
or anything ending `.template` under a content stage), which is taken
from the upstream - including a deletion, if the upstream removed one. A
real conflict elsewhere (tools/, types/, instructions/) leaves the merge
open and unresolved rather than guessing. Not idempotent: it can leave an
open merge behind on refusal. See instructions/private-instance.md."""
problem = _precondition_failure(remote)
if problem:
fail(problem)
return
_remote_gate_warning()
before = _rev_parse("HEAD")
if before is None:
fail("HEAD does not resolve - is this a git repository with at least one commit?")
return
if not no_fetch:
fetch_result = _run(["git", "fetch", remote, branch])
if fetch_result.returncode != 0:
fail(f"`git fetch {remote} {branch}` failed:\n{fetch_result.stderr}")
return
remote_ref = f"{remote}/{branch}"
if _rev_parse(remote_ref) is None:
fail(f"'{remote_ref}' does not resolve - fetch it first, or check --remote/--branch.")
return
if _is_ancestor(remote_ref, "HEAD"):
success(f"Already up to date with {remote_ref}.")
return
# The exit code is deliberately not the test - conflicts under the content
# stages are expected here and are exactly what the next steps undo. What
# *is* load-bearing is that a merge actually opened: without MERGE_HEAD,
# `_tree_paths("MERGE_HEAD")` is empty, and every stack-owned path in HEAD
# would then read as "the upstream deleted it" and be removed. A merge git
# refused to start (unrelated histories, an ignored file in the way) must
# therefore stop here, with the tree untouched.
merge_result = _run(["git", "merge", "--no-commit", "--no-ff", remote_ref])
if not _merge_in_progress():
fail(
f"`git merge --no-commit --no-ff {remote_ref}` did not open a merge, so there is "
f"nothing to scope - the working tree is unchanged:\n"
f"{merge_result.stdout}{merge_result.stderr}"
)
return
merge_head_paths = _tree_paths("MERGE_HEAD")
head_paths = _tree_paths("HEAD")
tracked_paths = merge_head_paths | head_paths
for stage in ownership.CONTENT_STAGES:
_restore_stage_to_local(stage, tracked_paths)
stack_paths = sorted(
p for p in (merge_head_paths | head_paths) if ownership.is_stack_owned(p)
)
# Only the deletions are recorded: what was *restored* is every stack-owned
# path in MERGE_HEAD, which is not the same question as what changed - the
# success message asks git for that instead.
deleted: list[str] = []
for relative in stack_paths:
if relative in merge_head_paths:
checkout = _run(["git", "checkout", "MERGE_HEAD", "--", relative])
if checkout.returncode != 0:
fail(
f"`git checkout MERGE_HEAD -- {relative}` failed even though it is listed "
f"in MERGE_HEAD's own tree:\n{checkout.stderr}\nThe merge is left open."
)
return
else:
_run(["git", "rm", "-q", "--cached", "--ignore-unmatch", relative])
target = config.ROOT / relative
if target.exists():
target.unlink()
deleted.append(relative)
unresolved = [p for p in _run(["git", "diff", "--name-only", "--diff-filter=U"]).stdout.splitlines() if p]
if unresolved:
fail(_unresolved_conflict_message(unresolved, remote, branch))
return
commit_result = _run(["git", "commit", "--no-edit"])
if commit_result.returncode != 0:
fail(f"`git commit --no-edit` failed:\n{commit_result.stderr}")
return
leaks = _content_leaks(before, "HEAD")
if leaks:
fail(_postcheck_failure_message(leaks, before))
return
success(
_merge_success_message(_stack_paths_changed(before, "HEAD"), deleted, remote, branch)
)
# --- upstream verify ---------------------------------------------------------
def _verify_failure_message(leaks: list[str], since: str, until: str) -> str:
listed = "\n".join(f" - {p}" for p in leaks)
return (
f"Content under a content stage (kb/, raw/, work/, reports/) changed between {since} "
f"and {until} through a path that is not stack-owned:\n{listed}\n\n"
"That is upstream content (or an equivalent local change) that reached this range "
"outside of a stack-owned path - inspect it before trusting this range as machinery-only."
)
def _verify_success_message(stack_moved: list[str], since: str, until: str) -> str:
if not stack_moved:
return f"No content changed between {since} and {until} under kb/, raw/, work/, reports/."
listed = "\n".join(f" - {p}" for p in stack_moved)
return (
f"Clean: only stack-owned paths changed under kb/, raw/, work/, reports/ between "
f"{since} and {until}:\n{listed}"
)
@cli_contract.record(cli_contract.CommandRecord(
path="upstream verify",
summary="Compare two revisions: did anything under a content stage change except through "
"a stack-owned path?",
synopsis=(cli_contract.Variant(usage="upstream verify --since <rev> [--until HEAD]"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.READ,
idempotent=cli_contract.Idempotent.YES,
atomic="Read-only",
budget=cli_contract.Budget.EXEMPT,
),
notes=(
"Compares `--since` with `--until` (default `HEAD`): did anything under a content stage "
"change except through a stack-owned path?",
"The same check `upstream merge` runs after its commit, so a hand-resolved merge "
"conflict, or a `dist upgrade`, can be verified the same way.",
"Exits 1 with the offending paths if anything leaked; otherwise reports which "
"stack-owned paths legitimately moved.",
"Read-only and exempt from the Iteration Budget Gate.",
),
failures=(
cli_contract.Failure(
cause="A leak: content changed under a content stage through a path that is not "
"stack-owned",
reaction="A finding is not fixed by re-running - it names the paths that leaked",
),
cli_contract.Failure(
cause="`--since`/`--until` is not a revision in this repository",
reaction="Fix the revision argument and retry",
),
),
examples=(
"tools/wikitool upstream verify --since HEAD~1",
"tools/wikitool upstream verify --since v7.0.0 --until HEAD",
),
never=(
"Never re-run to make a leak finding go away.",
),
see_also=(
"`wikitool upstream merge` - runs this check after its commit",
"`instructions/private-instance.md` - the private-instance workflow",
),
))
@app.command("verify")
def verify_command(
since: str = typer.Option(..., "--since", help="Git revision to compare from"),
until: str = typer.Option("HEAD", "--until", help="Git revision to compare to"),
):
"""Check that nothing under a content stage changed between --since and
--until except through a stack-owned path. Read-only, and exempt from the
Iteration Budget Gate - the same treatment `migrate verify` gets, for the
same reason: a check an agent has to ration is a check that gets skipped."""
leaks = _content_leaks(since, until)
if leaks:
fail(_verify_failure_message(leaks, since, until))
return
success(_verify_success_message(_stack_paths_changed(since, until), since, until))
+4 -1
View File
@@ -265,7 +265,10 @@ def new_command(
typer.echo(f"Run key: {run_key}")
typer.echo(f"Workshop: {rel_path(target)}/")
typer.echo(f"Next: fill in plan.md, then export WIKITOOL_SESSION_ID=\"{run_key}/u1\"")
typer.echo(
f"Next: fill in plan.md, then set WIKITOOL_SESSION_ID to {run_key}/u1 "
"(instructions/session-setup.md)"
)
success(f"Created workshop {run_key}")
+2 -2
View File
@@ -226,8 +226,8 @@ TELEMETRY_FILENAME = ".wikitool-telemetry.json"
# Which push targets `publish` may write to, for a checkout that says so. The
# danger this addresses is one checkout's content reaching another checkout's
# remote - a private instance pushing its own `kb/` to a public upstream, where
# it cannot be taken back.
# remote - a private instance pushing its own `kb/` to a public repository,
# where it cannot be taken back.
#
# It pins **URLs, not remote names**: a name-based list would pass a `publish`
# whose `origin` had been repointed, which is the failure it exists to catch.
+12 -14
View File
@@ -1,14 +1,13 @@
"""The ownership boundary for a path under a content stage: does it belong to
the *stack* (ships with every distribution, wins over local content when a
private instance merges from a public upstream) or to the *instance* (never
ships filled, wins over the upstream's version)?
the *stack* (ships with every distribution, and a release replaces it) or to
the *instance* (never ships filled, and no release touches it)?
One predicate, so `dist_cmd.py` (export) and `upstream_cmd.py` (merge/verify)
answer the same question about the same paths instead of each keeping its own
literal list that can drift out of sync with the other - see AGENTS.md
invariant 8, and Gitea #30 for the incident that made the drift concrete
(the private-instance merge procedure hardcoded a three-path list that
`dist_cmd.py` had already outgrown).
One predicate, so every caller answers the same question about the same paths
instead of keeping its own literal list that can drift out of sync - see
AGENTS.md invariant 8, and Gitea #30 for the incident that made the drift
concrete (the private-instance merge procedure, since removed with
`upstream merge` in Gitea #153, hardcoded a three-path list that `dist_cmd.py`
had already outgrown).
"""
from __future__ import annotations
@@ -21,9 +20,8 @@ from __future__ import annotations
CONTENT_STAGES = ("kb", "raw", "work", "reports")
# Bare filenames `dist export` overwrites with a fresh stub rather than
# shipping the stack's own copy. Not stack-owned: an upstream merge takes the
# *local* side for these (they are the instance's own log/placeholder),
# while `dist export` writes a brand-new one regardless of either side.
# shipping the stack's own copy. Not stack-owned: they are the instance's own
# log/placeholder, which `dist export` writes brand-new rather than copying.
EXPORT_STUB_NAMES = ("log.md", ".gitkeep")
# The single machinery filename directly under a content stage's own root.
@@ -33,7 +31,7 @@ _STAGE_CONTRACT_NAME = "CONTRACT.md"
def is_stack_owned(relative: str) -> bool:
"""Whether `relative` - a path under a content stage, e.g. "kb/CONTRACT.md"
or "kb/entities/COLLECTION.md.template" - is machinery: it ships with
every distribution, and it is the side an upstream merge keeps.
every distribution, and a release replaces it.
True for exactly two shapes:
@@ -48,7 +46,7 @@ def is_stack_owned(relative: str) -> bool:
False for everything else under a content stage, `EXPORT_STUB_NAMES`
included - those are handled separately by whichever caller cares about
them, because the two callers disagree about which side wins for a stub.
them.
"""
parts = relative.split("/")
if len(parts) < 2 or parts[0] not in CONTENT_STAGES:
+1 -2
View File
@@ -254,7 +254,7 @@ def test_top_level_help_is_the_index_without_frames(monkeypatch):
line.split(" ")[0].strip() for line in text.splitlines() if " non-idempotent " in line
}
assert {
"new", "log append", "publish", "upstream merge",
"new", "log append", "publish",
"version bump", "version release", "migrate done",
} <= non_idempotent
@@ -344,7 +344,6 @@ def test_network_yes_is_exactly_the_commands_that_can_reach_outside_this_checkou
expected = {
"sync",
"publish",
"upstream merge",
"version check",
"version notes",
"dist upgrade",
+61
View File
@@ -600,3 +600,64 @@ def test_validate_markers_rejects_nested_starts():
dist_cmd._validate_markers(
"<!-- dist:strip-start --><!-- dist:strip-start --><!-- dist:strip-end -->", "x"
)
# --- dist adopt ----------------------------------------------------------------
@pytest.fixture
def instance(repo):
"""A tree in the shape `dist export` leaves behind: the collection contract
and the page type-spec with its schema only as `.template`."""
(repo / "kb" / "entities" / "COLLECTION.md").rename(
repo / "kb" / "entities" / "COLLECTION.md.template"
)
for name in ("entity.md", "entity.schema.yaml"):
(repo / "types" / name).rename(repo / "types" / f"{name}.template")
return repo
def test_adopt_without_a_path_copies_every_collection_and_type_template(instance):
dist_cmd.run_adopt([])
for relative in ("kb/entities/COLLECTION.md", "types/entity.md", "types/entity.schema.yaml"):
adopted = instance / relative
template = instance / f"{relative}.template"
assert adopted.read_bytes() == template.read_bytes()
# Not in scope: the conventions template carries a sentinel and is filled, not copied.
assert (instance / "kb" / "CONVENTIONS.md").read_text(encoding="utf-8").startswith("---")
def test_adopt_never_overwrites_an_existing_target(instance):
own = instance / "types" / "entity.md"
own.write_text("# this instance's own entity\n", encoding="utf-8")
dist_cmd.run_adopt([])
assert own.read_text(encoding="utf-8") == "# this instance's own entity\n"
assert (instance / "types" / "entity.schema.yaml").exists()
def test_adopt_with_paths_copies_only_those(instance):
dist_cmd.run_adopt([Path("types/entity.md.template")])
assert (instance / "types" / "entity.md").exists()
assert not (instance / "types" / "entity.schema.yaml").exists()
assert not (instance / "kb" / "entities" / "COLLECTION.md").exists()
@pytest.mark.parametrize(
"given", ["kb/CONVENTIONS.md.template", "USER.md.template", "types/missing.md.template"]
)
def test_adopt_refuses_a_path_outside_its_set(instance, given):
with pytest.raises(typer.Exit):
dist_cmd.run_adopt([Path(given)])
assert not (instance / "kb" / "entities" / "COLLECTION.md").exists()
def test_adopt_dry_run_writes_nothing(instance):
dist_cmd.run_adopt([], dry_run=True)
assert not (instance / "types" / "entity.md").exists()
assert not (instance / "kb" / "entities" / "COLLECTION.md").exists()
@@ -0,0 +1,121 @@
"""The stack's shipped instructions read the same in bash, Git Bash and PowerShell 7.
`instructions/CONTRACT.md` § Writing an instruction states the rule; this is the check behind it.
An instruction is run by whichever harness the operator uses - Claude Code under Git Bash on
Windows, Copilot under PowerShell 7 - so a command block that only one shell reads sends the
other agent off to translate it, which is where the Weg-D install went wrong (Gitea #140, #153).
Scope: every instruction `dist export` ships, minus `instructions/migrations/`, which belong to
the release they shipped with. A test rather than an `instructions verify` rule because the rule
binds what the stack ships; an instance's own instructions are its own decision.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[3]
INSTRUCTIONS = REPO / "instructions"
# The info strings of a block an agent runs; a json/yaml/markdown block is content, not a command.
COMMAND_FENCES = {"", "bash", "sh", "shell", "console", "powershell", "pwsh"}
FORBIDDEN = (
("heredoc", re.compile(r"<<")),
("export", re.compile(r"(^|[;&|]\s*)export\s")),
("command substitution", re.compile(r"\$\(")),
("shell variable", re.compile(r"\$\{|\$[A-Z_][A-Z0-9_]*\b")),
("inline environment", re.compile(r"^[A-Z_][A-Z0-9_]*=\S*\s+\S")),
("&&", re.compile(r"&&")),
("for loop", re.compile(r"^\s*for\s.*;\s*do\b")),
("cp", re.compile(r"(^|[;&|]\s*)cp\s")),
("cat >", re.compile(r"\bcat\s+>")),
("sha256sum", re.compile(r"\bsha256sum\b")),
("curl", re.compile(r"\bcurl\b")),
("tar", re.compile(r"(^|[;&|]\s*)tar\s")),
)
# The two sanctioned exceptions, each one line per shell (instructions/CONTRACT.md): the session
# id (D26) and the preflight download before an instance exists (E2). Keyed by exact line, so a
# second use of the same construct elsewhere still fails.
ALLOWED = {
("session-setup.md", 'export WIKITOOL_SESSION_ID="wiki-20261001-1430"'),
("session-setup.md", "$env:WIKITOOL_SESSION_ID = 'wiki-20261001-1430'"),
("setup-instance.md", "curl -fLO <browser_download_url of preflight.sh>"),
}
def shipped_instructions() -> list[Path]:
return sorted(
path
for path in INSTRUCTIONS.rglob("*.md")
if not {"dev", "migrations"} & set(path.relative_to(INSTRUCTIONS).parts[:-1])
)
def command_lines(path: Path):
"""`(line number, line)` for every line inside a command fence."""
fence = None
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
stripped = line.strip()
if stripped.startswith("```"):
fence = None if fence is not None else stripped[3:].strip()
continue
if fence is not None and fence in COMMAND_FENCES and stripped:
yield number, stripped
def test_the_scope_is_not_empty():
names = {path.name for path in shipped_instructions()}
assert {"setup-instance.md", "session-setup.md", "upgrade-instance.md"} <= names
assert not any("migrations" in path.parts for path in shipped_instructions())
@pytest.mark.parametrize("path", shipped_instructions(), ids=lambda p: str(p.relative_to(INSTRUCTIONS)))
def test_a_shipped_instruction_uses_no_shell_specific_syntax(path):
found = [
f"{path.relative_to(REPO)}:{number}: {name}: {line}"
for number, line in command_lines(path)
if (path.name, line) not in ALLOWED
for name, pattern in FORBIDDEN
if pattern.search(line)
]
assert not found, "\n".join(found)
def test_every_exception_is_still_in_use():
"""An allowance nobody uses any more is a gap waiting for the next construct."""
used = {
(path.name, line)
for path in shipped_instructions()
for _, line in command_lines(path)
}
assert ALLOWED <= used, ALLOWED - used
SHIPPED_DOCS = ("AGENTS.md", "README.md", "INSTALL.md", "EVALS.md")
# A line that starts the PowerShell preflight - bare (`.\tools\preflight.ps1`, `& preflight.ps1`)
# or through pwsh - as opposed to one that merely names the file (a comment, a download).
PREFLIGHT_CALL = re.compile(r"^(?:&\s*)?(?:\.[\\/])?(?:tools[\\/])?preflight\.ps1\b|^pwsh\b.*preflight\.ps1")
BYPASS = "pwsh -NoProfile -ExecutionPolicy Bypass -File "
@pytest.mark.parametrize(
"path",
[REPO / name for name in SHIPPED_DOCS] + shipped_instructions(),
ids=lambda p: str(p.relative_to(REPO)),
)
def test_every_powershell_preflight_call_carries_the_bypass(path):
"""Copilot started `.\\tools\\preflight.ps1` bare in the #151 hand check; in a checkout that
carries a Mark of the Web that call fails with PowerShell's own refusal and no guidance."""
if not path.is_file():
pytest.skip(f"{path.name} is not in this tree")
bare = [
f"{number}: {line}"
for number, line in command_lines(path)
if PREFLIGHT_CALL.match(line) and not line.startswith(BYPASS)
]
assert not bare, "\n".join(bare)
+42 -21
View File
@@ -421,29 +421,29 @@ def build_release(base: Path, *, limit: int | None = None, tops: tuple[str, ...]
class AssetMachine(Machine):
"""The release asset on a machine with nothing unpacked yet: just the script, alone in a folder."""
"""The release asset on a machine with nothing unpacked yet: just the script, alone in
the folder the wiki is to be installed in."""
def __init__(self, base: Path, *, filled: bool = False, **release):
def __init__(self, base: Path, *, filled: bool = False, script: str = "preflight.sh", **release):
super().__init__(base)
self.tarball = build_release(base, **release)
self.download = base / "download"
self.download.mkdir()
for name, pairs in PLACEHOLDERS.items():
text = (TOOLS / name).read_text(encoding="utf-8")
for empty, full in pairs:
assert empty in text, f"{name} lost its placeholder {empty}"
if filled:
text = text.replace(empty, full)
(self.download / name).write_text(text, encoding="utf-8")
self.script = self.download / "preflight.sh"
self.root = self.download / "chemenu"
text = (TOOLS / script).read_text(encoding="utf-8")
for empty, full in PLACEHOLDERS[script]:
assert empty in text, f"{script} lost its placeholder {empty}"
if filled:
text = text.replace(empty, full)
self.script = self.download / script
self.script.write_text(text, encoding="utf-8")
self.root = self.download
self.tools = self.root / "tools"
self.cwd = self.download
self.extra_env.update({"RELEASE_DIR": str(self.tarball.parent), "CURL_LOG": str(base / "curl.log")})
self.standard()
def unpacked(self, root: Path | None = None) -> bool:
return (root or self.root).exists()
return ((root or self.root) / "tools").exists()
def leftovers(self) -> list[str]:
found = [str(path) for path in self.base.rglob(".chemenu-unpack.*")]
@@ -457,16 +457,28 @@ def asset(tmp_path: Path) -> AssetMachine:
@pytest.mark.parametrize("shell", SHELLS)
def test_asset_unpacks_next_to_itself_and_runs_the_tree_copy(asset, shell):
def test_asset_unpacks_into_its_own_folder_and_runs_the_tree_copy(asset, shell):
result = asset.run("--archive", str(asset.tarball), shell=shell)
assert result.returncode == 0, result.stdout + result.stderr
assert "sha256 OK" in result.stdout and "Preflight passed" in result.stdout
assert (asset.tools / "preflight.sh").is_file() and (asset.tools / "prerequisites.txt").is_file()
assert asset.recorded()["complete"] is True
assert (asset.tools / ".venv").is_dir()
# The asset itself is gone, so the first commit holds the stack and nothing else.
assert not asset.script.exists()
assert not asset.leftovers()
@pytest.mark.parametrize("shell", SHELLS)
def test_an_empty_clone_is_an_empty_folder(asset, shell):
(asset.root / ".git").mkdir()
(asset.root / ".git" / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
result = asset.run("--archive", str(asset.tarball), shell=shell)
assert result.returncode == 0, result.stdout + result.stderr
assert (asset.root / ".git" / "HEAD").read_text(encoding="utf-8") == "ref: refs/heads/main\n"
assert (asset.tools / "preflight.sh").is_file()
@pytest.mark.parametrize("shell", SHELLS)
def test_into_chooses_the_target_and_creates_missing_parents(asset, shell):
target = asset.base / "deep" / "er" / "wiki"
@@ -475,6 +487,8 @@ def test_into_chooses_the_target_and_creates_missing_parents(asset, shell):
assert (target / "tools" / "preflight.sh").is_file()
assert (target / ".wikitool-tools.json").is_file()
assert not asset.unpacked()
# Not in the target, so not the instance's to keep out of a commit.
assert asset.script.exists()
@pytest.mark.parametrize("shell", SHELLS)
@@ -486,13 +500,18 @@ def test_a_relative_into_resolves_against_the_working_directory(asset, shell):
@pytest.mark.parametrize("shell", SHELLS)
def test_an_existing_target_is_refused_and_left_alone(asset, shell):
asset.root.mkdir()
(asset.root / "mine.txt").write_text("keep", encoding="utf-8")
result = asset.run("--archive", str(asset.tarball), shell=shell)
@pytest.mark.parametrize("into", [False, True])
def test_an_occupied_target_is_refused_and_left_alone(asset, shell, into):
target = asset.base / "occupied" if into else asset.root
target.mkdir(exist_ok=True)
(target / "mine.txt").write_text("keep", encoding="utf-8")
before = sorted(path.name for path in target.iterdir())
args = ("--into", str(target)) if into else ()
result = asset.run("--archive", str(asset.tarball), *args, shell=shell)
assert result.returncode == 1
assert "already exists" in result.stderr and "--into" in result.stderr
assert [path.name for path in asset.root.iterdir()] == ["mine.txt"]
assert "is not empty" in result.stderr and "--into" in result.stderr
assert sorted(path.name for path in target.iterdir()) == before
assert asset.script.exists()
assert not asset.leftovers()
@@ -618,7 +637,8 @@ def test_the_folder_limit_is_judged_at_the_final_target_from_the_archive(
result = asset.run("--archive", str(asset.tarball), "--into", str(target), shell=shell)
assert result.returncode == expected, result.stdout + result.stderr
if expected == 42:
assert_guidance(result.stdout, f"too long ({length} characters, at most {limit or 95})")
assert_guidance(result.stdout, f"too long ({length} characters, at most {limit or 95})",
"such as C:\\Chemenu - put this script there", "--into C:\\Chemenu.")
assert not asset.unpacked(target) and not asset.leftovers()
else:
assert (target / "tools" / "preflight.sh").is_file()
@@ -643,7 +663,8 @@ def test_the_release_workflow_fills_exactly_the_placeholders_the_scripts_keep():
expression = f"s|^{as_written_in_the_workflow(empty)}|{as_written_in_the_workflow(filled)}|"
assert expression in text, f"release.yml does not run {expression}"
assert 'download="${PUBLIC_BASE_URL}/${GITHUB_REPOSITORY}/releases/download/${TAG}"' in text
assert 'for asset in "${NAME}.tar.gz" "${NAME}.tar.gz.sha256" preflight.sh preflight.ps1; do' in text
assert ('for asset in "${NAME}.tar.gz" "${NAME}.tar.gz.sha256" preflight.sh preflight.ps1 '
'setup-instance.md preflight.md; do') in text
# --- the launcher -----------------------------------------------------------------
+21 -12
View File
@@ -216,41 +216,49 @@ def _asset_run(asset: AssetMachine, *args: str) -> subprocess.CompletedProcess:
**asset.extra_env,
}
return subprocess.run(
[PWSH, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(asset.download / "preflight.ps1"), *args],
[PWSH, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(asset.script), *args],
capture_output=True, text=True, env=env, timeout=120, cwd=asset.cwd,
)
@pytest.fixture
def asset(tmp_path: Path) -> AssetMachine:
return AssetMachine(tmp_path.resolve())
return AssetMachine(tmp_path.resolve(), script="preflight.ps1")
def test_asset_unpacks_next_to_itself_and_runs_the_tree_copy(asset):
def test_asset_unpacks_into_its_own_folder_and_runs_the_tree_copy(asset):
result = _asset_run(asset, "--archive", str(asset.tarball))
assert result.returncode == 0, result.stdout + result.stderr
assert "sha256 OK" in result.stdout and "Preflight passed" in result.stdout
assert (asset.tools / "preflight.ps1").is_file()
assert asset.recorded()["complete"] is True
assert not asset.script.exists()
assert not asset.leftovers()
def test_an_empty_clone_is_an_empty_folder(asset):
(asset.root / ".git").mkdir()
result = _asset_run(asset, "--archive", str(asset.tarball))
assert result.returncode == 0, result.stdout + result.stderr
assert (asset.root / ".git").is_dir() and (asset.tools / "preflight.ps1").is_file()
def test_into_chooses_the_target_and_a_relative_one_follows_the_working_directory(asset):
far = asset.base / "deep" / "er" / "wiki"
assert _asset_run(asset, "--archive", str(asset.tarball), "--into", str(far)).returncode == 0
assert (far / "tools" / "preflight.ps1").is_file() and not asset.unpacked()
assert asset.script.exists()
asset.cwd = asset.base
assert _asset_run(asset, "--archive", str(asset.tarball), "--into", "here").returncode == 0
assert (asset.base / "here" / "tools" / "preflight.ps1").is_file()
def test_an_existing_target_is_refused_and_left_alone(asset):
asset.root.mkdir()
def test_an_occupied_target_is_refused_and_left_alone(asset):
(asset.root / "mine.txt").write_text("keep", encoding="utf-8")
result = _asset_run(asset, "--archive", str(asset.tarball))
assert result.returncode == 1
assert "already exists" in result.stderr and "--into" in result.stderr
assert [path.name for path in asset.root.iterdir()] == ["mine.txt"]
assert "is not empty" in result.stderr and "--into" in result.stderr
assert sorted(path.name for path in asset.root.iterdir()) == ["mine.txt", "preflight.ps1"]
def test_a_wrong_sha256_exits_1_and_unpacks_nothing(asset):
@@ -270,7 +278,7 @@ def test_archive_without_a_checksum_file_exits_1(asset):
def test_an_archive_with_two_top_level_folders_exits_1(tmp_path):
asset = AssetMachine(tmp_path.resolve(), tops=("chemenu-stack-9.9.9", "stray"))
asset = AssetMachine(tmp_path.resolve(), script="preflight.ps1", tops=("chemenu-stack-9.9.9", "stray"))
result = _asset_run(asset, "--archive", str(asset.tarball))
assert result.returncode == 1 and "exactly one top-level folder" in result.stderr
assert not asset.unpacked() and not asset.leftovers()
@@ -296,7 +304,7 @@ def test_set_and_the_exit_code_pass_through_to_the_tree_copy(asset):
assert_guidance(refused.stdout, "The path given for ripgrep (rg) does not work")
assert (asset.tools / "preflight.ps1").is_file() and not asset.tools_file.exists()
second = AssetMachine(asset.base / "second")
second = AssetMachine(asset.base / "second", script="preflight.ps1")
elsewhere = second.stub("rg", "#!/bin/sh\necho 'ripgrep 14.1.1'\n", asset.base / "opt")
ok = _asset_run(second, "--archive", str(second.tarball), "--set", f"rg={elsewhere}")
assert ok.returncode == 0, ok.stdout
@@ -321,14 +329,15 @@ def test_the_folder_limit_is_judged_at_the_final_target_from_the_archive(
base = tmp_path.resolve()
name_length = length - len(str(base / "download")) - 1
assert name_length > 0, "tmp_path is too long for this test"
asset = AssetMachine(base, limit=limit)
asset = AssetMachine(base, script="preflight.ps1", limit=limit)
asset.standard(pwsh=True)
asset.extra_env.update({"CHEMENU_PREFLIGHT_PLATFORM": "windows", "CHEMENU_PREFLIGHT_LONGPATHS": longpaths})
target = asset.download / ("t" * name_length)
result = _asset_run(asset, "--archive", str(asset.tarball), "--into", str(target))
assert result.returncode == expected, result.stdout + result.stderr
if expected == 42:
assert_guidance(result.stdout, f"too long ({length} characters, at most {limit or 95})")
assert_guidance(result.stdout, f"too long ({length} characters, at most {limit or 95})",
"such as C:\\Chemenu - put this script there", "--into C:\\Chemenu.")
assert not asset.unpacked(target) and not asset.leftovers()
else:
assert (target / "tools" / "preflight.ps1").is_file()
@@ -340,7 +349,7 @@ def release_server(asset):
handler.log_message = lambda *args: None
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
script = asset.download / "preflight.ps1"
script = asset.script
script.write_text(
(TOOLS / "preflight.ps1").read_text(encoding="utf-8").replace(
"$ReleaseArchiveUrl = \'\'", f"$ReleaseArchiveUrl = \'http://127.0.0.1:{server.server_port}/{asset.tarball.name}\'").replace(
+1 -1
View File
@@ -265,7 +265,7 @@ def test_exempt_budget_matches_the_pre_121_skip_sets():
"search", "doctor", "review", # old SKIP_COMMANDS
"budget status", "eval score", "eval sessions", "cite id", "links show",
"version show", "version check", "version notes",
"migrate list", "migrate status", "migrate verify", "upstream verify",
"migrate list", "migrate status", "migrate verify",
}
actual = {
path
-470
View File
@@ -1,470 +0,0 @@
"""Tests for `wikitool upstream merge`/`upstream verify` - the code procedure
that replaces private-instance.md's prose merge script (Gitea #30).
Two real git repos stand in for a private instance (`repo`, remote name
`upstream`) and the public repo it takes updates from (`upstream`, a plain
repo committed to directly - a fetch-only remote does not need to be bare for
`git fetch` to work against it). Each scenario diverges the two by committing
independently on each side, exactly like a real fetch-only upstream would.
"""
from __future__ import annotations
import subprocess
import pytest
import typer
from chemenu import config, ownership
from chemenu.commands import git_publish, upstream_cmd
def _git(root, *args):
result = subprocess.run(["git", *args], cwd=root, capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return result
def _write(root, relative, content):
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
def _commit(root, message):
_git(root, "add", "-A")
_git(root, "commit", "-m", message)
@pytest.fixture
def two_repos(tmp_path, monkeypatch):
"""`repo`, a private instance, with a fetch-only `upstream` remote pointing
at a second, independent repo. Both start from the same seed commit -
kb/CONTRACT.md, kb/CONVENTIONS.md(.template), kb/entities/COLLECTION.md,
raw/CONTRACT.md, work/CONTRACT.md, reports/CONTRACT.md, and one tools/
file - which is what a private instance looks like right after the
private-instance.md setup: the tracked machinery, plus its own filled
instance files layered on top.
"""
seed = tmp_path / "seed"
seed.mkdir()
_git(seed, "init", "-b", "main")
_git(seed, "config", "user.name", "Seed")
_git(seed, "config", "user.email", "seed@example.com")
# .wikitool-remotes.json is gitignored in the real repo (it is per-checkout,
# see config.PUBLISH_REMOTES_FILENAME) - without this, dropping one into the
# fixture during a test would show up as an untracked file and trip the
# dirty-working-tree precondition for a reason that has nothing to do with
# what that test is checking.
# Mirrors the real .gitignore in the two ways that matter here:
# `.wikitool-remotes.json` is per-checkout (dropping one in during a test
# must not read as a dirty tree), and `reports/` is derived output that is
# ignored except for its contract - which is what makes a content stage
# able to hold local, non-recomputable data a merge must not touch.
_write(
seed,
".gitignore",
f"/{config.PUBLISH_REMOTES_FILENAME}\n/reports/*\n!/reports/CONTRACT.md\n",
)
_write(seed, "kb/CONTRACT.md", "stack kb contract v1\n")
_write(seed, "kb/CONVENTIONS.md.template", "template v1\n")
_write(seed, "kb/CONVENTIONS.md", "instance conventions v1\n")
_write(seed, "kb/entities/COLLECTION.md", "instance collection contract v1\n")
_write(seed, "kb/Both.md", "page both sides delete\n")
_write(seed, "kb/ToDelete.md", "page the instance will delete\n")
_write(seed, "kb/RegularPage.md", "an ordinary page neither side has touched yet\n")
_write(seed, "raw/CONTRACT.md", "raw contract v1\n")
_write(seed, "work/CONTRACT.md", "work contract v1\n")
_write(seed, "reports/CONTRACT.md", "reports contract v1\n")
_write(seed, "tools/wikitool.py", "line one\nline two\nline three\n")
_commit(seed, "seed")
upstream = tmp_path / "upstream"
subprocess.run(["git", "clone", str(seed), str(upstream)], check=True, capture_output=True)
_git(upstream, "config", "user.name", "Upstream")
_git(upstream, "config", "user.email", "upstream@example.com")
# Cloned from `upstream`, not from `seed` directly: the remote (renamed
# below) must resolve to the path this fixture actually commits new
# upstream state into, or a later `git fetch upstream main` silently
# fetches from `seed` instead and never sees anything new.
repo = tmp_path / "repo"
subprocess.run(["git", "clone", str(upstream), str(repo)], check=True, capture_output=True)
_git(repo, "config", "user.name", "Test")
_git(repo, "config", "user.email", "test@example.com")
_git(repo, "remote", "rename", "origin", "upstream")
monkeypatch.setattr(config, "ROOT", repo)
monkeypatch.setenv("WIKITOOL_SESSION_ID", "test-session")
return upstream, repo
def _merge(**overrides):
kwargs = dict(remote="upstream", branch="main", no_fetch=False)
kwargs.update(overrides)
upstream_cmd.merge_command(**kwargs)
# --- the four restbefund regressions, plus the baseline table from the issue ---
def test_upstream_edit_of_a_page_the_instance_deleted_does_not_land(two_repos):
upstream, repo = two_repos
_git(repo, "rm", "-q", "kb/ToDelete.md")
_commit(repo, "instance deletes ToDelete")
_write(upstream, "kb/ToDelete.md", "upstream edited it after the instance deleted it\n")
_commit(upstream, "upstream edits ToDelete")
_merge()
assert not (repo / "kb/ToDelete.md").exists()
def test_upstream_new_page_does_not_land(two_repos):
upstream, repo = two_repos
_write(upstream, "kb/NewPage.md", "a demo page the upstream added\n")
_commit(upstream, "upstream adds NewPage")
_merge()
assert not (repo / "kb/NewPage.md").exists()
def test_page_deleted_on_both_sides_is_a_noop(two_repos):
upstream, repo = two_repos
_git(repo, "rm", "-q", "kb/Both.md")
_commit(repo, "instance deletes Both")
_git(upstream, "rm", "-q", "kb/Both.md")
_commit(upstream, "upstream deletes Both")
_merge() # must not raise
assert not (repo / "kb/Both.md").exists()
def test_kb_contract_change_lands(two_repos):
upstream, repo = two_repos
_write(upstream, "kb/CONTRACT.md", "stack kb contract v2\n")
_commit(upstream, "upstream changes kb/CONTRACT.md")
_merge()
assert (repo / "kb/CONTRACT.md").read_text(encoding="utf-8") == "stack kb contract v2\n"
def test_conventions_template_change_lands_local_conventions_untouched(two_repos):
upstream, repo = two_repos
_write(upstream, "kb/CONVENTIONS.md.template", "template v2\n")
_commit(upstream, "upstream changes the conventions template")
_merge()
assert (repo / "kb/CONVENTIONS.md.template").read_text(encoding="utf-8") == "template v2\n"
assert (repo / "kb/CONVENTIONS.md").read_text(encoding="utf-8") == "instance conventions v1\n"
def test_collection_contract_change_does_not_land(two_repos):
"""A COLLECTION.md is instance-owned since #39 - one level deeper than
`<stage>/CONTRACT.md`, so `is_stack_owned` must say no to it."""
upstream, repo = two_repos
_write(repo, "kb/entities/COLLECTION.md", "instance collection contract v2 (local)\n")
_commit(repo, "instance rewrites its own collection contract")
_write(upstream, "kb/entities/COLLECTION.md", "upstream collection contract v2\n")
_commit(upstream, "upstream changes the default collection contract")
_merge()
assert (repo / "kb/entities/COLLECTION.md").read_text(encoding="utf-8") == (
"instance collection contract v2 (local)\n"
)
def test_upstream_deletion_of_a_contract_file_lands(two_repos):
"""Restbefund 2: a machinery file the upstream deleted must not silently
survive because `git checkout MERGE_HEAD -- <path>` has nothing to check
out."""
upstream, repo = two_repos
_git(upstream, "rm", "-q", "raw/CONTRACT.md")
_commit(upstream, "upstream drops raw/CONTRACT.md")
_merge()
assert not (repo / "raw/CONTRACT.md").exists()
def test_new_stack_template_under_a_content_stage_lands(two_repos):
"""Restbefund 4: a brand-new stack-owned path the local tree has never
seen must still be recognised by the predicate, not by a literal list."""
upstream, repo = two_repos
_write(upstream, "kb/GLOSSARY.md.template", "a stack-owned template that never existed before\n")
_commit(upstream, "upstream adds a new template")
_merge()
assert (repo / "kb/GLOSSARY.md.template").read_text(encoding="utf-8") == (
"a stack-owned template that never existed before\n"
)
def test_open_workshop_run_files_do_not_land(two_repos):
upstream, repo = two_repos
_write(upstream, "work/some-run/README.md", "an in-progress workshop run\n")
_commit(upstream, "upstream ships an open work/ run")
_merge()
assert not (repo / "work/some-run").exists()
def test_merge_keeps_ignored_local_data_under_a_content_stage(two_repos):
"""`reports/` is gitignored except its contract, so a content stage's
working tree holds local data that is in no git tree and is not
recomputable - the telemetry traces `eval score` reads, saved eval
reports, past lint reports. Forcing the stage back to the local side must
not take those out as collateral: this instance had 497 trace directories
under reports/telemetry/ when the first version of this command wiped the
stage wholesale."""
upstream, repo = two_repos
_write(repo, "reports/telemetry/session-a/trace.jsonl", '{"event": "local"}\n')
_write(repo, "reports/Lint Report 2026-09-04.md", "a local lint report\n")
assert _git(repo, "status", "--porcelain").stdout == "" # ignored, so the tree is clean
_write(upstream, "kb/CONTRACT.md", "stack kb contract v2\n")
_commit(upstream, "upstream changes kb/CONTRACT.md")
_merge()
assert (repo / "reports/telemetry/session-a/trace.jsonl").read_text(encoding="utf-8") == (
'{"event": "local"}\n'
)
assert (repo / "reports/Lint Report 2026-09-04.md").exists()
assert (repo / "reports/CONTRACT.md").read_text(encoding="utf-8") == "reports contract v1\n"
def test_upstream_content_under_a_stage_absent_from_head_does_not_land(two_repos):
"""The stage guard must not rest on the local side happening to track
something under that stage: an instance whose `work/` holds no tracked
file at all must still not receive the upstream's open run."""
upstream, repo = two_repos
_git(repo, "rm", "-q", "work/CONTRACT.md")
_commit(repo, "instance has nothing tracked under work/")
_write(upstream, "work/some-run/README.md", "an in-progress workshop run\n")
_commit(upstream, "upstream ships an open work/ run")
_merge()
assert not (repo / "work/some-run").exists()
def test_one_upstream_commit_mixing_every_case_at_once(two_repos):
"""The acceptance test from the issue: a single upstream commit that edits
a page the instance deleted, adds a new page, deletes an untouched page,
changes a stack contract, changes a template, and deletes a different
stack contract - all at once, all restored or discarded correctly by one
`upstream merge` call."""
upstream, repo = two_repos
_git(repo, "rm", "-q", "kb/ToDelete.md")
_commit(repo, "instance deletes ToDelete")
_write(upstream, "kb/ToDelete.md", "upstream edited it after the instance deleted it\n")
_write(upstream, "kb/BrandNewPage.md", "a demo page the upstream added\n")
_git(upstream, "rm", "-q", "kb/RegularPage.md")
_write(upstream, "kb/CONTRACT.md", "stack kb contract v2\n")
_write(upstream, "kb/CONVENTIONS.md.template", "template v2\n")
_git(upstream, "rm", "-q", "raw/CONTRACT.md")
_commit(upstream, "one upstream commit: edit + add + delete + contract + template + contract-delete")
_merge()
assert not (repo / "kb/ToDelete.md").exists()
assert not (repo / "kb/BrandNewPage.md").exists()
assert (repo / "kb/RegularPage.md").exists()
assert (repo / "kb/CONTRACT.md").read_text(encoding="utf-8") == "stack kb contract v2\n"
assert (repo / "kb/CONVENTIONS.md.template").read_text(encoding="utf-8") == "template v2\n"
assert (repo / "kb/CONVENTIONS.md").read_text(encoding="utf-8") == "instance conventions v1\n"
assert not (repo / "raw/CONTRACT.md").exists()
def test_real_conflict_in_tools_leaves_the_merge_open(two_repos):
upstream, repo = two_repos
_write(repo, "tools/wikitool.py", "line one\nLOCAL CHANGE\nline three\n")
_commit(repo, "local edits tools/wikitool.py")
before = _git(repo, "rev-parse", "HEAD").stdout.strip()
_write(upstream, "tools/wikitool.py", "line one\nUPSTREAM CHANGE\nline three\n")
_commit(upstream, "upstream edits the same line")
with pytest.raises(typer.Exit) as excinfo:
_merge()
assert excinfo.value.exit_code == 1
assert _git(repo, "rev-parse", "HEAD").stdout.strip() == before
assert (repo / ".git" / "MERGE_HEAD").exists()
def test_a_merge_git_refuses_to_open_deletes_nothing(two_repos, tmp_path):
"""The failure mode with the worst blast radius if it is not guarded:
without MERGE_HEAD, every stack-owned path in HEAD reads as "the upstream
deleted it", and the restore loop would remove kb/CONTRACT.md,
raw/CONTRACT.md and every template. A merge git refuses to start must stop
before that, with the tree untouched."""
upstream, repo = two_repos
unrelated = tmp_path / "unrelated"
unrelated.mkdir()
_git(unrelated, "init", "-b", "main")
_git(unrelated, "config", "user.name", "Unrelated")
_git(unrelated, "config", "user.email", "unrelated@example.com")
_write(unrelated, "somefile.md", "no shared history with the instance\n")
_commit(unrelated, "unrelated root commit")
_git(repo, "remote", "set-url", "upstream", str(unrelated))
before = _git(repo, "rev-parse", "HEAD").stdout.strip()
with pytest.raises(typer.Exit) as excinfo:
_merge()
assert excinfo.value.exit_code == 1
assert _git(repo, "rev-parse", "HEAD").stdout.strip() == before
assert (repo / "kb/CONTRACT.md").read_text(encoding="utf-8") == "stack kb contract v1\n"
assert (repo / "raw/CONTRACT.md").exists()
assert (repo / "kb/CONVENTIONS.md.template").exists()
assert _git(repo, "status", "--porcelain").stdout == ""
def test_success_message_reports_what_changed_not_what_was_restored(two_repos, capsys):
"""Restoring every stack-owned path from MERGE_HEAD touches all of them
whether or not the upstream moved any, so the report has to ask git what
changed - otherwise a one-file update is announced as five."""
upstream, repo = two_repos
_write(upstream, "kb/CONTRACT.md", "stack kb contract v2\n")
_commit(upstream, "upstream changes exactly one stack path")
_merge()
out = capsys.readouterr().out
assert "Stack paths changed (1)" in out
assert "kb/CONTRACT.md" in out
assert "kb/CONVENTIONS.md.template" not in out
def test_dirty_working_tree_is_refused_untouched(two_repos):
upstream, repo = two_repos
before = _git(repo, "rev-parse", "HEAD").stdout.strip()
(repo / "kb/CONTRACT.md").write_text("uncommitted local edit\n", encoding="utf-8")
_write(upstream, "kb/CONTRACT.md", "stack kb contract v2\n")
_commit(upstream, "upstream changes kb/CONTRACT.md")
with pytest.raises(typer.Exit) as excinfo:
_merge()
assert excinfo.value.exit_code == 1
assert _git(repo, "rev-parse", "HEAD").stdout.strip() == before
assert (repo / "kb/CONTRACT.md").read_text(encoding="utf-8") == "uncommitted local edit\n"
def test_already_up_to_date_is_a_noop(two_repos):
upstream, repo = two_repos
before = _git(repo, "rev-parse", "HEAD").stdout.strip()
_merge() # nothing new upstream at all
assert _git(repo, "rev-parse", "HEAD").stdout.strip() == before
def test_merge_warns_when_the_publish_remote_gate_is_unarmed(two_repos, capsys):
upstream, repo = two_repos
assert git_publish.read_allowed_push_urls() is None # no .wikitool-remotes.json in this repo
_write(upstream, "kb/CONTRACT.md", "stack kb contract v2\n")
_commit(upstream, "upstream changes kb/CONTRACT.md")
_merge()
captured = capsys.readouterr()
assert "WARN" in captured.out
assert ".wikitool-remotes.json" in captured.out
def test_merge_stays_silent_when_the_publish_remote_gate_is_armed(two_repos, capsys):
upstream, repo = two_repos
(repo / config.PUBLISH_REMOTES_FILENAME).write_text(
'{"schema": 1, "allowed_push_urls": ["ssh://example/test.git"]}\n', encoding="utf-8"
)
_write(upstream, "kb/CONTRACT.md", "stack kb contract v2\n")
_commit(upstream, "upstream changes kb/CONTRACT.md")
_merge()
captured = capsys.readouterr()
assert "WARN" not in captured.out
def test_dist_cmd_contract_only_stages_agree_with_ownership(two_repos):
"""Consistency guard for the ownership refactor: `dist_cmd`'s own list of
stage-contract paths and `ownership.is_stack_owned` must not be able to
name a different set of stages - both are sourced from
`ownership.CONTENT_STAGES` now, so a stage added to one and not the other
fails this rather than only surfacing in a real merge."""
from chemenu.commands import dist_cmd
assert dist_cmd.CONTRACT_ONLY_STAGES # sanity: the derivation still yields entries
for relative in dist_cmd.CONTRACT_ONLY_STAGES:
assert ownership.is_stack_owned(relative)
# --- upstream verify --------------------------------------------------------
def test_verify_is_clean_on_a_stack_owned_only_change(two_repos):
upstream, repo = two_repos
since = _git(repo, "rev-parse", "HEAD").stdout.strip()
_write(repo, "kb/CONTRACT.md", "stack kb contract v2\n")
_commit(repo, "advance kb/CONTRACT.md")
upstream_cmd.verify_command(since=since, until="HEAD") # must not raise
def test_verify_fails_on_a_hand_botched_merge(two_repos, capsys):
upstream, repo = two_repos
since = _git(repo, "rev-parse", "HEAD").stdout.strip()
_write(repo, "kb/SneakedIn.md", "content that arrived outside a stack-owned path\n")
_commit(repo, "a hand-resolved merge that let content through")
with pytest.raises(typer.Exit) as excinfo:
upstream_cmd.verify_command(since=since, until="HEAD")
assert excinfo.value.exit_code == 1
captured = capsys.readouterr()
assert "kb/SneakedIn.md" in captured.out
# --- ownership predicate, exercised directly ---------------------------------
@pytest.mark.parametrize(
"relative,expected",
[
("kb/CONTRACT.md", True),
("raw/CONTRACT.md", True),
("work/CONTRACT.md", True),
("reports/CONTRACT.md", True),
("kb/CONVENTIONS.md.template", True),
("kb/entities/COLLECTION.md.template", True),
("kb/GLOSSARY.md.template", True),
("kb/CONVENTIONS.md", False),
("kb/entities/COLLECTION.md", False),
("kb/concepts/Some Page.md", False),
("raw/notes/x.md", False),
("tools/CONTRACT.md", False), # not a content stage
("kb/log.md", False), # export stub, not stack-owned
],
)
def test_is_stack_owned(relative, expected):
assert ownership.is_stack_owned(relative) == expected
+2 -2
View File
@@ -77,11 +77,11 @@ REGION_NAME = "toc"
HEADING_TEXT = "Contents"
# The suffix `dist export` re-keys an instance-owned file to, and the one
# `setup-instance.md` adopts away again. Lives here because this module is what
# `dist adopt` adopts away again. Lives here because this module is what
# decides which files are reference material *in both their forms*;
# `docs_verify` imports it rather than keeping a second spelling. `ownership.py`
# keeps its own literal deliberately - it answers a different question (which
# side an upstream merge keeps) over a narrower scope.
# path a release replaces) over a narrower scope.
TEMPLATE_SUFFIX = ".template"
# The line threshold Anthropic's own guidance names. Measured on the body
+53 -14
View File
@@ -8,8 +8,10 @@
# As the release asset `preflight.ps1` - a copy with no tools/prerequisites.txt next
# to it - the script is the first install step instead: it downloads the stack
# release named in $ReleaseArchiveUrl / $ReleaseChecksumUrl, checks the sha256,
# unpacks it into <script folder>\chemenu (or --into <path>), and runs the copy of
# this script inside the unpacked tree, which does everything above.
# unpacks it into the folder it lies in (or --into <path>), removes itself there, and
# runs the copy of this script inside the unpacked tree, which does everything above.
# That folder has to be empty apart from this script and a .git (an empty clone of the
# instance's own repository).
#
# pwsh -NoProfile -ExecutionPolicy Bypass -File preflight.ps1 [--into <path>] [--archive <tarball>]
#
@@ -108,8 +110,8 @@ while ($index -lt $args.Count) {
Write-Line 'means the user has to act - the output says how.'
Write-Line ''
Write-Line 'The second form is the release asset: it downloads the release (or takes'
Write-Line '--archive), checks its sha256, unpacks it into <script folder>\chemenu or --into,'
Write-Line 'and runs the preflight inside it.'
Write-Line '--archive), checks its sha256, unpacks it into its own folder or --into (empty,'
Write-Line 'or holding only .git), and runs the preflight inside it.'
exit 0
} else {
Write-ErrorLine "preflight: unknown argument: $arg"
@@ -235,10 +237,33 @@ function Resolve-AssetPath {
return [IO.Path]::GetFullPath($Path).TrimEnd('\', '/')
}
# Whether the install folder holds anything besides this script and a .git - the two
# things an empty folder may already carry: the script was downloaded into it, and the
# user may have cloned the instance's own, still empty repository there.
function Test-TargetOccupied {
param([string]$Target)
$selfName = Split-Path -Leaf $PSCommandPath
$isOwnFolder = [IO.Path]::GetFullPath($Target).TrimEnd('\', '/') -eq [IO.Path]::GetFullPath($Dir).TrimEnd('\', '/')
foreach ($entry in @(Get-ChildItem -LiteralPath $Target -Force)) {
if ($entry.Name -eq '.git') {
continue
}
if ($isOwnFolder -and $entry.Name -eq $selfName) {
continue
}
return $true
}
return $false
}
function Invoke-AssetMode {
$target = if ($Into) { Resolve-AssetPath $Into } else { Join-Path $Dir 'chemenu' }
if ($null -ne (Get-Item -LiteralPath $target -Force -ErrorAction SilentlyContinue)) {
Exit-Asset "$target already exists - nothing was unpacked. Choose another folder with --into <path>, or move the existing one away."
$target = if ($Into) { Resolve-AssetPath $Into } else { $Dir.TrimEnd('\', '/') }
$existing = Get-Item -LiteralPath $target -Force -ErrorAction SilentlyContinue
if ($null -ne $existing -and -not $existing.PSIsContainer) {
Exit-Asset "$target exists and is not a folder - nothing was unpacked."
}
if ($null -ne $existing -and (Test-TargetOccupied $target)) {
Exit-Asset "$target is not empty - nothing was unpacked. The wiki installs into an empty folder (an empty git clone is fine): put this script into one and run it there, or pass --into <path>."
}
if (-not $Archive -and (-not $ReleaseArchiveUrl -or -not $ReleaseChecksumUrl)) {
@@ -327,20 +352,28 @@ function Invoke-AssetMode {
if ($length -gt $limit) {
Add-Problem "The folder this wiki would be installed in is too long ($length characters, at most $limit): $target" `
"Windows on this computer only allows paths of up to 259 characters, and the wiki's own files need the rest" `
"Run this again with a shorter folder, for example: --into C:\Chemenu`nAlternatively, someone with administrator rights can turn on long paths in Windows."
"Use a shorter folder such as C:\Chemenu - put this script there and run it again,`nor pass --into C:\Chemenu.`nAlternatively, someone with administrator rights can turn on long paths in Windows."
Exit-WithGuide
}
}
$parent = Split-Path -Parent $target
$null = New-Item -ItemType Directory -Path $parent -Force
$unpack = Join-Path $parent ".chemenu-unpack.$PID"
# Unpacked inside the target rather than beside it: the folder above may be a drive
# root nobody can write to. Only then are the entries moved up, one by one.
$null = New-Item -ItemType Directory -Path $target -Force
$unpack = Join-Path $target ".chemenu-unpack.$PID"
$null = New-Item -ItemType Directory -Path $unpack
$null = Invoke-NativeVerbose $tar.Source @('-xzf', $archivePath, '-C', $unpack)
if (-not (Test-Path -LiteralPath (Join-Path $unpack $top) -PathType Container)) {
Exit-Asset "unpacking failed - the target was not created."
$unpacked = Join-Path $unpack $top
if (-not (Test-Path -LiteralPath $unpacked -PathType Container)) {
Exit-Asset 'unpacking failed - nothing was installed.'
}
foreach ($entry in @(Get-ChildItem -LiteralPath $unpacked -Force)) {
try {
Move-Item -LiteralPath $entry.FullName -Destination $target
} catch {
Exit-Asset "could not move $($entry.Name) into $target - the folder holds part of the stack now; empty it (keep .git) and run this again."
}
}
Move-Item -LiteralPath (Join-Path $unpack $top) -Destination $target
} finally {
foreach ($leftover in @($work, $unpack)) {
if ($leftover -and (Test-Path -LiteralPath $leftover)) {
@@ -353,6 +386,12 @@ function Invoke-AssetMode {
if (-not (Test-Path -LiteralPath $treeScript -PathType Leaf)) {
Exit-Asset 'the unpacked stack has no tools/preflight.ps1.'
}
# The release asset is not part of the stack; left in the folder, it would end up in
# the instance's first commit beside the tree's own tools/preflight.ps1. PowerShell has
# read the whole script before running it, so removing the file is safe here.
if ($target -eq $Dir.TrimEnd('\', '/')) {
Remove-Item -LiteralPath $PSCommandPath -Force
}
Write-Line "Unpacked into $target."
Write-Line 'If a later step stops, run tools/preflight.ps1 from inside that folder.'
Write-Line ''
+47 -15
View File
@@ -8,8 +8,10 @@
# As the release asset `preflight.sh` - a copy with no tools/prerequisites.txt next
# to it - the script is the first install step instead: it downloads the stack
# release named in RELEASE_ARCHIVE_URL / RELEASE_CHECKSUM_URL, checks the sha256,
# unpacks it into <script folder>/chemenu (or --into <path>), and runs the copy of
# this script inside the unpacked tree, which does everything above.
# unpacks it into the folder it lies in (or --into <path>), removes itself there, and
# runs the copy of this script inside the unpacked tree, which does everything above.
# That folder has to be empty apart from this script and a .git (an empty clone of the
# instance's own repository).
#
# preflight.sh [--into <path>] [--archive <tarball>] [--set <tool>=<path>]...
#
@@ -59,8 +61,8 @@ in .wikitool-tools.json and sets up tools/.venv. Exit 0 means ready; exit 42
means the user has to act - the output says how.
The second form is the release asset: it downloads the release (or takes
--archive), checks its sha256, unpacks it into <script folder>/chemenu or --into,
and runs the preflight inside it.
--archive), checks its sha256, unpacks it into its own folder or --into (empty,
or holding only .git), and runs the preflight inside it.
EOF
}
@@ -207,6 +209,23 @@ sha256_of() { # <file>
fi
}
# Whether the install folder holds anything besides this script and a .git - the
# two things an empty folder may already carry: the script was downloaded into it, and
# the user may have cloned the instance's own, still empty repository there.
target_is_occupied() {
[ -d "$TARGET" ] || return 1
self_name=${0##*/}
target_real=$(CDPATH='' cd -- "$TARGET" && pwd -P)
for entry in "$TARGET"/* "$TARGET"/.[!.]* "$TARGET"/..?*; do
[ -e "$entry" ] || [ -L "$entry" ] || continue
name=${entry##*/}
[ "$name" = .git ] && continue
[ "$target_real" = "$DIR" ] && [ "$name" = "$self_name" ] && continue
return 0
done
return 1
}
asset_mode() {
# GNU tar reads `C:` as a host name; Git Bash has to hand it `/c/...`.
if [ "$PLATFORM" = windows ] && command -v cygpath >/dev/null 2>&1; then
@@ -216,11 +235,14 @@ asset_mode() {
if [ -n "$INTO" ]; then
case "$INTO" in /*) TARGET=$INTO ;; *) TARGET="$(pwd)/$INTO" ;; esac
else
TARGET="$DIR/chemenu"
TARGET=$DIR
fi
TARGET=${TARGET%/}
if [ -e "$TARGET" ] || [ -L "$TARGET" ]; then
asset_fail "$(native_path "$TARGET") already exists - nothing was unpacked. Choose another folder with --into <path>, or move the existing one away."
if { [ -e "$TARGET" ] || [ -L "$TARGET" ]; } && [ ! -d "$TARGET" ]; then
asset_fail "$(native_path "$TARGET") exists and is not a folder - nothing was unpacked."
fi
if target_is_occupied; then
asset_fail "$(native_path "$TARGET") is not empty - nothing was unpacked. The wiki installs into an empty folder (an empty git clone is fine): put this script into one and run it there, or pass --into <path>."
fi
if [ -z "$ARCHIVE" ] && { [ -z "$RELEASE_ARCHIVE_URL" ] || [ -z "$RELEASE_CHECKSUM_URL" ]; }; then
@@ -287,21 +309,31 @@ asset_mode() {
if [ "$length" -gt "$limit" ]; then
problem "The folder this wiki would be installed in is too long ($length characters, at most $limit): $folder" \
"Windows on this computer only allows paths of up to 259 characters, and the wiki's own files need the rest" \
"Run this again with a shorter folder, for example: --into C:\\\\Chemenu
"Use a shorter folder such as C:\\Chemenu - put this script there and run it again,
or pass --into C:\\Chemenu.
Alternatively, someone with administrator rights can turn on long paths in Windows."
stop
fi
fi
parent=$(dirname -- "$TARGET")
mkdir -p "$parent" || asset_fail "could not create $parent."
UNPACK="$parent/.chemenu-unpack.$$"
mkdir "$UNPACK" || asset_fail "could not create a temporary folder next to the target."
tar -xzf "$archive" -C "$UNPACK" || asset_fail "unpacking failed - the target was not created."
[ -d "$UNPACK/$top" ] || asset_fail "unpacking produced no $top folder - the target was not created."
mv "$UNPACK/$top" "$TARGET" || asset_fail "could not move the unpacked stack to $(native_path "$TARGET")."
# Unpacked inside the target rather than beside it: the folder above may be a drive
# root nobody can write to. Only then are the entries moved up, one by one.
mkdir -p "$TARGET" || asset_fail "could not create $(native_path "$TARGET")."
UNPACK="$TARGET/.chemenu-unpack.$$"
mkdir "$UNPACK" || asset_fail "could not create a temporary folder in the target."
tar -xzf "$archive" -C "$UNPACK" || asset_fail "unpacking failed - nothing was installed."
[ -d "$UNPACK/$top" ] || asset_fail "unpacking produced no $top folder - nothing was installed."
for entry in "$UNPACK/$top"/* "$UNPACK/$top"/.[!.]* "$UNPACK/$top"/..?*; do
[ -e "$entry" ] || [ -L "$entry" ] || continue
mv "$entry" "$TARGET/" || asset_fail "could not move ${entry##*/} into $(native_path "$TARGET") - the folder holds part of the stack now; empty it (keep .git) and run this again."
done
rm -rf "$UNPACK"
UNPACK=""
# The release asset is not part of the stack; left in the folder, it would end up in
# the instance's first commit beside the tree's own tools/preflight.sh.
if [ "$(CDPATH='' cd -- "$TARGET" && pwd -P)" = "$DIR" ]; then
rm -f -- "$DIR/${0##*/}"
fi
[ -f "$TARGET/tools/preflight.sh" ] || asset_fail "the unpacked stack has no tools/preflight.sh."
echo "Unpacked into $(native_path "$TARGET")."