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

+20 -8
View File
@@ -71,6 +71,17 @@ Optional Arguments:
-v, --verbose Enable debug logging
--external-rsync Use external rsync instead of Python implementation
--external-hardlink Use cp/gcp command for hardlinking
--keep-all DAYS Keep every backup up to this many days in the past
(default: 7)
--keep-daily DAYS Keep one backup per day up to this many days in
the past (default: 30)
--keep-weekly WEEKS Keep one backup per week up to this many weeks in
the past (default: 52)
--keep-monthly MONTHS
Keep one backup per month up to this many months
in the past (default: 12)
--keep-yearly YEARS Keep one backup per year up to this many years in
the past (default: 5)
```
### Examples
@@ -159,7 +170,7 @@ cura-te-ipsum creates complete directory snapshots, but files that haven't chang
### Retention Policy
Default retention (configurable in code):
Default retention (configurable via the `--keep-*` CLI options above):
- **7 days**: Keep all backups
- **30 days**: Keep one backup per day
@@ -232,13 +243,14 @@ backups/
backend's hardlinker cannot carry forward into the next backup. Pick
one of `--external-rsync`/`--external-hardlink` or the Python
defaults and use it consistently for a given backup destination.
- **`--dry-run` behaves differently per backend, though neither leaves
a lasting change.** With `--external-rsync`, rsync itself avoids
writing to the destination (it still reads and stats files to
compute what would change). With the Python backend, files are still
fully copied into a throwaway staging directory, which is then
deleted - correct, but slower and more I/O-heavy than the external
backend's dry run.
- **`--dry-run` never writes anything, with either backend.** Neither
backend creates a staging directory, hardlink, lock, or any other
file or metadata update - each source is compared directly against
the latest completed snapshot (never a copy of it), and the proposed
create/rewrite/delete/exclude operations are logged. `--external-rsync`
runs real rsync with its own `--dry-run` flag (it still reads and
stats files to compute what would change); the Python backend performs
the same read-only comparison itself.
## Development