"""`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 `/` and " "`/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 `/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="`/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 `//`.", 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 " "`//`, 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 `//`. 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="`//` 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 `//`.""" 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())