Files
chemenu/tools/chemenu/commands/backup_cmd.py
T
torbenandClaude Opus 5.5 9736472f48
CI / verify (push) Successful in 8m24s
CI / pwsh (push) Successful in 2m45s
Release / release (push) Successful in 34s
Release / wiki-mirror (push) Skipped
backup: an instance backs up into a synced folder - backup init makes a bare repository under <target>/git its origin, backup run and publish mirror the machine data into <target>/<machine>/, doctor warns about .git in a synced folder (#186); 10.0.0-beta.10
Files changed:
- CHANGES.md
- INSTALL.md
- VERSION
- docs/explanation/configuration-tiers.md
- docs/how-to/README.md
- docs/how-to/back-up-to-a-synced-folder.md
- docs/reference/commands/README.md
- docs/reference/commands/git.md
- docs/reference/commands/instance-health.md
- docs/reference/configuration.md
- instructions/publish-cycle.md
- instructions/setup-instance.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/backup.py
- tools/chemenu/cli.py
- tools/chemenu/cli_contract.py
- tools/chemenu/commands/backup_cmd.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/git_publish.py
- tools/chemenu/config_shapes.py
- tools/chemenu/reference_doc.py
- tools/chemenu/tests/conftest.py
- tools/chemenu/tests/test_backup.py
- tools/chemenu/tests/test_config_shapes.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
2026-10-12 19:39:49 +02:00

170 lines
8.1 KiB
Python

"""`wikitool backup init` and `backup run` - back an instance up into a synced folder.
The mechanism and what a run guarantees are `chemenu.backup`'s; this module only
turns its results and refusals into output and exit codes. `publish` runs the
same machine-data step after a push (`git_publish.publish_command`).
"""
from __future__ import annotations
import typer
from chemenu import backup, cli_contract, config
from chemenu.commands._util import fail, success
from chemenu.errors import ChemenuError
app = typer.Typer(help="Back this instance up into a synced folder (OneDrive and the like).")
HOW_TO = "docs/how-to/back-up-to-a-synced-folder.md"
_SECTION_FAILURE = cli_contract.Failure(
cause=f"No `backup` section in `{config.LOCAL_SETTINGS_FILENAME}`, or a malformed one - a "
"target that is not absolute or lies inside the instance, a `machine_name` that cleans to "
"nothing or to `git`, an `extra` path that resolves outside the instance or into `.git/`",
reaction=f"Show the message to the user and stop. The section is theirs to write or fix by "
f"hand - `config set` refuses its fields; {HOW_TO} has the procedure",
)
@app.command("init")
@cli_contract.record(cli_contract.CommandRecord(
path="backup init",
summary="Create the backup target's bare repository and make it `origin` when there is none.",
synopsis=(cli_contract.Variant(usage="backup init"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.YES,
atomic="Every precondition is checked first; a refusal changes nothing",
budget=cli_contract.Budget.COUNTED,
),
notes=(
"Reads the `backup` section of `.wikitool-local.json` and creates `<target>/` and "
"`<target>/git`, the latter as a bare repository (`git init --bare -b main`). An existing "
"bare repository there, or an empty directory, is used as it is.",
"Sets `origin` to `<target>/git` when the checkout has no `origin`. An `origin` that "
"already pushes there is left as it is; one that pushes anywhere else stops the command.",
"When `remotes.allowed_push_urls` exists and lacks the URL `origin` pushes to, prints the "
"`remotes` section with that URL added, for the user to copy into "
"`.wikitool-local.json`. It never writes that file: the field is a gate.",
"Pushes nothing: the next `publish` sends the history to the new `origin`.",
),
failures=(
_SECTION_FAILURE,
cli_contract.Failure(
cause="`<target>/git` exists and is not a bare repository; nothing was changed",
reaction="Show the message to the user - whether that folder moves, or the target "
"does, is their decision",
),
cli_contract.Failure(
cause="`origin` already pushes to another URL; the message names both and nothing was "
"changed",
reaction="Show both URLs to the user and stop. Never repoint `origin` yourself - "
"`git remote set-url` is theirs to run",
),
),
examples=("tools/wikitool backup init",),
never=(
"Never add the printed URL to `remotes.allowed_push_urls` yourself - that field is a gate, "
"and editing it is the user's.",
),
see_also=(
"`wikitool backup run` - the machine-data half of the backup",
"`wikitool publish` - pushes to the bare repository and runs the machine-data backup "
"after it",
f"`{HOW_TO}` - the whole procedure, restoring included",
),
))
def init_command():
"""Create the backup target's bare repository and make it `origin` when there is none."""
try:
cfg = backup.require_config()
result = backup.init(cfg)
except ChemenuError as exc:
fail(str(exc))
if result.created_repo:
typer.echo(f"Created the bare repository {result.git_dir}.")
else:
typer.echo(f"{result.git_dir} is already a bare repository - left as it is.")
if result.set_origin:
typer.echo(f"Set origin to {result.git_dir}.")
if result.allowlist_entry:
typer.echo(
"remotes.allowed_push_urls does not list this push target, so `publish` will stop at "
"the Publish-Remote Gate. THE USER CANNOT SEE THIS OUTPUT - show them this section, to "
f"put into {config.LOCAL_SETTINGS_FILENAME} themselves:\n{result.allowlist_entry}"
)
success("Backup target ready; the next `publish` pushes to it.")
@app.command("run")
@cli_contract.record(cli_contract.CommandRecord(
path="backup run",
summary="Mirror this machine's files that are not in git into `<target>/<machine>/`.",
synopsis=(cli_contract.Variant(usage="backup run"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.YES,
atomic="Per file - each is written to a temporary file and renamed, the manifest last; an "
"interrupted run leaves whole files and the previous manifest",
budget=cli_contract.Budget.COUNTED,
),
notes=(
"Mirrors `.wikitool-local.json`, `ENVIRONMENT.md` if present, every file in `incoming/` "
"but `.gitkeep`, and the `extra` paths of the `backup` section into "
"`<target>/<machine>/`, then writes the manifest `.chemenu-backup.json` there: time, "
"stack version, machine name, and per file its relative path, size and sha256.",
"Never mirrors `mcp-upload/`, `reports/`, `.wikitool-tools.json`, `tools/.venv/`, the "
"published skills or `.git/` - unless an `extra` path names one of the first ones. The "
"secrets file lies outside the instance and is never read.",
"Writes and deletes only under `<target>/<machine>/`. Deletes only a file the previous "
"manifest lists and the instance no longer has; a file there the manifest does not list "
"stays as it is. A file whose size and sha256 match the previous manifest is not "
"rewritten.",
"A symbolic link or junction inside a mirrored directory is skipped and named in the "
"output, never followed.",
"`publish` runs the same step after every push when `machine_data` is true.",
),
failures=(
_SECTION_FAILURE,
cli_contract.Failure(
cause="`machine_data` is false",
reaction="Show the message to the user; turning it on is their edit of "
"`.wikitool-local.json`",
),
cli_contract.Failure(
cause="`<target>/<machine>/` is not empty and has no manifest, or its manifest cannot "
"be read; nothing was written",
reaction="Show the message to the user - the folder is not one this backup wrote. "
"They set another `machine_name` or empty it",
),
cli_contract.Failure(
cause="The target is not a directory (the synced folder is missing or not mounted), "
"or a write failed",
reaction="Show the message to the user; once the cause is fixed, run `backup run` "
"again",
),
),
examples=("tools/wikitool backup run",),
never=(
"Never point `extra` at a path outside the instance to back something else up - the "
"command refuses it, and the secrets belong in no synced folder.",
),
see_also=(
"`wikitool backup init` - sets up the target's bare repository",
"`wikitool doctor` - the `backup` line reports the age of the last run",
f"`{HOW_TO}` - the whole procedure, restoring included",
),
))
def run_command():
"""Mirror this machine's files that are not in git into `<target>/<machine>/`."""
try:
cfg = backup.require_config()
if not cfg.machine_data:
fail(f"backup.machine_data is false in {config.LOCAL_SETTINGS_FILENAME} - there is "
"nothing to back up with `backup run`; the user sets it to true by hand")
result = backup.run(cfg)
except ChemenuError as exc:
fail(str(exc))
except OSError as exc:
fail(f"the machine-data backup failed: {exc}")
success(result.summary())