DEV Community

Super Funicular
Super Funicular

Posted on

rsync --delete Emptied the Backup and Exited 0: Testing a Nightly Copy of Camera Clips

If a phone records clips all day and something on a computer copies them to a drive every night, the copy is usually one rsync line someone found in a forum. I tested the common versions of that line against a folder of fake clips, and the result I did not expect was this: the flag people add to make the backup tidy, --delete, will empty the backup completely the first night the source folder shows up empty. It exits with status 0 while it does it.

Disclosure: I make an Android camera app. Nothing here uses its code or describes how it names or stores files. Everything below runs on a computer you copy clips to, against files the scripts create themselves, with rsync 3.2.7 on Linux.

What the tests showed:

  • rsync -r copied every clip again on the second run. Same ten files, same 10,485,760 bytes, both nights. rsync -a moved nothing the second time.
  • A trailing slash decided the folder layout. Running the same copy once with clips/ and once with clips put two full copies on the drive, one of them inside a new clips folder.
  • --delete made the drive keep exactly what the phone keeps. Once the phone side cleared out clips a week old, the next copy cleared them off the drive too.
  • An empty source with --delete deleted all ten clips from the drive, exit status 0.
  • --max-delete=5 was a cap, not a stop. It deleted five of the ten before stopping.

The test folder

Ten fake clips of 1 MiB each, aged one to ten days, so the age-based steps later have something to act on.

#!/bin/bash
# Ten fake clips, 1 MiB of random bytes each, aged 1 to 10 days.
rm -rf clips && mkdir clips
for d in 1 2 3 4 5 6 7 8 9 10; do
  f=clips/clip_day$(printf %02d $d).mp4
  head -c 1048576 /dev/urandom > "$f"
  touch -d "$d days ago" "$f"
done
ls clips | wc -l
Enter fullscreen mode Exit fullscreen mode

Each script below starts by rebuilding this folder, so every result starts from the same ten files.

Without -t, every night is a full copy

The usual short form is rsync -r, recursive and nothing else. Here it is twice in a row, next to rsync -a twice in a row:

#!/bin/bash
# Same folder, copied twice with -r and twice with -a. How much moves on the second run?
./setup.sh >/dev/null
rm -rf bk_r bk_a
for run in 1 2; do
  rsync -r --stats clips/ bk_r/ > stats.txt
  echo "-r run $run: $(grep -E 'regular files transferred' stats.txt | cut -d: -f2) files, $(grep 'Total transferred file size' stats.txt | cut -d: -f2)"
done
for run in 1 2; do
  rsync -a --stats clips/ bk_a/ > stats.txt
  echo "-a run $run: $(grep -E 'regular files transferred' stats.txt | cut -d: -f2) files, $(grep 'Total transferred file size' stats.txt | cut -d: -f2)"
done
Enter fullscreen mode Exit fullscreen mode
-r run 1:  10 files,  10,485,760 bytes
-r run 2:  10 files,  10,485,760 bytes
-a run 1:  10 files,  10,485,760 bytes
-a run 2:  0 files,  0 bytes
Enter fullscreen mode Exit fullscreen mode

The second -r run sent the whole folder again. rsync decides whether a file needs copying by comparing its size and its modification time on both sides. Plain -r does not carry the modification time across, so every copy on the drive gets the time it was written, the times do not match, and every file looks changed. With ten small files that costs nothing. With a few weeks of footage it means the nightly job reads and writes every clip, every night.

-a is shorthand for a group of options. The one that matters here is -t, which keeps modification times. With the times kept, the second run found nothing to do.

Keeping times matters for a second reason. Any cleanup that runs on the drive and picks clips by age needs those ages to be real. I covered that from the find side in a test of find -mtime on a folder of camera clips, and I will not repeat it here.

The slash at the end of the source

rsync reads clips and clips/ differently. The manual puts it this way: "A trailing slash on the source changes this behavior to avoid creating an additional directory level at the destination."

That is easy to agree with and easy to forget at the keyboard. This script runs the copy once each way:

#!/bin/bash
# One night the command is typed with a trailing slash on the source, one night without.
./setup.sh >/dev/null
rm -rf drive && mkdir drive
rsync -a clips/ drive/
rsync -a clips  drive/
find drive -name '*.mp4' | sed 's|/clip_day.*||' | sort | uniq -c
du -sh drive | cut -f1
Enter fullscreen mode Exit fullscreen mode
     10 drive
     10 drive/clips
