Make dry-run genuinely non-mutating (#23)

* Make dry-run genuinely non-mutating

fs.rsync() now takes a real dry_run code path: every mutating call
(copy, delete, chmod, chown, utime) is skipped, and a missing dst_dir
is never created - the same comparison logic still runs so the yielded
actions match what a real run would do.

backup.initiate_backup() diffs each source directly against the latest
completed snapshot on dry-run, instead of building and discarding a
throwaway staging directory with real hardlinks. cleanup_old_backups()
no longer creates the repo-id file as a side effect of a dry-run.

cli.py skips lock acquisition entirely on dry-run (set_backups_lock()
writes PID/timestamp metadata, itself a mutation) and exposes the
retention policy via --keep-all/--keep-daily/--keep-weekly/
--keep-monthly/--keep-yearly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Address Copilot review: guard restore lock release, reject negative retention values

- Guard restore --dry-run's finally block the same way backup's is
  guarded, for symmetry (release_backups_lock() was already a safe
  no-op when unheld, but this makes the dry-run contract explicit
  rather than relying on that implementation detail).
- Reject negative --keep-* values via a dedicated argparse type: a
  negative threshold shifts retention comparisons into the future and
  can make cleanup treat every existing backup as deletable.
- Add a restore --dry-run lock-skip regression test, mirroring the
  existing backup one.
- Fix _snapshot_all_stats() in the new dry-run non-mutation test to
  also record the backups_dir root's own stat, not just its children.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Maxim Shulga <maxim.shulga@alludo.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-08-09 01:39:53 +00:00
1 parent 699c569cc2
commit 22757ece58
8 files changed
+478 -51

No files matched your search

+83
View File
@@ -358,6 +358,89 @@ class TestRsyncBasic:
assert dst_stat.st_mode == src_stat.st_mode
class TestRsyncDryRun:
"""
fs.rsync(dry_run=True) must yield the same actions a real run would,
while never creating, deleting, or modifying anything - including
dst_dir itself when it doesn't already exist.
"""
@pytest.fixture
def rsync_dirs(self, tmp_path):
src_dir = os.path.join(str(tmp_path), "source")
dst_dir = os.path.join(str(tmp_path), "dest")
os.mkdir(src_dir)
return src_dir, dst_dir
def test_dry_run_does_not_create_missing_destination(self, rsync_dirs):
src_dir, dst_dir = rsync_dirs
with open(os.path.join(src_dir, "file.txt"), "w") as f:
f.write("content")
actions = list(fs.rsync(src_dir, dst_dir, dry_run=True))
assert not os.path.exists(dst_dir)
assert [a[1] for a in actions] == [fs.Actions.CREATE]
def test_dry_run_does_not_copy_new_files(self, rsync_dirs):
src_dir, dst_dir = rsync_dirs
os.mkdir(dst_dir)
with open(os.path.join(src_dir, "file.txt"), "w") as f:
f.write("content")
actions = list(fs.rsync(src_dir, dst_dir, dry_run=True))
assert not os.path.exists(os.path.join(dst_dir, "file.txt"))
assert [a[1] for a in actions] == [fs.Actions.CREATE]
def test_dry_run_does_not_delete_stale_files(self, rsync_dirs):
src_dir, dst_dir = rsync_dirs
os.mkdir(dst_dir)
stale = os.path.join(dst_dir, "stale.txt")
with open(stale, "w") as f:
f.write("stale")
actions = list(fs.rsync(src_dir, dst_dir, dry_run=True))
assert os.path.exists(stale)
assert [a[1] for a in actions] == [fs.Actions.DELETE]
def test_dry_run_does_not_rewrite_changed_files(self, rsync_dirs):
src_dir, dst_dir = rsync_dirs
os.mkdir(dst_dir)
src_file = os.path.join(src_dir, "file.txt")
dst_file = os.path.join(dst_dir, "file.txt")
with open(src_file, "w") as f:
f.write("updated content")
with open(dst_file, "w") as f:
f.write("modified")
actions = list(fs.rsync(src_dir, dst_dir, dry_run=True))
with open(dst_file, "r") as f:
assert f.read() == "modified"
assert [a[1] for a in actions] == [fs.Actions.REWRITE]
def test_dry_run_does_not_change_permissions(self, rsync_dirs):
src_dir, dst_dir = rsync_dirs
os.mkdir(dst_dir)
src_file = os.path.join(src_dir, "script.sh")
dst_file = os.path.join(dst_dir, "script.sh")
with open(src_file, "w") as f:
f.write("content")
with open(dst_file, "w") as f:
f.write("content")
os.utime(dst_file, ns=(os.stat(src_file).st_atime_ns,
os.stat(src_file).st_mtime_ns))
os.chmod(src_file, 0o755)
os.chmod(dst_file, 0o644)
actions = list(fs.rsync(src_dir, dst_dir, dry_run=True))
assert os.stat(dst_file).st_mode & 0o777 == 0o644
assert [a[1] for a in actions] == [fs.Actions.UPDATE_PERM]
class TestParseRsyncOutput:
"""Test _parse_rsync_output() parsing of rsync --itemize-changes."""