fix(client): make the disaster-recovery path actually work
Review found the read path scoped to the CURRENT device's uuid, which breaks the exact case snapshot mode exists for: when the laptop dies, the replacement machine has a new id, so snapshots printed 'no restore points yet' while the backups sat on the forge, and restore errored. Reproduced, then fixed by unscoping the READ only. Writing stays device-scoped (two machines must not overwrite each other) and pruning stays device-scoped (machine A must not apply its clock to machine B's refs); the docstring now says why the three differ. Also from the same review: - mirror mode printed a %cI timestamp that restore could not accept, so copying the first column looped the user back to snapshots. It now matches the log, and refuses an ambiguous timestamp (two commits in one second) with the candidate ids instead of guessing. - the size guard advised 'add a .gitignore' while measuring with a plain walk that ignored one. It now measures what git would sync, through a throwaway git dir outside the folder so a refused add leaves no .git behind. - get --all caught only SystemExit, so a RuntimeError from any git call abandoned the remaining repos. - get --all mapped alice/notes and bob/notes to one path and reported the second as 'already present'. Clashes now clone to <owner>-<name> and say so. - the prune clock was in-memory, so watch --once under launchd pruned every run. Persisted in config. 153 tests. Live-verified on the beta forge: machine A backed up uncommitted work and was deleted; machine B, different device id, cloned the repo, listed A's snapshot and restored both files.
This commit is contained in:
@@ -10,6 +10,7 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
@@ -578,8 +579,45 @@ class TestSnapshots(GitScenarioBase):
|
||||
ref = client.push_snapshot(self.local, self.DEV)
|
||||
snaps = client.list_snapshots(self.local, self.DEV)
|
||||
self.assertEqual([s["ref"] for s in snaps], [ref])
|
||||
# writing is device-scoped: the ref carries THIS device's id, so two
|
||||
# machines cannot overwrite each other
|
||||
self.assertEqual(snaps[0]["device"], self.DEV)
|
||||
self.assertEqual(client.list_snapshots(self.local, "someone-else"), [])
|
||||
|
||||
def test_a_new_machine_can_see_the_dead_machine_s_backups(self):
|
||||
"""The case the whole feature exists for. Reads must NOT be scoped to
|
||||
this device's id -- a replacement laptop has a new id, and scoping
|
||||
would show an empty list while the backups sit on the forge."""
|
||||
self.write(self.local, "a.txt", "committed")
|
||||
run_git(self.local, "add", "-A")
|
||||
run_git(self.local, "commit", "-m", "c")
|
||||
run_git(self.local, "push", "-u", "granthi", "main")
|
||||
self.write(self.local, "a.txt", "PRECIOUS UNCOMMITTED WORK")
|
||||
ref = client.push_snapshot(self.local, "laptop-that-died")
|
||||
|
||||
# a fresh clone standing in for the replacement machine
|
||||
newbox = os.path.join(self.tmp, "newbox")
|
||||
subprocess.run(["git", "clone", "-q", "--origin", "granthi",
|
||||
self.bare, newbox], check=True, capture_output=True,
|
||||
env=GIT_ENV)
|
||||
client.save_config({"gitea_base": self.tmp, "login": "alice",
|
||||
"token": "t", "device_id": "brand-new-laptop",
|
||||
"folders": {newbox: {"name": "cloud",
|
||||
"full_name": "alice/cloud",
|
||||
"branch": "main",
|
||||
"mode": "snapshot"}}})
|
||||
|
||||
seen = client.list_snapshots(newbox)
|
||||
self.assertEqual([s["ref"] for s in seen], [ref])
|
||||
self.assertEqual(seen[0]["device"], "laptop-that-died")
|
||||
|
||||
# and it can actually restore it
|
||||
dest = os.path.join(self.tmp, "recovered")
|
||||
client.cmd_restore(argparse.Namespace(folder=newbox, at=seen[0]["ts"],
|
||||
into=dest))
|
||||
with open(os.path.join(dest, "a.txt")) as f:
|
||||
self.assertEqual(f.read(), "PRECIOUS UNCOMMITTED WORK")
|
||||
|
||||
|
||||
class TestRetention(unittest.TestCase):
|
||||
"""Retention is what keeps 30-second backups from being a disk leak --
|
||||
@@ -900,5 +938,169 @@ class TestCredentialHelperIsolation(GitScenarioBase):
|
||||
self.assertNotIn("stale-token", out.stdout)
|
||||
|
||||
|
||||
class TestMirrorRestore(GitScenarioBase):
|
||||
"""Mirror is the default for a plain folder, so its restore path is the
|
||||
one most people will use -- and the timestamp `snapshots` prints has to
|
||||
be a timestamp `restore` accepts, or the user just loops."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
client.save_config({"gitea_base": self.tmp, "login": "alice",
|
||||
"token": "t", "device_id": "d1",
|
||||
"folders": {self.local: {
|
||||
"name": "cloud", "full_name": "alice/cloud",
|
||||
"branch": "main", "mode": "mirror"}}})
|
||||
|
||||
def test_restore_accepts_the_timestamp_snapshots_printed(self):
|
||||
self.write(self.local, "a.txt", "the version I want back")
|
||||
client.sync_folder(self.local, mode="mirror")
|
||||
time.sleep(1.1) # %cI has one-second resolution
|
||||
self.write(self.local, "a.txt", "later junk")
|
||||
client.sync_folder(self.local, mode="mirror")
|
||||
|
||||
with mock.patch("sys.stdout", new_callable=io.StringIO) as out:
|
||||
client.cmd_snapshots(argparse.Namespace(folder=self.local,
|
||||
limit=20))
|
||||
listed = [l.split() for l in out.getvalue().splitlines()
|
||||
if l.startswith(" ")]
|
||||
wanted_ts = listed[-1][0] # first column of the oldest entry
|
||||
|
||||
dest = os.path.join(self.tmp, "mirror-restore")
|
||||
client.cmd_restore(argparse.Namespace(folder=self.local,
|
||||
at=wanted_ts, into=dest))
|
||||
with open(os.path.join(dest, "a.txt")) as f:
|
||||
self.assertEqual(f.read(), "the version I want back")
|
||||
|
||||
def test_two_commits_in_the_same_second_are_refused_not_guessed(self):
|
||||
"""%cI has one-second resolution. Picking one silently would restore
|
||||
something the user did not choose."""
|
||||
self.write(self.local, "a.txt", "first")
|
||||
client.sync_folder(self.local, mode="mirror")
|
||||
self.write(self.local, "a.txt", "second")
|
||||
client.sync_folder(self.local, mode="mirror")
|
||||
stamps = run_git(self.local, "log", "--format=%cI").splitlines()
|
||||
if len(set(stamps)) != 1:
|
||||
self.skipTest("commits did not land in the same second")
|
||||
with self.assertRaises(SystemExit) as caught:
|
||||
client.cmd_restore(argparse.Namespace(folder=self.local,
|
||||
at=stamps[0], into=None))
|
||||
self.assertIn("matches 2 restore points", str(caught.exception))
|
||||
|
||||
def test_restore_still_accepts_a_sha(self):
|
||||
self.write(self.local, "a.txt", "one")
|
||||
client.sync_folder(self.local, mode="mirror")
|
||||
sha = run_git(self.local, "rev-parse", "HEAD")
|
||||
dest = os.path.join(self.tmp, "by-sha")
|
||||
client.cmd_restore(argparse.Namespace(folder=self.local, at=sha,
|
||||
into=dest))
|
||||
self.assertTrue(os.path.exists(os.path.join(dest, "a.txt")))
|
||||
|
||||
def test_default_destination_is_a_usable_path(self):
|
||||
self.write(self.local, "a.txt", "one")
|
||||
client.sync_folder(self.local, mode="mirror")
|
||||
with mock.patch("sys.stdout", new_callable=io.StringIO) as out:
|
||||
client.cmd_snapshots(argparse.Namespace(folder=self.local,
|
||||
limit=5))
|
||||
ts = [l.split() for l in out.getvalue().splitlines()
|
||||
if l.startswith(" ")][0][0]
|
||||
client.cmd_restore(argparse.Namespace(folder=self.local, at=ts,
|
||||
into=None))
|
||||
made = [d for d in os.listdir(self.tmp) if d.startswith("local-restore-")]
|
||||
self.assertEqual(len(made), 1, made)
|
||||
self.assertNotIn(":", made[0])
|
||||
|
||||
|
||||
class TestMeasureHonoursGitignore(GitScenarioBase):
|
||||
"""The guard tells people to add a .gitignore. That advice has to work."""
|
||||
|
||||
def test_ignored_files_are_not_counted(self):
|
||||
os.makedirs(os.path.join(self.local, "bulkdata"))
|
||||
for i in range(30):
|
||||
self.write(self.local, f"bulkdata/x{i}.bin", "y" * 100)
|
||||
self.write(self.local, "real.txt", "mine")
|
||||
before, _ = client.measure_folder(self.local)
|
||||
self.write(self.local, ".gitignore", "bulkdata/\n")
|
||||
after, _ = client.measure_folder(self.local)
|
||||
self.assertGreater(before, 30)
|
||||
self.assertEqual(after, 2) # real.txt + .gitignore
|
||||
|
||||
def test_measure_leaves_no_git_dir_behind(self):
|
||||
plain = os.path.join(self.tmp, "untouched")
|
||||
os.makedirs(plain)
|
||||
self.write(plain, "a.txt", "x")
|
||||
client.measure_folder(plain)
|
||||
self.assertEqual(os.listdir(plain), ["a.txt"])
|
||||
|
||||
|
||||
class TestGetAllRobustness(GitScenarioBase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.forge = os.path.join(self.tmp, "forge")
|
||||
for full in ("alice/notes", "bob/notes"):
|
||||
path = os.path.join(self.forge, full + ".git")
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
subprocess.run(["git", "init", "-q", "--bare", "-b", "main", path],
|
||||
check=True, capture_output=True, env=GIT_ENV)
|
||||
seed = os.path.join(self.tmp, "seed-" + full.replace("/", "-"))
|
||||
subprocess.run(["git", "clone", "-q", path, seed], check=True,
|
||||
capture_output=True, env=GIT_ENV)
|
||||
run_git(seed, "config", "user.name", "s")
|
||||
run_git(seed, "config", "user.email", "s@s")
|
||||
self.write(seed, "who.txt", full)
|
||||
run_git(seed, "add", "-A")
|
||||
run_git(seed, "commit", "-m", "seed")
|
||||
run_git(seed, "push", "-q", "origin", "main")
|
||||
client.save_config({"gitea_base": self.forge, "login": "alice",
|
||||
"token": "t", "folders": {}})
|
||||
self.repos = [{"name": "notes", "full_name": "alice/notes"},
|
||||
{"name": "notes", "full_name": "bob/notes"}]
|
||||
|
||||
def test_same_named_repos_from_two_owners_both_land(self):
|
||||
into = os.path.join(self.tmp, "ws")
|
||||
os.makedirs(into)
|
||||
with mock.patch.object(client, "list_repos",
|
||||
lambda c: (self.repos, False)), \
|
||||
mock.patch("sys.stdout", new_callable=io.StringIO) as out:
|
||||
client.cmd_get(get_ns(all=True, into=into))
|
||||
self.assertIn("name clash", out.getvalue())
|
||||
with open(os.path.join(into, "notes", "who.txt")) as f:
|
||||
self.assertEqual(f.read(), "alice/notes")
|
||||
with open(os.path.join(into, "bob-notes", "who.txt")) as f:
|
||||
self.assertEqual(f.read(), "bob/notes")
|
||||
self.assertEqual(len(client.load_config()["folders"]), 2)
|
||||
|
||||
def test_a_git_failure_on_one_repo_does_not_abandon_the_rest(self):
|
||||
into = os.path.join(self.tmp, "ws2")
|
||||
os.makedirs(into)
|
||||
real_clone = client.clone_one
|
||||
|
||||
def flaky(cfg, full_name, dest, mode=None):
|
||||
if full_name == "alice/notes":
|
||||
raise RuntimeError("git clone failed: pretend network blip")
|
||||
return real_clone(cfg, full_name, dest, mode)
|
||||
|
||||
with mock.patch.object(client, "list_repos",
|
||||
lambda c: (self.repos, False)), \
|
||||
mock.patch.object(client, "clone_one", flaky), \
|
||||
mock.patch("sys.stdout", new_callable=io.StringIO) as out:
|
||||
rc = client.cmd_get(get_ns(all=True, into=into))
|
||||
self.assertEqual(rc, 1) # reported, not hidden
|
||||
self.assertIn("FAILED alice/notes", out.getvalue())
|
||||
self.assertTrue(os.path.exists(os.path.join(into, "notes", "who.txt")))
|
||||
|
||||
|
||||
class TestPruneClockIsPersisted(GitScenarioBase):
|
||||
def test_watch_once_does_not_prune_every_run(self):
|
||||
client.save_config({"gitea_base": self.tmp, "login": "alice",
|
||||
"token": "t", "device_id": "d1", "folders": {}})
|
||||
with mock.patch.object(client, "prune_snapshots") as pruner:
|
||||
client.watch_pass(now=1_000_000.0)
|
||||
self.assertEqual(client.load_config()["last_prune"], 1_000_000.0)
|
||||
client.watch_pass(now=1_000_060.0) # a minute later: not due
|
||||
client.watch_pass(now=1_003_700.0) # an hour later: due again
|
||||
self.assertEqual(client.load_config()["last_prune"], 1_003_700.0)
|
||||
self.assertEqual(pruner.call_count, 0) # no snapshot folders linked
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user