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

+64 -12
View File
@@ -637,14 +637,20 @@ def cleanup_old_backups(backups_dir: str,
)
backups_dir_abs = _canonical(backups_dir)
repo_id = _ensure_repo_id(backups_dir)
if dry_run:
# _ensure_repo_id() would create the repo-id file on first use -
# a mutation dry-run must never perform - and it isn't needed
# for anything below, since nothing is actually deleted here.
for backup in all_backups:
if backup not in keep:
_lg.info("Would remove old backup %s", backup.name)
return
repo_id = _ensure_repo_id(backups_dir)
for backup in all_backups:
if backup in keep:
continue
if dry_run:
_lg.info("Would remove old backup %s", backup.name)
continue
_quarantine_and_delete(backup, backups_dir_abs, repo_id)
@@ -750,6 +756,50 @@ def process_backed_entry(backup_dir: str,
dst_dir=os.path.join(backup_dir, DELTA_DIR))
def _plan_backup(sources, backups_dir: str,
latest_backup: Optional[os.DirEntry],
rsync_func) -> None:
"""
Print the operations a real run would perform, without creating,
deleting, or modifying anything: no staging directory, no lock,
no hardlinks, no metadata updates. Each source is diffed directly
against its counterpart in the latest completed snapshot (or
reported as entirely new, if there is no latest snapshot, or this
source wasn't part of it) - never against a throwaway copy.
"""
# A placeholder path that is guaranteed not to exist, used as the
# comparison target for a source with nothing to diff against yet.
# rsync_func(..., dry_run=True) treats a missing dst as "everything
# in src is new" and never creates it.
no_prior_copy = os.path.join(
backups_dir, "%sdry-run-%s" % (STAGING_PREFIX, uuid.uuid4().hex)
)
for src in sources:
src_abs = os.path.abspath(src)
src_name = os.path.basename(src_abs)
if latest_backup is not None:
dst_abs = os.path.join(latest_backup.path, src_name)
else:
dst_abs = os.path.join(no_prior_copy, src_name)
_lg.info("Dry-run: comparing %s against %s", src_abs, dst_abs)
for entry_relpath, action, msg in rsync_func(
src_abs, dst_abs, dry_run=True
):
full_relpath = os.path.join(src_name, entry_relpath)
if action == fs.Actions.ERROR:
_lg.error("Would fail to back up %s: %s",
full_relpath, msg)
continue
if action == fs.Actions.EXCLUDE:
_lg.info("Would exclude %s (%s)", full_relpath, msg)
continue
if action == fs.Actions.NOTHING:
continue
_lg.info("Would %s: %s", action.name.lower(), full_relpath)
def initiate_backup(sources,
backups_dir: str,
dry_run: bool = False,
@@ -765,6 +815,10 @@ def initiate_backup(sources,
never be mistaken for a successful one. On success, the staging
directory is fsync'd and atomically renamed into its final snapshot
name.
With dry_run=True, this only plans and prints proposed operations -
see _plan_backup(). No staging directory, hardlink, or file is ever
created, and no existing file is touched.
:param sources: list of directories to backup (relative paths ok)
:param backups_dir: directory where all backups are stored
:param dry_run: if True, no actual changes will be made
@@ -774,6 +828,11 @@ def initiate_backup(sources,
snapshot was created (dry-run or no changes since last backup).
:raises BackupFailedError: if the backup could not be completed.
"""
if dry_run:
_plan_backup(sources, backups_dir, _get_latest_backup(backups_dir),
fs.rsync_ext if external_rsync else fs.rsync)
return None
cleanup_stale_staging_dirs(backups_dir)
snapshot_name = _reserve_snapshot_name(backups_dir)
@@ -840,9 +899,7 @@ def initiate_backup(sources,
dst_abs = os.path.join(staging.path, src_name)
_lg.info("Backing up directory %s to staging for %s",
src_abs, snapshot_name)
for entry_relpath, action, msg in rsync_func(
src_abs, dst_abs, dry_run=dry_run
):
for entry_relpath, action, msg in rsync_func(src_abs, dst_abs):
if action == fs.Actions.ERROR:
raise BackupFailedError(
"Failed to copy %s: %s" % (entry_relpath, msg)
@@ -889,11 +946,6 @@ def initiate_backup(sources,
shutil.rmtree(staging.path, ignore_errors=True)
raise
# do not create backup on dry-run
if dry_run:
_lg.info("Dry-run, removing staging directory: %s", snapshot_name)
shutil.rmtree(staging.path, ignore_errors=True)
return None
# do not create backup if no change from previous one - but a change
# in *which* entries are excluded is itself a change worth keeping,
# or the new exclusions would never make it into a written manifest.