irsync
Rename-aware Python wrapper around rsync. When files are moved or renamed on
the source drive, the original rsync deletes the old path on the backup drive
and re-transfers the (often huge) file from scratch. irsync avoids that by
keeping an inode snapshot at the root of the source tree, comparing it against
a fresh snapshot on each run, and replaying just the moves/renames atomically
on the backup tree before invoking rsync for the actual content delta. When
no source changes are detected at all, the backup drive is never accessed.
Installation
pip install irsync
irsync calls the system rsync binary, so rsync must be installed and on
your PATH. Its only Python dependency is
drivecfg, which supplies the optional
drive shorthands described under Configuration.
Quick usage
Installation puts an irsync command on your PATH (equivalently,
python -m irsync):
# First backup of a drive: snapshots both sides, then runs rsync.
irsync /mnt/data /mnt/data_backup --yes
# Subsequent run with files renamed on /mnt/data:
# the inode diff is computed, the renames are replayed on /mnt/data_backup
# using os.rename, and rsync handles only the actual content changes.
irsync /mnt/data /mnt/data_backup --yes
# Subsequent run with no source changes: the backup drive is never touched.
irsync /mnt/data /mnt/data_backup --yes
# Force a full rsync run even when the snapshot says nothing changed.
irsync /mnt/data /mnt/data_backup --yes --force
# Take a baseline snapshot without backing up (e.g. before a big reorg).
irsync /mnt/data --snapshot-only
# Skip the inode logic entirely and run a plain delete-before rsync.
irsync /mnt/data /mnt/data_backup --no-snapshot
Everything above works on plain paths and needs no configuration at all.
Configuration
irsync can also take shorthands — a drive id, a named endpoint such as ~ or
mypython, or ALL for a whole-machine run — instead of a source and
destination. Those names mean nothing on their own, so they are read from a
drivecfg config file. irsync loads it
lazily, and how lazily depends on the invocation:
irsync SOURCE DEST, two plain paths, never looks for one. This is the only shape that skips discovery entirely.- A shorthand (a drive id, an endpoint name, or
ALL) always looks for one, whether or not a destination was also given, and refuses with exit code 2 if none is found, naming every location it tried. - A single path with no destination also always looks for one, even though it might turn out to be an ordinary directory rather than a configured name — irsync has to check before it can tell the difference — and refuses the same way if none is found.
--snapshot-onlyis the one case that looks without requiring: it consults a config if one exists (so a configured name still resolves), but a missing file is not an error there — it falls back to treating the argument as a literal path.
Naming a config explicitly, with --config or $DRIVECFG_CONFIG, always
triggers a load attempt and always refuses if that specific file is missing,
regardless of which of the above shapes it's attached to.
The file is discovered from --config PATH, then $DRIVECFG_CONFIG, then
$XDG_CONFIG_HOME/drivecfg/drives.toml (usually
~/.config/drivecfg/drives.toml). A short example, with invented drive ids:
schema_version = 1
base_dir = "/media/alice"
drives = [
{ id = "A", enclosure = "desk-dock" },
{ id = "B", dir = "photos", backup_dir = "photos_backup" },
{ id = "C", backup = false },
]
# What `irsync ALL` runs, in order. "*drives" expands to every drive
# with backup = true, in file order.
backup_order = ["mypython", "*drives", "~"]
[endpoints."~"]
source = { path = "~" }
dest = { drive = "A", path = "home_backup" }
[endpoints.mypython]
source = { drive = "B", path = "projects/python" }
dest = { drive = "A", path = "python_backup" }
With that file in place:
irsync A # /media/alice/A -> /media/alice/A_backup
irsync B # /media/alice/photos -> /media/alice/photos_backup
irsync mypython # the configured source/destination pair
irsync ALL --yes # every entry of backup_order, in order
A drive id that the config does not define is refused with exit code 2 and a
message naming the drives that are configured — irsync never guesses a path
for an unknown name. base_dir is used only as the mount-gate root (see
Safety); when no config is loaded it defaults to
/media/<your-username>, which can refuse a run but can never redirect one.
Safety
irsync fails closed: when it cannot establish that an endpoint is the drive
you meant, it refuses with exit code 2 instead of proceeding. The motivating
case is a removable drive that isn't mounted — its mountpoint directory still
exists and is empty, which every other check reads as a legitimate empty
tree. Left unchecked, an empty source lets rsync --delete-before erase
the destination, and irsync would exit 0 and call it a successful backup.
- The drive is not mounted. Any endpoint that resolves under the media
base directory (e.g.
/media/<user>/A) must sit on a mounted filesystem; the base directory itself is exempt, since it is the ordinary directory that contains mountpoints, not a mountpoint itself.--require-mountextends this check to endpoints outside the base directory too (the base directory stays exempt either way).--allow-unmountedproceeds anyway. - A first backup would destroy data. When there is no usable snapshot
baseline — none exists yet, or an existing one is rejected because it
belongs to a different source tree — a local destination that already
holds files other than irsync's own snapshot/lock is refused, because
rsync --delete-beforewould remove them. A remote destination is never scanned, so this guard only applies locally.--allow-nonempty-destadopts the destination anyway. - A first backup would come from an empty source. Also gated on there
being no usable baseline: a source with no entries besides irsync's own
reserved files is never a legitimate backup — it is the signature of an
unmounted or mistyped source — so the run refuses regardless of what the
destination holds. Firing only when there is no baseline keeps this
independent of the >50% deletion guard below: a genuine "I deleted
everything" run, with a baseline in place, is handled by that guard
instead.
--allow-empty-sourceproceeds anyway. - A run would delete more than half of what's recorded. Independent of
the above, if the diff says more than 50% of the previously-recorded
entries are gone, the run refuses — usually a swapped-source or
stale-snapshot accident.
--allow-massive-deleteoverrides it.--forcedoes not — it only means "run rsync even though the snapshot diff found no changes." --dry-runchanges nothing. Honored on both the normal (snapshot) path and--no-snapshot: irsync shows what rsync would do and exits 0 without touching the destination or persisting a snapshot, on either path. Note that the empty-source refusal above is deliberately not--dry-run-exempt (a preview of an empty source is a wall of deletions, which conveys less than the refusal message does), so it still fires first even under--dry-run; the nonempty-destination refusal, by contrast, is exempt and merely previews.
| Flag | Effect |
|---|---|
--allow-unmounted |
proceed even if the drive is not mounted |
--allow-nonempty-dest |
adopt a destination that already holds files, on a baseline-less run |
--allow-empty-source |
proceed with a baseline-less run even though the source is empty |
--allow-massive-delete |
proceed when the diff would delete more than half of the recorded entries |
--require-mount |
also apply the mount check to endpoints outside the media base directory |
--force |
run rsync even when the snapshot diff found no changes (nothing else) |
Each flag disarms exactly one guard, so a cron line cannot silently lose a protection it did not name. If a refusal fires on a drive you expect to be attached, check that it is actually mounted before reaching for a flag.
An ALL run treats an unmounted source as a skip, not an error: the
configured list names every drive you might ever attach, so missing ones are
expected. An unmounted destination is different and counts as a real
error: if the source drive is mounted but its backup drive is not, the
run would back up nothing while still looking like success, which is exactly
what this guard exists to prevent. Either way the batch continues to the next
drive rather than stopping. A run with only skips still exits 0, naming both
the skipped and the successfully backed-up drives in its summary; a run with
at least one unmounted destination (or any other real error) exits 1. A
non-zero exit from ALL means a backup actually failed (1) or you aborted at
the prompt (130).
Project structure
src/irsync/
paths.py # is_rsync_remote, ensure_local_dir, with_trailing_slash
snapshot.py # snapshot_tree, write_snapshot, read_snapshot, SNAPSHOT_FILENAME
statx.py # ctypes statx(2) wrapper — inode birth time (btime)
preflight.py # mount / empty-source / nonempty-dest / rsync-present guards
diff.py # compute_changes, plan_directory_moves (cycle-safe)
replay.py # apply_moves — atomic os.rename on the dest tree
rsync_runner.py # build_rsync_command, run_dry_run, run_real_sync
options.py # Options dataclass, drivecfg-backed resolve_endpoints/resolve_source
backup.py # the orchestrator: snapshot → diff → replay → rsync → persist
cli.py # argparse CLI
__main__.py # `python -m irsync` entry point
tests/ # pytest unit + end-to-end tests
Development
This project uses pixi for the dev environment:
pixi run test # pytest
pixi run lint # ruff check
pixi run format # ruff format
pixi run typecheck # mypy --strict
pixi run pre-commit run --all-files
drivecfg on PyPI
pyproject.toml declares drivecfg>=0.1,<1 as a real runtime dependency.
drivecfg 0.1.0 is published on PyPI,
so this resolves normally: CI's pip install .[dev] and the dev environment's
pixi install both install the real package. The formerly-required ordering
("publish drivecfg before pushing or tagging irsync") is satisfied and no
longer something a release needs to check.
The dev environment used to sidestep the missing package with a temporary
PYTHONPATH shim pointing at a sibling ../drivecfg/src checkout, wired into
pixi.toml and pyproject.toml. That shim has been removed now that
drivecfg installs normally; pixi.toml's [pypi-exclude-newer] carries a
scoped override so the workspace's cooldown doesn't block picking up
first-party releases the same day they're published.
License
Apache-2.0. See LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file irsync-0.1.0.tar.gz.
File metadata
- Download URL: irsync-0.1.0.tar.gz
- Upload date:
- Size: 192.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bbf740a8560450873356dcf578edd9213f4b5fe9dfc92eda79cdebc8338c47e3
|
|
| MD5 |
39c7a994308d88345b0d62e133069df8
|
|
| BLAKE2b-256 |
9889b9fd0890533a217eab219adc26d58e6ae132fdf8b687fd92f4860b0a8e69
|
Provenance
The following attestation bundles were made for irsync-0.1.0.tar.gz:
Publisher:
release.yml on killett/irsync
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
irsync-0.1.0.tar.gz -
Subject digest:
bbf740a8560450873356dcf578edd9213f4b5fe9dfc92eda79cdebc8338c47e3 - Sigstore transparency entry: 2735991880
- Sigstore integration time:
-
Permalink:
killett/irsync@724ff496a61841d3a92745b5b8db9be44bec9f27 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/killett
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@724ff496a61841d3a92745b5b8db9be44bec9f27 -
Trigger Event:
push
-
Statement type:
File details
Details for the file irsync-0.1.0-py3-none-any.whl.
File metadata
- Download URL: irsync-0.1.0-py3-none-any.whl
- Upload date:
- Size: 53.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e417a03cc3d7e318332db31de6cbbfed4d2e958cb017ff786c16e73c06565c69
|
|
| MD5 |
4447f99669849ae240021a41a1c89d75
|
|
| BLAKE2b-256 |
f3abca7b3c0431006f9b23aff3681e6323535db9131587b868afac31a798364d
|
Provenance
The following attestation bundles were made for irsync-0.1.0-py3-none-any.whl:
Publisher:
release.yml on killett/irsync
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
irsync-0.1.0-py3-none-any.whl -
Subject digest:
e417a03cc3d7e318332db31de6cbbfed4d2e958cb017ff786c16e73c06565c69 - Sigstore transparency entry: 2735991923
- Sigstore integration time:
-
Permalink:
killett/irsync@724ff496a61841d3a92745b5b8db9be44bec9f27 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/killett
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@724ff496a61841d3a92745b5b8db9be44bec9f27 -
Trigger Event:
push
-
Statement type: