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.
+71 -6
View File
@@ -29,6 +29,20 @@ def _normalize_argv(argv):
return ["backup", *argv]
def _nonneg_int(value: str) -> int:
"""
argparse type for --keep-* options: a negative threshold shifts
retention comparisons into the future, which can make cleanup treat
every existing backup as eligible for deletion.
"""
parsed = int(value)
if parsed < 0:
raise argparse.ArgumentTypeError(
"must be zero or a positive integer, got %r" % value
)
return parsed
def _build_parser():
parser = argparse.ArgumentParser(
prog="cura-te-ipsum",
@@ -72,6 +86,40 @@ def _build_parser():
default=False,
help="Use cp command for creating hardlink "
"copies")
backup_parser.add_argument("--keep-all",
type=_nonneg_int,
default=7,
metavar="DAYS",
help="keep every backup up to this many days "
"in the past (default: %(default)s)")
backup_parser.add_argument("--keep-daily",
type=_nonneg_int,
default=30,
metavar="DAYS",
help="keep one backup per day up to this "
"many days in the past "
"(default: %(default)s)")
backup_parser.add_argument("--keep-weekly",
type=_nonneg_int,
default=52,
metavar="WEEKS",
help="keep one backup per week up to this "
"many weeks in the past "
"(default: %(default)s)")
backup_parser.add_argument("--keep-monthly",
type=_nonneg_int,
default=12,
metavar="MONTHS",
help="keep one backup per month up to this "
"many months in the past "
"(default: %(default)s)")
backup_parser.add_argument("--keep-yearly",
type=_nonneg_int,
default=5,
metavar="YEARS",
help="keep one backup per year up to this "
"many years in the past "
"(default: %(default)s)")
backup_parser.add_argument("sources",
nargs="+",
metavar="SOURCE",
@@ -143,7 +191,11 @@ def _run_backup(args) -> int:
start_time = time.time()
if not backup.set_backups_lock(backups_dir_abs, args.force):
# A dry-run never writes anything, including lock metadata, so it
# doesn't need to acquire the lock at all - only a real run has to
# serialize against other backups/restores.
if not args.dry_run and not backup.set_backups_lock(
backups_dir_abs, args.force):
return 1
exit_code = 0
@@ -160,13 +212,21 @@ def _run_backup(args) -> int:
external_rsync=args.external_rsync,
external_hardlink=args.external_hardlink,
)
backup.cleanup_old_backups(backups_dir=backups_dir_abs,
dry_run=args.dry_run)
backup.cleanup_old_backups(
backups_dir=backups_dir_abs,
dry_run=args.dry_run,
keep_all=args.keep_all,
keep_daily=args.keep_daily,
keep_weekly=args.keep_weekly,
keep_monthly=args.keep_monthly,
keep_yearly=args.keep_yearly,
)
except backup.BackupFailedError as err:
_lg.error("Backup failed: %s", err)
exit_code = 1
finally:
backup.release_backups_lock(backups_dir_abs)
if not args.dry_run:
backup.release_backups_lock(backups_dir_abs)
end_time = time.time()
spent_time = end_time - start_time
@@ -189,7 +249,11 @@ def _run_restore(args) -> int:
# while it's still in progress. set_backups_lock() already logs why
# (e.g. "Previous backup is still in progress") - add restore-specific
# context here so a restore failure doesn't read like a backup one.
if not backup.set_backups_lock(backups_dir_abs, args.force):
# A dry-run never writes to dest_dir, so it doesn't need this
# protection - nothing it reads can be invalidated by a concurrent
# writer in a way that produces anything worse than a stale preview.
if not args.dry_run and not backup.set_backups_lock(
backups_dir_abs, args.force):
_lg.error("Could not acquire the backups lock, restore aborted "
"(use --force to wait for it instead)")
return 1
@@ -243,7 +307,8 @@ def _run_restore(args) -> int:
else:
_lg.info("Verified: restored content matches the manifest")
finally:
backup.release_backups_lock(backups_dir_abs)
if not args.dry_run:
backup.release_backups_lock(backups_dir_abs)
return exit_code
+64 -21
View File
@@ -439,6 +439,18 @@ def update_direntry(src_entry: os.DirEntry,
return copy_direntry(src_entry, dst_entry.path)
def _maybe_update(src_entry: os.DirEntry, dst_entry: os.DirEntry,
dry_run: bool) -> Optional[str]:
"""
update_direntry(), unless dry_run - in which case dst_entry is left
untouched and no digest is available (nothing was actually copied to
hash).
"""
if dry_run:
return None
return update_direntry(src_entry, dst_entry)
def rsync(src_dir,
dst_dir,
dry_run=False) -> Iterable[Tuple[str, Actions, str]]:
@@ -447,6 +459,14 @@ def rsync(src_dir,
Yield (path, action, error message) tuples.
Entries in dst_dir will be removed if not present in src_dir.
Analog of 'rsync --delete -irltpog'.
With dry_run=True, dst_dir is never created, written to, or mutated
in any way (no copy, delete, chmod, chown, or utime call runs) - the
same comparison logic that decides each action still runs, so the
yielded (path, action, message) tuples describe exactly what a real
run would do, but purely by reading src_dir and dst_dir. This lets a
dry run diff straight against the previous, immutable snapshot
instead of a throwaway hardlinked copy.
"""
_lg.debug("Rsync: %s -> %s", src_dir, dst_dir)
@@ -457,13 +477,18 @@ def rsync(src_dir,
raise BackupCreationError(
"Error during reading source directory: %s" % src_root_abs
)
if os.path.exists(dst_root_abs):
if not os.path.isdir(dst_root_abs):
raise BackupCreationError(
"Destination path is not a directory: %s" % dst_root_abs
)
else:
os.mkdir(dst_root_abs)
dst_exists = os.path.exists(dst_root_abs)
if dst_exists and not os.path.isdir(dst_root_abs):
raise BackupCreationError(
"Destination path is not a directory: %s" % dst_root_abs
)
if not dst_exists:
if dry_run:
# Nothing to compare against and nothing to create - every
# source entry below is naturally reported as a CREATE.
dst_root_abs = None
else:
os.mkdir(dst_root_abs)
# Create source map {rel_path: dir_entry}
src_files_map = {
@@ -472,7 +497,8 @@ def rsync(src_dir,
}
# process dst tree
for dst_entry in scantree(dst_root_abs, dir_first=False):
dst_iter = scantree(dst_root_abs, dir_first=False) if dst_root_abs else ()
for dst_entry in dst_iter:
rel_path = dst_entry.path[len(dst_root_abs) + 1:]
src_entry = src_files_map.get(rel_path)
@@ -480,6 +506,9 @@ def rsync(src_dir,
# remove dst entries not existing in source
if src_entry is None:
_lg.debug("Rsync, deleting: %s", rel_path)
if dry_run:
yield rel_path, Actions.DELETE, ""
continue
try:
rm_direntry(dst_entry)
yield rel_path, Actions.DELETE, ""
@@ -513,7 +542,7 @@ def rsync(src_dir,
" (src is a file, dst is not a file): %s",
rel_path)
try:
digest = update_direntry(src_entry, dst_entry)
digest = _maybe_update(src_entry, dst_entry, dry_run)
yield rel_path, Actions.REWRITE, digest or ""
except (OSError, BackupCreationError) as exc:
yield rel_path, Actions.ERROR, str(exc)
@@ -525,7 +554,7 @@ def rsync(src_dir,
" (src is a dir, dst is not a dir): %s",
rel_path)
try:
update_direntry(src_entry, dst_entry)
_maybe_update(src_entry, dst_entry, dry_run)
yield rel_path, Actions.REWRITE, ""
except (OSError, BackupCreationError) as exc:
yield rel_path, Actions.ERROR, str(exc)
@@ -537,7 +566,7 @@ def rsync(src_dir,
" (src is a symlink, dst is not a symlink): %s",
rel_path)
try:
update_direntry(src_entry, dst_entry)
_maybe_update(src_entry, dst_entry, dry_run)
yield rel_path, Actions.REWRITE, ""
except (OSError, BackupCreationError) as exc:
yield rel_path, Actions.ERROR, str(exc)
@@ -547,7 +576,7 @@ def rsync(src_dir,
if src_entry.inode() == dst_entry.inode():
_lg.debug("Rsync, rewriting (shared inode with src): %s", rel_path)
try:
digest = update_direntry(src_entry, dst_entry)
digest = _maybe_update(src_entry, dst_entry, dry_run)
yield rel_path, Actions.REWRITE, digest or ""
except (OSError, BackupCreationError) as exc:
yield rel_path, Actions.ERROR, str(exc)
@@ -565,7 +594,7 @@ def rsync(src_dir,
_lg.debug("Rsync, rewriting (different %s): %s",
reason, rel_path)
try:
digest = update_direntry(src_entry, dst_entry)
digest = _maybe_update(src_entry, dst_entry, dry_run)
yield rel_path, Actions.REWRITE, digest or ""
except (OSError, BackupCreationError) as exc:
yield rel_path, Actions.ERROR, str(exc)
@@ -577,7 +606,7 @@ def rsync(src_dir,
_lg.debug("Rsync, rewriting (different symlink target): %s",
rel_path)
try:
update_direntry(src_entry, dst_entry)
_maybe_update(src_entry, dst_entry, dry_run)
yield rel_path, Actions.REWRITE, ""
except (OSError, BackupCreationError) as exc:
yield rel_path, Actions.ERROR, str(exc)
@@ -598,25 +627,30 @@ def rsync(src_dir,
# before mutating permissions/ownership in place, or the change
# would silently apply to every older snapshot sharing the inode.
if src_entry.is_file(follow_symlinks=False):
if src_stat.st_mode != dst_stat.st_mode or (
if not dry_run and (src_stat.st_mode != dst_stat.st_mode or (
src_stat.st_uid != dst_stat.st_uid
or src_stat.st_gid != dst_stat.st_gid):
or src_stat.st_gid != dst_stat.st_gid)):
_break_shared_hardlink(dst_entry.path)
# update permissions and ownership
if src_stat.st_mode != dst_stat.st_mode:
_lg.debug("Rsync, updating permissions: %s", rel_path)
os.chmod(dst_entry.path, src_stat.st_mode)
if not dry_run:
os.chmod(dst_entry.path, src_stat.st_mode)
yield rel_path, Actions.UPDATE_PERM, ""
if (src_stat.st_uid != dst_stat.st_uid
or src_stat.st_gid != dst_stat.st_gid):
_lg.debug("Rsync, updating owners: %s", rel_path)
try:
os.chown(dst_entry.path, src_stat.st_uid, src_stat.st_gid)
if dry_run:
yield rel_path, Actions.UPDATE_OWNER, ""
except PermissionError:
_lg.debug("Cannot change ownership (not root): %s", rel_path)
else:
try:
os.chown(dst_entry.path, src_stat.st_uid, src_stat.st_gid)
yield rel_path, Actions.UPDATE_OWNER, ""
except PermissionError:
_lg.debug("Cannot change ownership (not root): %s",
rel_path)
# process remained source entries (new files/dirs/symlinks)
for rel_path, src_entry in src_files_map.items():
@@ -627,6 +661,10 @@ def rsync(src_dir,
yield rel_path, Actions.EXCLUDE, src_kind
continue
if dry_run:
yield rel_path, Actions.CREATE, ""
continue
dst_path = os.path.join(dst_root_abs, rel_path)
_lg.debug("Rsync, creating: %s", rel_path)
try:
@@ -635,6 +673,11 @@ def rsync(src_dir,
except (OSError, BackupCreationError) as exc:
yield rel_path, Actions.ERROR, str(exc)
if dry_run:
# Nothing below this point does anything but restore directory
# mtimes disturbed by the mutations above - none of which ran.
return
# restore dir mtimes in dst, updated by updating files
for src_entry in scantree(src_root_abs, dir_first=True,
one_filesystem=True):