Disclosure first
I sell the core of this post for $4 as snapshot.sh, part of DevToolkit (https://payhip.com/b/aEn8M), so I am not neutral. The flag that does the work is free and has been in rsync for a long time, and if you would rather write your own eight lines than pay me, that is a reasonable call.
Every measurement below is from one Apple Silicon Mac, run today: /bin/bash 3.2.57, and /usr/bin/rsync reporting this on the first two lines of --version:
openrsync: protocol version 29
rsync version 2.6.9 compatible
That is worth stating up front, because most rsync documentation is written against 3.x. openrsync advertises --link-dest in its own usage line, it worked in every case below, and it accepts -c, which I tested. Those are the only two flags this post depends on, and both were checked on the copy your Mac actually ships.
The one flag
# products/devtoolkit/snapshot.sh:14-23
STAMP=$(date +%Y-%m-%d_%H%M)
DEST="$ROOT/$STAMP"
LATEST="$ROOT/latest"
# link from last snapshot for hardlink-based dedup
LINK_ARG=""
if [ -d "$LATEST" ]; then LINK_ARG="--link-dest=$LATEST"; fi
rsync -a --delete $LINK_ARG "$SRC/" "$DEST/"
ln -sfn "$DEST" "$LATEST"
--link-dest=DIR tells rsync: before you copy a file, look at DIR, and if the same file is already there unchanged, make the new snapshot's entry a hard link to it instead of writing the bytes again. LATEST is a symlink to the previous snapshot, which is why the chain works. $LINK_ARG is deliberately unquoted at line 22, so that on the very first run, when no latest exists, it expands to no argument at all rather than to an empty one.
Three snapshots later, the directory looks like a rotation and the inodes say something more interesting. This is the state after run 2:
$ ls -la backups
drwxr-xr-x@ 4 aarivpatel wheel 128 Sep 30 18:55 2026-09-30_1855
drwxr-xr-x@ 4 aarivpatel wheel 128 Sep 30 18:55 2026-09-30_1856
lrwxr-xr-x@ 1 aarivpatel wheel 31 Sep 30 18:56 latest -> /tmp/bk/backups/2026-09-30_1856
stat -f 'ino=%i links=%l' on both snapshots gives the pair that proves the mechanism. First the file that did not change, then the one 200,000-byte file I rewrote between the two runs:
docs/file1.bin (unchanged) S1 ino=85983680 links=2 | S2 ino=85983680 links=2
docs/file0.bin (changed) S1 ino=85983679 links=1 | S2 ino=85984875 links=1
Same inode number in two separate snapshots, link count 2. That is the entire mechanism, and it is why the space arithmetic comes out the way it does below.
The space, measured
Source tree: 7 files, 1,200,003 bytes, du -sk reports 1,180 KB. Run 1 copies everything. Then I rewrote exactly one 200,000-byte file and ran it again.
$ du -sk src # the source tree
1180 src
$ du -sk backups # backup root after run 2, one 200 KB file changed
1376 backups
$ du -sk backups # backup root after run 3, only a text file changed
1380 backups
$ du -sk backups/2026-09-30_1856 # what ONE snapshot reports on its own
1180 backups/2026-09-30_1856
So: three full-looking snapshots occupy 1,380 KB. Three ordinary copies of that folder would be 3,540 KB (3 x 1,180, arithmetic rather than measurement). Against the 1,180 KB the first snapshot needed, the second cost 196 KB of new bytes for one changed 200,000-byte file, and the third cost 4 KB for one changed text file. Each snapshot still reads as a complete copy of the folder when you cd into it, which is the point: restoring is cp -R, not a delta replay.
One measurement trap: du dedupes by inode within a single invocation, so three separate du -sk calls each report 1,180 KB for a snapshot and summing them double counts. The number that means anything is the one taken across the whole backup root.
It also survives deletion, which is the reason to keep snapshots at all. Two files in the source, then rm src/gone.txt, then another run a minute later:
$ ls bk/2026-09-30_1906
gone.txt
stay.txt
$ ls bk/2026-09-30_1907
stay.txt
--delete removes it from the new snapshot only. The old copy is a directory entry pointing at an inode that still has a link, so rm on one snapshot cannot empty it. That is genuinely useful for the "I deleted a folder yesterday" case, and it is the only protection here that I would describe as reliable.
"keep" is snapshots, not days
# products/devtoolkit/snapshot.sh:7-9
SRC="${1:?usage: snapshot.sh <source> <backup-root> [keep]}"
ROOT="${2:?usage: snapshot.sh <source> <backup-root> [keep]}"
KEEP="${3:-7}"
# products/devtoolkit/snapshot.sh:26-33
# prune old snapshots beyond KEEP
# portable: awk keeps all but the last KEEP lines (BSD head rejects negative -n counts)
count=0
while IFS= read -r old; do
rm -rf "$old"; count=$((count+1))
done < <(ls -1d "$ROOT"/20* 2>/dev/null | awk -v k="$KEEP" '{lines[NR]=$0} END {for (i=1; i<=NR-k; i++) print lines[i]}')
[ $count -gt 0 ] && echo "pruned $count old snapshot(s)"
echo "snapshots kept: $(ls -1d "$ROOT"/20* 2>/dev/null | wc -l | tr -d ' ')"
KEEP defaults to 7 and counts directories, not nights. The README in the pack calls the invocation ./snapshot.sh ~/projects /backups 7 "nightly backup, keep 7 days", and that is only true if you run it exactly once per day. Run it twice a day and 7 means 3.5 days. The sort is lexical over names, which does equal chronological order for the %Y-%m-%d_%H%M stamp, so the pruning itself picks the right victims: with three snapshots and keep 2 it removed the oldest and left the two newest intact and readable.
Two real problems lived in that glob, in the build I audited (v1.5). Both are fixed in v1.6, shipped 2026-09-30; the reproductions below are from v1.5 and are kept as they were measured, because they show exactly what the old code did and why the guards exist now.
The stamp is minute-resolution, so two runs in the same minute are one snapshot. DEST is not checked for existence. A second run inside the same minute rsyncs into the directory it just made, with --delete, and overwrites the history it was supposed to add:
$ /bin/bash snapshot.sh src bk
[x] snapshot -> bk/2026-09-30_1905
snapshots kept: 1
$ cat bk/20*/state.txt
ALPHA
$ echo BETA > src/state.txt
$ /bin/bash snapshot.sh src bk # still the same minute
[x] snapshot -> bk/2026-09-30_1905
snapshots kept: 1
$ ls -1d bk/20* | wc -l
1
$ cat bk/20*/state.txt
BETA
ALPHA is gone. The script printed a success line naming a snapshot directory, and there is only one directory, holding the newest state. A nightly cron never hits this, because cron fires once a day. A person who runs it twice because the first run looked slow does. Two lines would close it: second resolution in the date format, or a check that $DEST does not already exist before rsync runs. Neither is in the script, so describing this as the well-known hard-link rotation would be overselling it.
The prune glob matches anything in the root whose name starts with 20. ls -1d "$ROOT"/20* does not know which directories are snapshots. I put a folder called 2023-tax-docs in a backup root and ran with keep 1:
$ ls -1 bk
2023-tax-docs
2026-09-30_1905
latest
$ /bin/bash snapshot.sh src bk 1
[x] snapshot -> bk/2026-09-30_1906
pruned 2 old snapshot(s)
snapshots kept: 1
$ ls -1 bk
2026-09-30_1906
latest
Two things pruned, one of them never created by this script. rm -rf, no prompt, no dry run. That is a data-loss bug, not a quirk, and the fix is a stricter glob (match [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]_[0-9][0-9][0-9][0-9]) or a sidecar marker file per snapshot. The rule for now: nothing else lives in the backup root.
What counts as unchanged
This one is not a bug in the script so much as a property of rsync that bites silently. rsync -a compares size and mtime first, and only reads contents if those differ. I forced two files to the same 14-byte size and the same mtime, with different contents:
src: BBBB-CHANGED! size=14 mtime=Jan 1 00:00:00 2026
dst: AAAA-ORIGINAL size=14 mtime=Jan 1 00:00:00 2026
$ rsync -avn /tmp/qc/src/ /tmp/qc/dst/ # dry run, default quick check
Transfer starting: 2 files
$ rsync -avnc /tmp/qc/src/ /tmp/qc/dst/ # dry run, --checksum
Transfer starting: 2 files
f.txt
(Output trimmed to the lines that decide the argument; the byte-rate lines from each run are dropped.)
Without -c nothing is transferred. Run through snapshot.sh the same way, both snapshots came out holding AAAA-ORIGINAL while the source already said BBBB-CHANGED!. For documents you edit by hand this rarely fires, because saving moves the mtime. It fires for anything that restores timestamps deliberately, and for collisions at second granularity. -c fixes it and costs a full read of both trees every run, which is the opposite of why incremental backups are cheap. I left it off, and I should have put that reasoning in the script's own header rather than only here.
And the caveat that matters most
There is no check in snapshot.sh that $ROOT is on a different device than $SRC. It does not even look. Line 12 is mkdir -p "$ROOT", and that is the extent of the policy.
So: if the source and the backup root are on the same physical disk, this is history, not a backup. One failed SSD loses both, and a stolen laptop loses both. It is also not a defence against anything running as you: the snapshots are ordinary directories in your own account, with no immutability and no separate credentials, so a process that can write the source can walk the backup root too. A ransomware run does change sizes and mtimes, so the next snapshot faithfully preserves the encrypted versions, and what saves you is only that the pre-encryption snapshots are still on disk until they are pruned. With the default keep 7 and one run a day, that window is about a week, and it closes if the malware deletes directories as it goes.
The same-volume trap is easy to fall into on a Mac because everything looks separate:
$ stat -f '%d %N' /Users/aarivpatel/Documents /tmp
16777231 /Users/aarivpatel/Documents
16777231 /tmp
Those are the same APFS container, so pointing the backup root at /tmp, at another folder, or at a second internal volume on one physical drive buys you nothing against hardware failure. The good news is that a genuinely separate disk costs you nothing here: the hard links are created among the snapshots on the destination side, so dedup keeps working when $ROOT lives on an external drive or a NAS mount. That is the configuration where --link-dest means what it looks like. It is still one leg of a backup, not a backup, until something exists that the laptop cannot reach.
Update: both fixed
I wrote the two defects above as things I had measured and not changed. That was true when I wrote it, and it is the reason to keep the reproductions rather than quietly editing them. Here is what changed on 2026-09-30, in v1.6.
The same-minute collision now refuses. Before rsync runs, the script checks whether the destination already exists and stops without writing. Real output, second run inside the same minute:
$ echo BETA > src/state.txt
$ /bin/bash snapshot.sh src bk
error: '/tmp/verbatim.40025/bk/2026-09-30_2137' already exists (a snapshot was taken this same minute).
Nothing was written. Wait for the next minute, or pass a different backup root.
$ echo $?
1
$ cat bk/2026-09-30_2137/state.txt
ALPHA
The same harness against the old build printed a success line, exited 0, and left BETA in that directory. The new one exits 1 and the first snapshot still holds ALPHA.
The prune pattern is now the whole stamp shape. SNAP_GLOB='[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]_[0-9][0-9][0-9][0-9]', and each candidate has to be a directory before it is removed. Same test, keep 2, a 2023-tax-docs folder planted in the backup root:
old 2023-tax-docs DELETED 20*-prefixed dirs left: 2
new 2023-tax-docs SURVIVED 20*-prefixed dirs left: 4
One thing added that the post did not ask for. Hard links cannot cross filesystems, and a snapshot tree on the same volume as its source is not a backup against a dead disk - it is an undo history. The script now prints a warning when source and backup root share a device, and carries on, because same-volume snapshots are still worth having for the mistake you made this morning.
What did not change. Pruning still identifies snapshots by name, so a folder you name like a timestamp is treated as one. Dedup still works: after the fix, a second snapshot's unchanged file reports link count 2 and the same inode as the first, and two snapshots of a 4K source still occupy 4K.
How to tell which build you have. unzip -p devtoolkit-*.zip devtoolkit/snapshot.sh | grep -c SNAP_GLOB returns 3 on v1.6 and 0 on v1.5. The store serves v1.6 now. I cannot tell you what an old receipt's link would serve, because I have never tested that path - the store has had 0 orders, so there is no existing buyer to test it on, which is the only reason the swap is safe to have made.
Getting it
DevToolkit is $4, one-time purchase, instant download, 30-day refund: https://payhip.com/b/aEn8M. It is also in the $19 bundle at https://payhip.com/b/yTE86. snapshot.sh is one of five scripts; bulk-rename.sh is the only one that previews what it will do, so read the usage comment at the top of this one before pointing it at anything you care about.
The genuine limitations, stated plainly: as of v1.6 the minute-resolution stamp refuses a second run instead of overwriting it, and the prune matches the full YYYY-MM-DD_HHMM shape instead of a 20* prefix - both were live, unguarded defects in v1.5, described with reproductions above. See "Update: both fixed" below for what changed and how to tell which build you have. The scripts are written and tested on macOS; they should work on Linux, but I have not tested them there. If all you wanted was the --link-dest idea, the lines in this post are enough and you should keep them.
Top comments (0)