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:
8 files changed
+478
-51
No files matched your search
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user