Disclosure first
I sell the five-line 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 decades, 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, /usr/bin/rsync reporting openrsync: protocol version 29 and rsync version 2.6.9 compatible. Worth naming that up front, because most hard-link backup recipes online assume rsync 3.x. --link-dest is advertised in openrsync's own usage line and it worked in every case below, but I verified --link-dest on the copy of rsync your Mac actually ships rather than trusting the recipe.
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:
$ ls -la backups
drwxr-xr-x 2026-09-30_1855
drwxr-xr-x 2026-09-30_1856
lrwxr-xr-x latest -> /tmp/bk/backups/2026-09-30_1856
# unchanged in both snapshots
$ stat -f 'ino=%i links=%l' backups/2026-09-30_1855/docs/file1.bin
ino=85983680 links=2
$ stat -f 'ino=%i links=%l' backups/2026-09-30_1856/docs/file1.bin
ino=85983680 links=2
# the one file I rewrote between runs
$ stat -f 'ino=%i links=%l' backups/2026-09-30_1855/docs/file0.bin
ino=85983679 links=1
$ stat -f 'ino=%i links=%l' backups/2026-09-30_1856/docs/file0.bin
ino=85984875 links=1
Same inode number in two snapshots, link count 2. That is the entire mechanism, and it is why the space arithmetic comes out like this.
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 backups # after run 1
1180
$ du -sk backups # after run 2, one 200 KB file changed
1376
$ du -sk backups # after run 3, only a text file changed
1380
$ du -sk backups/2026-09-30_1856 # what ONE snapshot reports on its own
1180
So: three full-looking snapshots occupy 1,380 KB. Three naive copies would be 3,540 KB. The second snapshot cost 196 KB of new bytes for a 200 KB changed file, and the third cost 4 KB for a 20-byte edit to one 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.
The other thing worth knowing is that this holds because du dedupes by inode within one invocation. du -sk on a single snapshot reports 1,180 KB for it, and reporting three of those side by side would be double counting. The honest number is the one measured across the whole backup root.
It also survives deletion, which is the reason to keep snapshots at all. I deleted gone.txt from the source between runs 2 and 3:
$ ls backups/2026-09-30_1852 # older snapshot
gone.txt stay.txt
$ ls backups/2026-09-30_1853 # snapshot after the deletion
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 live in that glob.
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 # src/state.txt contains ALPHA
[x] snapshot -> /tmp/snap4/bk/2026-09-30_1847
snapshots kept: 1
$ echo BETA > src/state.txt
$ /bin/bash snapshot.sh src bk # same minute
[x] snapshot -> /tmp/snap4/bk/2026-09-30_1847
snapshots kept: 1
$ ls -1d bk/20* | wc -l
1
$ cat bk/2026-09-30_1847/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 human who runs it twice because the first run seemed to hang does. The classic hard-link recipe dodges it by putting the date (not the time) in a name that is created once a day and failing if it exists; this script has no such guard, so calling it "the classic 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_1851 latest
$ /bin/bash snapshot.sh src bk 1
[x] snapshot -> /tmp/t4/bk/2026-09-30_1852
pruned 2 old snapshot(s)
$ ls -1 bk
2026-09-30_1852 latest
Two things pruned, one of them never created by this script. rm -rf, no prompt. 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 src/ dst/ # dry run, default quick check
Transfer starting: 2 files
$ rsync -avnc src/ dst/ # dry run, --checksum
Transfer starting: 2 files
f.txt
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 your own documents this almost never fires, because saving a file moves its mtime. It fires for anything that restores timestamps deliberately (some sync clients, tar -x replays, archive tools) and for coincidence at second granularity. -c fixes it and costs a full read of both trees on every run, which for a big folder is the opposite of why incremental backups are cheap. I left it off and wish I had documented the choice 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 disk, this is not a backup, it is history. One failed SSD takes both, one stolen laptop takes both, and any process running as you that can delete the source can also walk the backup root, because the snapshots are ordinary directories owned by your account with no immutability whatsoever. That also means it does not stop ransomware: encrypting your files changes size and mtime, so the next snapshot faithfully preserves the encrypted versions, and you are protected only until the pruned window runs out.
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 one APFS container. Hard links cannot span filesystems anyway, so a second physical disk is not merely better here, it is the only configuration in which the tool means what it appears to mean. Put $ROOT on an external drive or a NAS mount and the hard-link dedup still works, because all the linking happens among the snapshots on the destination side. Then it is one leg of a real backup, and you still want a second copy somewhere the laptop cannot reach.
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: the minute-resolution stamp and the 20* prune glob above are both live in the build you download, and neither has a guard. The scripts are written and tested on macOS; they should work on Linux, but I have not tested them there, and -c versus the default quick check is the kind of difference that would show up only on a machine I do not have. If you only want the --link-dest idea, the eight lines in this post are enough and you should keep them.
Top comments (0)