21M
Enter fullscreen mode Exit fullscreen mode

Ten clips at the top of the drive and ten more inside drive/clips, which doubled the space used. Nothing failed and nothing warned. If a cron line and a hand-typed line differ by that one character, the drive slowly holds two copies of everything. Neither copy is wrong, so no check ever flags it.

My habit now is to put the slash on both paths every time, clips/ drive/, and to write the line once in a script so nobody retypes it.

--delete turns a backup into a mirror

Forum answers often add --delete so the drive does not fill with files that no longer exist at the source. That is a fair goal. Here is what it does when the phone side runs its own one-week cleanup:

#!/bin/bash
# The phone side keeps one week: every clip a week old or more is deleted there.
# Then the nightly copy runs, once with --delete and once without.
./setup.sh >/dev/null
rm -rf keep mirror && rsync -a clips/ keep/ && rsync -a clips/ mirror/
find clips -name '*.mp4' -mtime +6 -delete
echo "source now:         $(ls clips | wc -l) clips"
echo "dry run of --delete:"
rsync -a --delete -n -i clips/ mirror/
rsync -a --delete clips/ mirror/
rsync -a          clips/ keep/
echo "with --delete:      $(ls mirror | wc -l) clips on the drive"
echo "without --delete:   $(ls keep | wc -l) clips on the drive"
Enter fullscreen mode Exit fullscreen mode
source now:         6 clips
dry run of --delete:
*deleting   clip_day10.mp4
*deleting   clip_day09.mp4
*deleting   clip_day08.mp4
*deleting   clip_day07.mp4
.d..t...... ./
with --delete:      6 clips on the drive
without --delete:   10 clips on the drive
Enter fullscreen mode Exit fullscreen mode

The dry run, -n with -i, listed the four clips it was about to remove, marked *deleting, plus one line for the drive folder's own timestamp. Then the real run removed them. The drive kept six clips, exactly the six the phone still had.

So a backup made this way can be no older than the source. If the reason for copying clips off a phone is to keep them longer than the phone can, --delete defeats the purpose. Leaving it off, the drive kept all ten.

There is a middle option. --backup-dir tells rsync to move anything it would delete or overwrite into another folder instead. In the manual's words, it tells rsync "to store all backups in the specified directory on the receiving side."

#!/bin/bash
# Same deletion, but --delete moves removed files aside instead of erasing them.
./setup.sh >/dev/null
rm -rf drive && mkdir drive && rsync -a clips/ drive/current/
find clips -name '*.mp4' -mtime +6 -delete
rsync -a --delete --backup --backup-dir=../removed-2026-10-17 clips/ drive/current/
echo "current: $(ls drive/current | wc -l) clips"
echo "removed: $(ls drive/removed-2026-10-17 | tr '\n' ' ')"
Enter fullscreen mode Exit fullscreen mode
current: 6 clips
removed: clip_day07.mp4 clip_day08.mp4 clip_day09.mp4 clip_day10.mp4 
Enter fullscreen mode Exit fullscreen mode

The current folder matches the phone, and the four clips that left the phone are sitting in a dated folder on the drive. A relative --backup-dir is resolved from the destination folder, which is why the path starts with ../. Clearing out old dated folders becomes a separate decision someone makes on purpose, instead of a side effect of the copy.

The night the source is empty

This is the case that made me write the article. A copy job points at a path. If the phone is not connected, the card reader is unplugged, or a network share did not come up, that path can still exist as an empty folder. rsync cannot tell the difference between a folder that is empty and a folder that is empty because nothing is mounted on it.

#!/bin/bash
# The card reader was not plugged in, so the source path is an empty folder.
./setup.sh >/dev/null
fresh() { rm -rf drive card && rsync -a clips/ drive/ && mkdir card; }
fresh; rsync -a --delete card/ drive/
echo "--delete:              exit $?, $(ls drive | wc -l) clips left on the drive"
fresh; rsync -a --delete --max-delete=5 card/ drive/ 2>/dev/null
echo "--delete --max-delete=5: exit $?, $(ls drive | wc -l) clips left on the drive"
fresh; rsync -a --delete --max-delete=0 card/ drive/ 2>/dev/null
echo "--delete --max-delete=0: exit $?, $(ls drive | wc -l) clips left on the drive"
Enter fullscreen mode Exit fullscreen mode
--delete:              exit 0, 0 clips left on the drive
--delete --max-delete=5: exit 25, 5 clips left on the drive
--delete --max-delete=0: exit 25, 10 clips left on the drive
Enter fullscreen mode Exit fullscreen mode

