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
This commit is contained in:
1 parent
e789146116
commit
9736472f48
25 files changed
+2024
-15
No files matched your search
@@ -39,6 +39,10 @@ _WIKITOOL_ENV = (
|
||||
"CHEMENU_PREFLIGHT_POLICY",
|
||||
"CHEMENU_PREFLIGHT_MARKED",
|
||||
"CHEMENU_PREFLIGHT_MOVE_FAIL",
|
||||
# Where a sync client keeps its folder - `doctor`'s sync-folder check reads them.
|
||||
"OneDrive",
|
||||
"OneDriveCommercial",
|
||||
"OneDriveConsumer",
|
||||
) + tuple(var for var, _harness in HARNESS_ENV_VARS)
|
||||
|
||||
# Environment git reads for identity or for where its repo lives. A stray
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
"""`backup init`, `backup run`, the machine-data step of `publish`, and the two
|
||||
`doctor` lines - against a real checkout and a target directory in `tmp_path`.
|
||||
|
||||
The target's path carries a space on purpose: the folder a sync client keeps on
|
||||
Windows is `OneDrive - <company>`, and every path that reaches git or the file
|
||||
system has to survive that."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import typer
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from chemenu import backup, config, config_shapes, settings
|
||||
from chemenu.cli import app
|
||||
from chemenu.commands import doctor
|
||||
from chemenu.commands.git_publish import publish_command
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def _git(root: Path, *args: str) -> str:
|
||||
result = subprocess.run(["git", *args], cwd=root, capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _write_local(root: Path, **sections) -> None:
|
||||
(root / config.LOCAL_SETTINGS_FILENAME).write_text(
|
||||
json.dumps({"schema": 1, **sections}, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def instance(tmp_path, monkeypatch):
|
||||
"""A checkout with one commit and no remote, and an empty synced folder
|
||||
beside it - whose name has a space in it."""
|
||||
root = tmp_path / "instance"
|
||||
root.mkdir()
|
||||
_git(root, "init", "-b", "main")
|
||||
_git(root, "config", "user.name", "Test")
|
||||
_git(root, "config", "user.email", "test@example.com")
|
||||
(root / "kb").mkdir()
|
||||
(root / "incoming").mkdir()
|
||||
(root / "incoming" / ".gitkeep").write_text("", encoding="utf-8")
|
||||
(root / ".gitignore").write_text(
|
||||
"/incoming/*\n!/incoming/.gitkeep\n/.wikitool-local.json\n/ENVIRONMENT.md\n/mcp-upload/\n"
|
||||
"/reports/\n/.wikitool-tools.json\n", encoding="utf-8")
|
||||
(root / "README.md").write_text("init\n", encoding="utf-8")
|
||||
_git(root, "add", "-A")
|
||||
_git(root, "commit", "-m", "init")
|
||||
synced = tmp_path / "OneDrive - Example"
|
||||
synced.mkdir()
|
||||
monkeypatch.setattr(config, "ROOT", root)
|
||||
monkeypatch.setenv("WIKITOOL_SESSION_ID", "test-session")
|
||||
return root
|
||||
|
||||
|
||||
def _target(root: Path) -> Path:
|
||||
return root.parent / "OneDrive - Example" / "Chemenu-Backup"
|
||||
|
||||
|
||||
def _configure(root: Path, **overrides) -> Path:
|
||||
target = _target(root)
|
||||
section = {"target": str(target), "machine_data": True, "machine_name": "WORK-LAPTOP"}
|
||||
section.update(overrides)
|
||||
_write_local(root, backup=section)
|
||||
return target
|
||||
|
||||
|
||||
def _snapshot(directory: Path) -> dict:
|
||||
"""Every file below `directory` with its bytes - what "unchanged" is measured against."""
|
||||
return {p.relative_to(directory).as_posix(): p.read_bytes()
|
||||
for p in sorted(directory.rglob("*")) if p.is_file()}
|
||||
|
||||
|
||||
def _mirrored(target: Path, machine: str = "WORK-LAPTOP") -> set[str]:
|
||||
return set(_snapshot(target / machine))
|
||||
|
||||
|
||||
def _flat(text: str) -> str:
|
||||
"""Output with Rich's line wrapping undone."""
|
||||
return " ".join(text.split())
|
||||
|
||||
|
||||
def _publish(**overrides):
|
||||
kwargs = dict(message="change", push=True, confirm=None, confirm_rebase=None, yes=False,
|
||||
threshold=10, remote="origin", branch="main", path=None)
|
||||
kwargs.update(overrides)
|
||||
publish_command(**kwargs)
|
||||
|
||||
|
||||
# --- the section -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_no_section_reads_as_none_and_the_commands_name_it(instance):
|
||||
assert backup.read_config() is None
|
||||
for command in ("init", "run"):
|
||||
result = runner.invoke(app, ["backup", command])
|
||||
assert result.exit_code == 1
|
||||
assert "no backup section" in _flat(result.output)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["", "...", " ", "git", "GIT", ".git", "-Git-"])
|
||||
def test_a_machine_name_that_cleans_to_nothing_or_git_is_an_error(instance, name):
|
||||
_configure(instance, machine_name=name)
|
||||
with pytest.raises(settings.ValidationError, match="machine_name"):
|
||||
backup.read_config()
|
||||
|
||||
|
||||
def test_a_machine_name_is_cleaned_to_a_directory_name(instance):
|
||||
_configure(instance, machine_name="Work Laptop.corp")
|
||||
assert backup.read_config().machine_name == "Work-Laptop-corp"
|
||||
|
||||
|
||||
def test_without_a_machine_name_the_host_name_is_used(instance, monkeypatch):
|
||||
monkeypatch.setattr(backup.socket, "gethostname", lambda: "host.example")
|
||||
_configure(instance)
|
||||
data = json.loads((instance / config.LOCAL_SETTINGS_FILENAME).read_text(encoding="utf-8"))
|
||||
del data["backup"]["machine_name"]
|
||||
(instance / config.LOCAL_SETTINGS_FILENAME).write_text(json.dumps(data), encoding="utf-8")
|
||||
assert backup.read_config().machine_name == "host-example"
|
||||
|
||||
|
||||
def test_a_target_inside_the_instance_is_an_error(instance):
|
||||
_configure(instance, target=str(instance / "backup"))
|
||||
with pytest.raises(settings.ValidationError, match="inside the instance"):
|
||||
backup.read_config()
|
||||
|
||||
|
||||
def test_a_relative_target_is_an_error(instance):
|
||||
_configure(instance, target="backup")
|
||||
with pytest.raises(settings.ValidationError, match="absolute"):
|
||||
backup.read_config()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field, value", [("machine_data", "yes"), ("extra", "a")])
|
||||
def test_a_field_of_the_wrong_type_is_an_error(instance, field, value):
|
||||
_configure(instance, **{field: value})
|
||||
with pytest.raises(settings.ValidationError, match=field):
|
||||
backup.read_config()
|
||||
|
||||
|
||||
def test_the_gate_fields_are_refused_by_config_set(instance):
|
||||
for name in ("target", "machine_data", "extra"):
|
||||
assert config_shapes.BACKUP.field(name).user_only
|
||||
result = runner.invoke(app, ["config", "set", "backup.target", "/tmp/x"])
|
||||
assert result.exit_code == 42
|
||||
|
||||
|
||||
# --- backup init -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_init_creates_a_bare_repository_and_makes_it_origin(instance):
|
||||
target = _configure(instance)
|
||||
result = runner.invoke(app, ["backup", "init"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert backup.is_bare_repo(target / "git")
|
||||
assert _git(instance, "remote", "get-url", "--push", "origin") == str(target / "git")
|
||||
|
||||
before = _snapshot(target)
|
||||
again = runner.invoke(app, ["backup", "init"])
|
||||
assert again.exit_code == 0, again.output
|
||||
assert "left as it is" in _flat(again.output)
|
||||
assert _snapshot(target) == before
|
||||
assert _git(instance, "remote", "get-url", "--push", "origin") == str(target / "git")
|
||||
|
||||
|
||||
def test_init_into_an_empty_directory_uses_it(instance):
|
||||
target = _configure(instance)
|
||||
(target / "git").mkdir(parents=True)
|
||||
assert runner.invoke(app, ["backup", "init"]).exit_code == 0
|
||||
assert backup.is_bare_repo(target / "git")
|
||||
|
||||
|
||||
def test_init_leaves_an_origin_that_points_elsewhere_and_changes_nothing(instance, tmp_path):
|
||||
target = _configure(instance)
|
||||
_git(instance, "remote", "add", "origin", "https://example.org/other.git")
|
||||
result = runner.invoke(app, ["backup", "init"])
|
||||
assert result.exit_code == 1
|
||||
assert "https://example.org/other.git" in _flat(result.output)
|
||||
assert str(target / "git") in _flat(result.output)
|
||||
assert _git(instance, "remote", "get-url", "--push", "origin") == "https://example.org/other.git"
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_init_refuses_a_git_folder_that_is_not_a_bare_repository(instance):
|
||||
target = _configure(instance)
|
||||
(target / "git").mkdir(parents=True)
|
||||
(target / "git" / "notes.txt").write_text("mine\n", encoding="utf-8")
|
||||
before = _snapshot(target)
|
||||
result = runner.invoke(app, ["backup", "init"])
|
||||
assert result.exit_code == 1
|
||||
assert "not a bare git repository" in _flat(result.output)
|
||||
assert _snapshot(target) == before
|
||||
assert subprocess.run(["git", "remote", "get-url", "origin"], cwd=instance,
|
||||
capture_output=True).returncode != 0
|
||||
|
||||
|
||||
def test_init_prints_the_allowlist_entry_and_never_writes_it(instance):
|
||||
target = _configure(instance)
|
||||
data = json.loads((instance / config.LOCAL_SETTINGS_FILENAME).read_text(encoding="utf-8"))
|
||||
data["remotes"] = {"allowed_push_urls": ["https://example.org/kb.git"]}
|
||||
(instance / config.LOCAL_SETTINGS_FILENAME).write_text(json.dumps(data), encoding="utf-8")
|
||||
before = (instance / config.LOCAL_SETTINGS_FILENAME).read_bytes()
|
||||
|
||||
result = runner.invoke(app, ["backup", "init"])
|
||||
assert result.exit_code == 0, result.output
|
||||
entry = json.loads(result.output[result.output.index("{"):result.output.rindex("}") + 1])
|
||||
assert entry == {"remotes": {"allowed_push_urls": ["https://example.org/kb.git", str(target / "git")]}}
|
||||
assert (instance / config.LOCAL_SETTINGS_FILENAME).read_bytes() == before
|
||||
|
||||
|
||||
# --- backup run --------------------------------------------------------------
|
||||
|
||||
|
||||
def _fill(root: Path) -> None:
|
||||
(root / "ENVIRONMENT.md").write_text("# env\n", encoding="utf-8")
|
||||
(root / "incoming" / "paper.pdf").write_bytes(b"%PDF-1.7 fixture")
|
||||
(root / "incoming" / "old").mkdir()
|
||||
(root / "incoming" / "old" / "note.md").write_text("note\n", encoding="utf-8")
|
||||
for relative in ("mcp-upload/sub-1/x.md", "reports/lint.md", ".wikitool-tools.json"):
|
||||
(root / relative).parent.mkdir(parents=True, exist_ok=True)
|
||||
(root / relative).write_text("not mirrored\n", encoding="utf-8")
|
||||
(root / ".claude").mkdir()
|
||||
(root / ".claude" / "settings.local.json").write_text("{}\n", encoding="utf-8")
|
||||
|
||||
|
||||
def test_run_mirrors_exactly_the_machine_data(instance):
|
||||
target = _configure(instance, extra=[".claude/settings.local.json"])
|
||||
target.mkdir(parents=True)
|
||||
_fill(instance)
|
||||
result = runner.invoke(app, ["backup", "run"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert _mirrored(target) == {
|
||||
".wikitool-local.json", "ENVIRONMENT.md", "incoming/paper.pdf", "incoming/old/note.md",
|
||||
".claude/settings.local.json", backup.MANIFEST_NAME,
|
||||
}
|
||||
machine = target / "WORK-LAPTOP"
|
||||
assert (machine / "incoming" / "paper.pdf").read_bytes() == b"%PDF-1.7 fixture"
|
||||
manifest = json.loads((machine / backup.MANIFEST_NAME).read_text(encoding="utf-8"))
|
||||
assert manifest["machine"] == "WORK-LAPTOP"
|
||||
assert {entry["path"] for entry in manifest["files"]} == _mirrored(target) - {backup.MANIFEST_NAME}
|
||||
assert all(len(entry["sha256"]) == 64 and entry["size"] >= 0 for entry in manifest["files"])
|
||||
|
||||
|
||||
def test_run_never_mirrors_git_even_through_extra(instance):
|
||||
_configure(instance, extra=[".git"])
|
||||
with pytest.raises(settings.ValidationError, match=r"\.git"):
|
||||
backup.read_config()
|
||||
target = _configure(instance, extra=["kb"])
|
||||
target.mkdir(parents=True)
|
||||
(instance / "kb" / ".git").mkdir()
|
||||
(instance / "kb" / ".git" / "HEAD").write_text("ref\n", encoding="utf-8")
|
||||
(instance / "kb" / "page.md").write_text("page\n", encoding="utf-8")
|
||||
assert runner.invoke(app, ["backup", "run"]).exit_code == 0
|
||||
assert "kb/page.md" in _mirrored(target)
|
||||
assert not any(".git/" in path for path in _mirrored(target))
|
||||
|
||||
|
||||
def test_a_second_run_removes_what_left_incoming_and_keeps_a_foreign_file(instance):
|
||||
target = _configure(instance)
|
||||
target.mkdir(parents=True)
|
||||
_fill(instance)
|
||||
assert runner.invoke(app, ["backup", "run"]).exit_code == 0
|
||||
foreign = target / "WORK-LAPTOP" / "kept-by-hand.txt"
|
||||
foreign.write_bytes(b"mine")
|
||||
|
||||
(instance / "incoming" / "paper.pdf").unlink()
|
||||
result = runner.invoke(app, ["backup", "run"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "1 removed" in _flat(result.output)
|
||||
assert not (target / "WORK-LAPTOP" / "incoming" / "paper.pdf").exists()
|
||||
assert foreign.read_bytes() == b"mine"
|
||||
|
||||
|
||||
def test_an_unchanged_file_is_not_rewritten(instance):
|
||||
target = _configure(instance)
|
||||
target.mkdir(parents=True)
|
||||
_fill(instance)
|
||||
assert runner.invoke(app, ["backup", "run"]).exit_code == 0
|
||||
copy = target / "WORK-LAPTOP" / "incoming" / "paper.pdf"
|
||||
stamp = copy.stat().st_mtime_ns
|
||||
os.utime(copy, ns=(stamp - 10**9, stamp - 10**9))
|
||||
result = runner.invoke(app, ["backup", "run"])
|
||||
assert "0 written" in _flat(result.output)
|
||||
assert copy.stat().st_mtime_ns == stamp - 10**9
|
||||
|
||||
|
||||
def test_run_refuses_a_non_empty_folder_without_a_manifest(instance):
|
||||
target = _configure(instance)
|
||||
(target / "WORK-LAPTOP").mkdir(parents=True)
|
||||
(target / "WORK-LAPTOP" / "somebody-elses.txt").write_text("x\n", encoding="utf-8")
|
||||
_fill(instance)
|
||||
before = _snapshot(target)
|
||||
result = runner.invoke(app, ["backup", "run"])
|
||||
assert result.exit_code == 1
|
||||
assert "no .chemenu-backup.json" in _flat(result.output)
|
||||
assert _snapshot(target) == before
|
||||
|
||||
|
||||
def test_run_writes_nothing_outside_its_own_folder(instance):
|
||||
target = _configure(instance)
|
||||
target.mkdir(parents=True)
|
||||
(target / "OTHER-PC").mkdir()
|
||||
(target / "OTHER-PC" / "a.txt").write_text("other\n", encoding="utf-8")
|
||||
(target / "git").mkdir()
|
||||
(target / "git" / "b").write_text("repo\n", encoding="utf-8")
|
||||
before = {k: v for k, v in _snapshot(target).items()}
|
||||
_fill(instance)
|
||||
assert runner.invoke(app, ["backup", "run"]).exit_code == 0
|
||||
after = _snapshot(target)
|
||||
assert {k: v for k, v in after.items() if not k.startswith("WORK-LAPTOP/")} == before
|
||||
|
||||
|
||||
def test_a_manifest_entry_that_leaves_the_folder_is_refused(instance):
|
||||
target = _configure(instance)
|
||||
(target / "WORK-LAPTOP").mkdir(parents=True)
|
||||
(target / "victim.txt").write_text("keep\n", encoding="utf-8")
|
||||
(target / "WORK-LAPTOP" / backup.MANIFEST_NAME).write_text(json.dumps(
|
||||
{"schema": 1, "files": [{"path": "../victim.txt", "size": 5, "sha256": "0" * 64}]}),
|
||||
encoding="utf-8")
|
||||
assert runner.invoke(app, ["backup", "run"]).exit_code == 1
|
||||
assert (target / "victim.txt").read_text(encoding="utf-8") == "keep\n"
|
||||
|
||||
|
||||
def test_an_extra_path_outside_the_instance_is_refused_before_anything_is_written(instance, tmp_path):
|
||||
target = _configure(instance, extra=["../outside.txt"])
|
||||
target.mkdir(parents=True)
|
||||
(tmp_path / "outside.txt").write_text("secret\n", encoding="utf-8")
|
||||
result = runner.invoke(app, ["backup", "run"])
|
||||
assert result.exit_code == 1
|
||||
assert "outside the instance" in _flat(result.output)
|
||||
assert not (target / "WORK-LAPTOP").exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink") or os.name == "nt", reason="symlinks")
|
||||
def test_an_extra_symlink_to_outside_is_refused(instance, tmp_path):
|
||||
(tmp_path / "secrets").mkdir()
|
||||
(tmp_path / "secrets" / "secrets.json").write_text("{}\n", encoding="utf-8")
|
||||
(instance / "linked").symlink_to(tmp_path / "secrets")
|
||||
target = _configure(instance, extra=["linked"])
|
||||
target.mkdir(parents=True)
|
||||
assert runner.invoke(app, ["backup", "run"]).exit_code == 1
|
||||
assert not (target / "WORK-LAPTOP").exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink") or os.name == "nt", reason="symlinks")
|
||||
def test_a_symlink_in_incoming_is_skipped_and_named(instance, tmp_path):
|
||||
(tmp_path / "elsewhere.txt").write_text("not mine\n", encoding="utf-8")
|
||||
(instance / "incoming" / "link.txt").symlink_to(tmp_path / "elsewhere.txt")
|
||||
target = _configure(instance)
|
||||
target.mkdir(parents=True)
|
||||
result = runner.invoke(app, ["backup", "run"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "incoming/link.txt (a link - not followed)" in _flat(result.output)
|
||||
assert "incoming/link.txt" not in _mirrored(target)
|
||||
|
||||
|
||||
def test_run_refuses_while_machine_data_is_off(instance):
|
||||
_configure(instance, machine_data=False)
|
||||
result = runner.invoke(app, ["backup", "run"])
|
||||
assert result.exit_code == 1
|
||||
assert "machine_data is false" in _flat(result.output)
|
||||
|
||||
|
||||
def test_run_refuses_a_missing_target(instance):
|
||||
target = _configure(instance)
|
||||
result = runner.invoke(app, ["backup", "run"])
|
||||
assert result.exit_code == 1
|
||||
assert "not a directory" in _flat(result.output)
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
# --- publish -----------------------------------------------------------------
|
||||
|
||||
|
||||
def _ready(root: Path, **overrides) -> Path:
|
||||
target = _configure(root, **overrides)
|
||||
assert runner.invoke(app, ["backup", "init"]).exit_code == 0
|
||||
return target
|
||||
|
||||
|
||||
def test_publish_pushes_to_the_target_and_backs_the_machine_data_up(instance):
|
||||
target = _ready(instance, extra=[".claude/settings.local.json"])
|
||||
_fill(instance)
|
||||
(instance / "kb" / "page.md").write_text("page\n", encoding="utf-8")
|
||||
_publish()
|
||||
assert _git(target / "git", "rev-parse", "main") == _git(instance, "rev-parse", "HEAD")
|
||||
assert _mirrored(target) == {
|
||||
".wikitool-local.json", "ENVIRONMENT.md", "incoming/paper.pdf", "incoming/old/note.md",
|
||||
".claude/settings.local.json", backup.MANIFEST_NAME,
|
||||
}
|
||||
|
||||
|
||||
def test_publish_without_machine_data_backs_nothing_up(instance):
|
||||
target = _ready(instance, machine_data=False)
|
||||
(instance / "kb" / "page.md").write_text("page\n", encoding="utf-8")
|
||||
_publish()
|
||||
assert _git(target / "git", "rev-parse", "main") == _git(instance, "rev-parse", "HEAD")
|
||||
assert not (target / "WORK-LAPTOP").exists()
|
||||
|
||||
|
||||
def test_a_failed_machine_backup_is_a_warning_after_the_push(instance, capsys):
|
||||
target = _ready(instance)
|
||||
(target / "WORK-LAPTOP").mkdir()
|
||||
(target / "WORK-LAPTOP" / "somebody-elses.txt").write_text("x\n", encoding="utf-8")
|
||||
(instance / "kb" / "page.md").write_text("page\n", encoding="utf-8")
|
||||
_publish() # no typer.Exit: exit 0
|
||||
out = capsys.readouterr().out
|
||||
assert "WARN" in out and "backup run" in out
|
||||
assert _git(target / "git", "rev-parse", "main") == _git(instance, "rev-parse", "HEAD")
|
||||
|
||||
|
||||
def test_an_unwritable_target_is_a_warning_after_the_push(instance, capsys, monkeypatch):
|
||||
target = _ready(instance)
|
||||
|
||||
def refuse(*args, **kwargs):
|
||||
raise PermissionError(13, "Permission denied", str(target))
|
||||
|
||||
monkeypatch.setattr(backup, "_copy_atomic", refuse)
|
||||
(instance / "kb" / "page.md").write_text("page\n", encoding="utf-8")
|
||||
_publish()
|
||||
out = capsys.readouterr().out
|
||||
assert "WARN" in out and "Permission denied" in out
|
||||
assert _git(target / "git", "rev-parse", "main") == _git(instance, "rev-parse", "HEAD")
|
||||
|
||||
|
||||
def test_publish_no_push_backs_nothing_up(instance):
|
||||
target = _ready(instance)
|
||||
(instance / "kb" / "page.md").write_text("page\n", encoding="utf-8")
|
||||
_publish(push=False)
|
||||
assert not (target / "WORK-LAPTOP").exists()
|
||||
|
||||
|
||||
def test_a_malformed_section_stops_publish_before_the_commit(instance):
|
||||
_configure(instance, machine_name="git")
|
||||
(instance / "kb" / "page.md").write_text("page\n", encoding="utf-8")
|
||||
head = _git(instance, "rev-parse", "HEAD")
|
||||
for push in (True, False):
|
||||
with pytest.raises(typer.Exit) as exc:
|
||||
_publish(push=push)
|
||||
assert exc.value.exit_code == 1
|
||||
assert _git(instance, "rev-parse", "HEAD") == head
|
||||
|
||||
|
||||
# --- doctor ------------------------------------------------------------------
|
||||
|
||||
|
||||
def _check(name: str):
|
||||
return next(check for check in doctor.run_doctor() if check.name == name)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def doctor_instance(instance, monkeypatch):
|
||||
# `run_doctor` runs every check; the two under test only need the checkout.
|
||||
for name in ("check_skills", "check_structure", "check_personalization", "check_conventions",
|
||||
"check_generated_files", "check_tool_paths"):
|
||||
monkeypatch.setattr(doctor, name, lambda: doctor.Check(name, "OK", "stubbed"))
|
||||
return instance
|
||||
|
||||
|
||||
@pytest.mark.parametrize("var", backup.SYNC_ENV_VARS)
|
||||
def test_sync_folder_warns_under_a_onedrive_folder(doctor_instance, monkeypatch, var):
|
||||
monkeypatch.setenv(var, str(doctor_instance.parent))
|
||||
check = _check("sync-folder")
|
||||
assert check.status == "WARN"
|
||||
assert "back-up-to-a-synced-folder.md" in check.fix and "Known Folder Move" in check.fix
|
||||
|
||||
|
||||
def test_sync_folder_warns_under_cloud_storage(doctor_instance, tmp_path, monkeypatch):
|
||||
cloud = tmp_path / "home" / "Library" / "CloudStorage"
|
||||
cloud.mkdir(parents=True)
|
||||
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
||||
moved = cloud / "OneDrive-Example" / "instance"
|
||||
moved.parent.mkdir()
|
||||
doctor_instance.rename(moved)
|
||||
monkeypatch.setattr(config, "ROOT", moved)
|
||||
assert _check("sync-folder").status == "WARN"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink") or os.name == "nt", reason="symlinks")
|
||||
def test_sync_folder_sees_through_a_symlink(doctor_instance, tmp_path, monkeypatch):
|
||||
synced = tmp_path / "OneDrive - Example"
|
||||
real = synced / "instance"
|
||||
doctor_instance.rename(real)
|
||||
(tmp_path / "via-link").symlink_to(real)
|
||||
monkeypatch.setattr(config, "ROOT", tmp_path / "via-link")
|
||||
monkeypatch.setenv("OneDrive", str(synced))
|
||||
assert _check("sync-folder").status == "WARN"
|
||||
|
||||
|
||||
def test_sync_folder_is_ok_elsewhere_and_never_sets_the_exit_code(doctor_instance, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("OneDrive", str(tmp_path / "OneDrive - Example"))
|
||||
assert _check("sync-folder").status == "OK"
|
||||
|
||||
|
||||
def test_sync_folder_compares_without_case_on_windows(doctor_instance, monkeypatch):
|
||||
monkeypatch.setenv("OneDrive", str(doctor_instance.parent).upper())
|
||||
monkeypatch.setattr(backup, "IGNORE_CASE", False)
|
||||
assert _check("sync-folder").status == "OK"
|
||||
monkeypatch.setattr(backup, "IGNORE_CASE", True)
|
||||
assert _check("sync-folder").status == "WARN"
|
||||
|
||||
|
||||
def test_backup_check_absent_is_ok(doctor_instance):
|
||||
check = _check("backup")
|
||||
assert (check.status, check.detail) == ("OK", "absent - no backup section in the settings")
|
||||
|
||||
|
||||
def test_backup_check_fails_on_a_malformed_section(doctor_instance):
|
||||
_configure(doctor_instance, machine_name="GIT")
|
||||
check = _check("backup")
|
||||
assert check.status == "FAIL" and "machine_name" in check.detail
|
||||
|
||||
|
||||
def test_backup_check_warns_until_init_and_the_first_run(doctor_instance):
|
||||
target = _configure(doctor_instance)
|
||||
check = _check("backup")
|
||||
assert check.status == "WARN" and "not a bare repository" in check.detail
|
||||
|
||||
assert runner.invoke(app, ["backup", "init"]).exit_code == 0
|
||||
check = _check("backup")
|
||||
assert check.status == "WARN" and "no machine-data backup" in check.detail
|
||||
|
||||
assert runner.invoke(app, ["backup", "run"]).exit_code == 0
|
||||
check = _check("backup")
|
||||
assert check.status == "OK" and "last machine-data backup" in check.detail
|
||||
assert str(target) in check.detail
|
||||
|
||||
|
||||
def test_backup_check_warns_on_an_origin_elsewhere(doctor_instance):
|
||||
target = _configure(doctor_instance, machine_data=False)
|
||||
subprocess.run(["git", "init", "--bare", "-q", "-b", "main", str(target / "git")], check=True)
|
||||
_git(doctor_instance, "remote", "add", "origin", "https://example.org/other.git")
|
||||
check = _check("backup")
|
||||
assert check.status == "WARN" and "https://example.org/other.git" in check.detail
|
||||
|
||||
|
||||
def test_a_warning_from_either_check_leaves_doctors_exit_code_alone(doctor_instance, monkeypatch):
|
||||
monkeypatch.setenv("OneDrive", str(doctor_instance.parent))
|
||||
_configure(doctor_instance)
|
||||
checks = {check.name: check.status for check in doctor.run_doctor()}
|
||||
assert checks["sync-folder"] == "WARN" and checks["backup"] == "WARN"
|
||||
assert all(status != "FAIL" for name, status in checks.items() if name in ("sync-folder", "backup"))
|
||||
|
||||
|
||||
def test_the_git_remote_warning_names_the_how_to(doctor_instance):
|
||||
assert "back-up-to-a-synced-folder.md" in _check("git-remote").fix
|
||||
|
||||
|
||||
def test_age_text():
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime(2026, 10, 12, 12, 0, tzinfo=timezone.utc)
|
||||
assert backup.age_text("2026-10-12T11:59:30+00:00", now) == "less than a minute old"
|
||||
assert backup.age_text("2026-10-12T09:00:00+00:00", now) == "3 h old"
|
||||
assert backup.age_text("2026-10-10T12:00:00+00:00", now) == "2 d old"
|
||||
assert backup.age_text("garbage", now) == "of unknown age"
|
||||
@@ -79,6 +79,7 @@ def test_the_telemetry_policy_reads_the_record():
|
||||
def test_one_record_per_section():
|
||||
assert [shape.key for shape in config_shapes.SETTINGS_SECTIONS] == [
|
||||
"telemetry", "remotes", "upload", "tasks", "superproductivity", "caldav", "sites", "fetch",
|
||||
"backup",
|
||||
]
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user