feat!: installation only from a release, into an empty folder; upstream merge/verify and private-instance.md removed, dist adopt, shell-neutral instructions (#153)
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:
1 parent
d0f08d1fba
commit
a6d07f97c4
46 files changed
+1314
-1936
No files matched your search
+55
-119
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
)),
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
@@ -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}")
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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 -----------------------------------------------------------------
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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")."
|
||||
|
||||
Reference in new issue
Block a user