Plain --delete emptied the drive and reported success. A cron job that only checks the exit status would record a good night.

--max-delete looks like the guard for this, and the manual describes it accurately: "If that limit is exceeded, all further deletions are skipped through the end of the transfer." The word that matters is further. With a limit of five it still deleted five clips, half the drive, before stopping with exit status 25. A limit protects against a total loss. It does not prevent a partial one.

--max-delete=0 is the setting that deleted nothing. The manual says it reports extra files on the destination "without removing any of them", and it did exactly that, with exit status 25 so a script can notice.

A nightly script that refuses instead of guessing

Putting the pieces together: refuse to run when the source has no clips, keep times with -a, move removed clips aside with --backup-dir, cap deletions, and say so when rsync exits with anything other than 0.

#!/bin/bash
# nightly.sh SOURCE DRIVE -- copy clips to a drive without trusting the source blindly.
set -u
SRC="${1:?usage: nightly.sh SOURCE DRIVE}"
DST="${2:?usage: nightly.sh SOURCE DRIVE}"
[ -d "$DST" ] || { echo "refusing: $DST is not there"; exit 1; }
n=$(find "$SRC" -maxdepth 1 -name '*.mp4' | wc -l)
if [ "$n" -lt 1 ]; then
  echo "refusing: $SRC holds $n clips (not mounted?)"; exit 1
fi
rsync -a --delete --max-delete=20 \
      --backup --backup-dir="../removed-$(date +%F)" "$SRC/" "$DST/current/"
rc=$?
[ "$rc" -eq 0 ] || echo "rsync exit $rc -- look before the next run"
exit "$rc"
Enter fullscreen mode Exit fullscreen mode

And the test, once with the clips present and once with the empty card folder:

#!/bin/bash
./setup.sh >/dev/null
rm -rf drive card && mkdir card drive
./nightly.sh clips drive; echo "card present: exit $?, $(ls drive/current | wc -l) clips in current"
./nightly.sh card drive;  echo "card missing: exit $?, $(ls drive/current | wc -l) clips in current"
Enter fullscreen mode Exit fullscreen mode
card present: exit 0, 10 clips in current
refusing: card holds 0 clips (not mounted?)
card missing: exit 1, 10 clips in current
Enter fullscreen mode Exit fullscreen mode

With the card missing, the script refused before rsync ran, and the drive still held all ten clips.

One thing went wrong on my first attempt at this test, and it is worth knowing. I forgot to create the drive folder, and rsync failed with exit status 11, reporting that it could not make drive/current because there was no such file or directory. rsync will create the last folder of the destination path, but not missing folders above it. That is why the script checks that the drive folder exists before it does anything else.

The limit of 20 is a number I picked for ten clips a day with some slack. It should be set from how many clips a normal night removes, so that an ordinary night stays under it.

Things I did not test

  • FAT and exFAT drives. Many USB sticks and SD cards use them. The manual says those filesystems store times with a 2-second resolution and suggests --modify-window=1 for them: "Specifying 1 is useful for copies to/from MS Windows FAT filesystems". My drive here is a plain Linux folder, so I have not seen what happens without it.
  • Whether the drive is really mounted. The script checks that the drive folder exists. An unmounted mount point is also a folder that exists, and in that case the clips would be copied onto the computer's own disk. On Linux, mountpoint -q checks for that. I did not exercise it, because my test drive is a plain folder.
  • A clip still being written. If the phone is mid-recording when the copy runs, the drive may get a partial file. With -a the next run sees a different size and copies it again, but I have not tested what a player makes of the partial copy in between.
  • Copies over a network. Everything here is local to one machine.

If you want a phone that records in the background so there is something to copy, the app I make is Background Camera RemoteStream on Google Play. The scripts above do not depend on it.

A copy job earns trust on the night something is missing, not on the nights everything is there.

Sources. Quotes are from the rsync(1) manual page, entries for the trailing-slash rule, --backup-dir, --max-delete and --modify-window. Test results come from the scripts above, run on one Linux machine with bash and rsync 3.2.7.

Top comments (0)