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

+51
View File
@@ -117,6 +117,32 @@ class TestArgumentParsing:
assert m_cleanup.call_args.kwargs['dry_run'] is True
assert m_init.call_args.kwargs['dry_run'] is True
def test_dry_run_does_not_acquire_lock(self, tmp_path):
"""
A dry-run must not take the backups lock at all - set_backups_lock()
writes PID/timestamp metadata to the lock file, which is itself a
mutation a dry-run must never make.
"""
backups_dir = tmp_path / "backups"
backups_dir.mkdir()
source = tmp_path / "src"
source.mkdir()
with mock.patch('sys.argv', ['cura-te-ipsum', '-b',
str(backups_dir), str(source),
'--dry-run']):
with mock.patch('curateipsum.backup.set_backups_lock') \
as m_lock:
with mock.patch('curateipsum.backup.cleanup_old_backups'):
with mock.patch('curateipsum.backup.initiate_backup'):
with mock.patch(
'curateipsum.backup.release_backups_lock') \
as m_release:
cli.main()
m_lock.assert_not_called()
m_release.assert_not_called()
def test_force_flag_passed_to_lock(self, tmp_path):
"""Should pass force=True to set_backups_lock when --force is set."""
backups_dir = tmp_path / "backups"
@@ -655,6 +681,31 @@ class TestRestoreSubcommand:
assert result == 0
assert not dest.exists()
def test_restore_dry_run_does_not_acquire_lock(self, tmp_path):
"""
Mirrors TestMain.test_dry_run_does_not_acquire_lock for restore:
restore --dry-run must not touch the lock either.
"""
backups_dir = tmp_path / "backups"
backups_dir.mkdir()
source = tmp_path / "src"
source.mkdir()
self._make_backup(backups_dir, source)
dest = tmp_path / "dest"
with mock.patch('sys.argv', ['cura-te-ipsum', 'restore', '-b',
str(backups_dir), '-n', str(dest)]):
with mock.patch('curateipsum.backup.set_backups_lock') \
as m_lock:
with mock.patch(
'curateipsum.backup.release_backups_lock') \
as m_release:
result = cli.main()
assert result == 0
m_lock.assert_not_called()
m_release.assert_not_called()
def test_restore_verify_passes_after_clean_restore(self, tmp_path):
backups_dir = tmp_path / "backups"
backups_dir.mkdir()
+121
View File
@@ -193,6 +193,127 @@ def test_dry_run_creates_no_backup(integration_dirs):
assert len(backups) == 0
def test_dry_run_leaves_backups_dir_byte_for_byte_unchanged(integration_dirs):
"""
A dry-run must never create a staging directory, hardlink, lock
metadata, or any other trace on disk - not even a dotfile. Compare
every entry's full stat() (not just name/existence) before and
after, so an in-place mutation of an existing entry (e.g. the
previous snapshot getting chmod'd) can't slip past a listing-only
check.
"""
backups_dir, source_dir = integration_dirs
(source_dir / "file.txt").write_text("original")
bk.initiate_backup(
sources=[str(source_dir)], backups_dir=str(backups_dir),
dry_run=False
)
def _snapshot_all_stats(root):
st = os.lstat(root)
result = {root: (st.st_mode, st.st_mtime_ns, st.st_ino, st.st_size)}
for dirpath, dirnames, filenames in os.walk(root):
for name in dirnames + filenames:
path = os.path.join(dirpath, name)
st = os.lstat(path)
result[path] = (st.st_mode, st.st_mtime_ns, st.st_ino,
st.st_size)
return result
before = _snapshot_all_stats(str(backups_dir))
# Change the source so the dry-run has something to report
# (create, rewrite, and permission-change candidates all included).
(source_dir / "file.txt").write_text("changed")
(source_dir / "new.txt").write_text("new")
bk.initiate_backup(
sources=[str(source_dir)], backups_dir=str(backups_dir),
dry_run=True
)
bk.cleanup_old_backups(backups_dir=str(backups_dir), dry_run=True)
after = _snapshot_all_stats(str(backups_dir))
assert before == after
def test_dry_run_cleanup_does_not_create_repo_id_file(integration_dirs):
"""
cleanup_old_backups(dry_run=True) must not create the repo-id file
as a side effect of computing what it would delete - that file's
on-disk creation is itself a mutation a dry-run must never make.
"""
backups_dir, source_dir = integration_dirs
(source_dir / "file.txt").write_text("content")
bk.initiate_backup(
sources=[str(source_dir)], backups_dir=str(backups_dir),
dry_run=False
)
# A real backup already creates the repo-id file (the manifest needs
# it); remove it to simulate a legacy repo predating that file, so a
# dry-run cleanup recreating it would actually be observable.
repo_id_path = os.path.join(str(backups_dir), bk.REPO_ID_FILE)
assert os.path.exists(repo_id_path)
os.remove(repo_id_path)
bk.cleanup_old_backups(backups_dir=str(backups_dir), dry_run=True,
keep_all=0)
assert not os.path.exists(repo_id_path)
def test_dry_run_reports_changes_against_previous_snapshot(integration_dirs):
"""
A dry-run's reported operations must reflect a real diff against the
latest snapshot, not just "everything is new" - proving the new
direct-comparison path (no throwaway staging copy) actually diffs
against the right target.
"""
backups_dir, source_dir = integration_dirs
(source_dir / "unchanged.txt").write_text("same")
(source_dir / "will_change.txt").write_text("before")
bk.initiate_backup(
sources=[str(source_dir)], backups_dir=str(backups_dir),
dry_run=False
)
(source_dir / "will_change.txt").write_text("after")
(source_dir / "brand_new.txt").write_text("new")
seen = []
latest_backup = bk._get_latest_backup(str(backups_dir))
bk._plan_backup([str(source_dir)], str(backups_dir), latest_backup,
fs_rsync_recording(seen))
# fs_rsync_recording() wraps fs.rsync() directly, so recorded paths
# are relative to the source root itself (the source-name prefix
# _plan_backup() adds for logging isn't part of what rsync() yields).
kinds = {(relpath, action) for relpath, action in seen}
assert ("will_change.txt", "REWRITE") in kinds
assert ("brand_new.txt", "CREATE") in kinds
assert not any(relpath == "unchanged.txt" for relpath, _ in seen)
def fs_rsync_recording(seen):
"""
Wrap the real fs.rsync() so a test can inspect exactly which
(relpath, action) pairs a dry-run would report, without depending
on log output.
"""
from curateipsum import fs
def _wrapped(src, dst, dry_run=False):
for relpath, action, msg in fs.rsync(src, dst, dry_run=dry_run):
seen.append((relpath, action.name))
yield relpath, action, msg
return _wrapped
def test_no_backup_if_no_changes(integration_dirs):
"""Test that no backup is created if nothing changed"""
backups_dir, source_dir = integration_dirs
+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."""