Prune a stale synced copy with an rsync checksum dry run
Contents
Compare an old one-way copy of another machine's tree with that machine by checksum, without moving any file contents, delete locally only the top-level entries whose files all exist on the source with the same checksum (git's .git/index and FETCH_HEAD aside), and revoke the temporary SSH key.
- Tested on
- Fedora 44 (kernel 7.2.10), rsync 3.5.1 protocol 33, GNU findutils 4.10.0, GNU Awk 5.3.2, against a loopback remote shell on synthetic trees (2026-10-25). Original run: a Fedora workstation against an older laptop over SSH (2026-10-23 or 2026-10-24, the day was not recorded); the rsync versions and the laptop's OS were not recorded.
- Not verified
- steps 1, 10 and 11 against a real SSH server, and step 2 over ssh (it ran through the loopback shell). No second machine was reachable for this manual; the key-removal command ran only against a fake
authorized_keysthrough the loopback shell, and step 11's identity check only asssh -G. - Needs
- bash, rsync on both machines, an SSH login on the source, and the source owner's permission to add a temporary key and remove it again. GNU find and xargs, awk and coreutils on this machine.
- Time
- about 30 min of hands-on steps (an estimate; the original run was not timed), plus one checksum pass over both trees.
- Risk
- step 8 deletes local entries for good. Every file in them matched the source by checksum at the step 7 re-check, except
.git/indexand.git/FETCH_HEADingit-noiseentries: Undo brings back the source's versions of those two, so the copy's own index, and anything staged in it, cannot be restored. Undo needs access to the source again. On the source, step 1 adds one key and step 10 removes it; the dry runs write nothing there, but they make it read every file that has the same size as its local counterpart.
Before you start#
- Placeholders used below:
<ABSOLUTE_PATH_OF_COPY>: root of the stale copy on this machine.<USER>@<HOST>: the SSH login on the source machine.<PATH_ON_SOURCE>: the tree the copy was made from, relative to that login's home or absolute.<KEY>: file name for the temporary key, for exampletmp-compare.<ENTRY>: one top-level entry name (Undo only).<ABSOLUTE_PATH_OF_SHIM>: where you saved the test shim (Notes only).
- Set them once, in the bash shell you use for every step (the commands rely on bash splitting an unquoted
$RSYNC_RSHinto words):LOCAL=<ABSOLUTE_PATH_OF_COPY> SRC=<USER>@<HOST> REMOTE=<PATH_ON_SOURCE> - Work from a directory outside
$LOCAL, for examplemkdir -p ~/copy-compare && cd ~/copy-compare. The steps writedry.txt,verdicts.tsvand similar files to the current directory; inside the copy they would turn up in the comparison. - rsync, GNU find, awk, and a UTF-8 character set (in the C locale rsync escapes non-ASCII names; see Failure modes):Expected: the last line is
$ rsync --version | head -1; find --version | head -1; awk --version | head -1; locale charmap rsync version 3.5.1-g315819c5 protocol version 33 find (GNU findutils) 4.10.0 GNU Awk 5.3.2, API 4.0, PMA Avon 8-g1, (GNU MPFR 4.2.2, GNU MP 6.3.0) UTF-8UTF-8. - Nothing under
$LOCALis unreadable to you:Expected: no output. Each line it prints is an owner uid, a mode and a path that rsync cannot read or enter either. On a test tree with a mode-000 file and a mode-444 directory:$ find "$LOCAL" \( ! -type l ! -readable -o -type d ! -executable \) -prune -printf '%U %m %P\n'Both kinds can make an entry look identical. rsync prints no itemize line for the contents of a directory it cannot open, which shows up in step 3 as an error. A file it cannot read gets no error at all: when the source's copy of it is unreadable too and has the same size, rsync prints nothing for it (Failure modes, rows 2 and 3). Make the listed paths readable, or move them out of$ find "$LOCAL" \( ! -type l ! -readable -o -type d ! -executable \) -prune -printf '%U %m %P\n' 1000 0 both-locked/secret.bin 1000 444 noexec-dir$LOCALand decide on them separately, before step 3.! -readablealone misses the mode-444 directory, hence the-executabletest.! -type lleaves symlinks out: rsync compares a symlink's target as text, so one that dangles on this machine is fine, and without that test the check listed a dangling symlink in the same test tree. The original copy held an unreadable directory, a Postgres data directory written by a container: owned by the container's uid, mode 700, and unreadable toduas well. - Backups: none. Nothing is deleted before step 8, and step 8 deletes only entries that step 7 has just re-checked against the source.
Steps#
Create a temporary key and install it on the source.
ssh-keygen -t ed25519 -N '' -C tmp-compare -f ~/.ssh/<KEY> ssh-copy-id -i ~/.ssh/<KEY>.pub "$SRC" export RSYNC_RSH="ssh -i $HOME/.ssh/<KEY> -o IdentitiesOnly=yes"ssh-copy-idappends the public key to~/.ssh/authorized_keyson the source, creating the file if needed; it logs in with the account's password (or a key that already works) to do so.RSYNC_RSHis the remote shell rsync uses when no-eis given, so every rsync command below goes through this ssh command. rsync splits the value into separate arguments: a logging shim set asRSYNC_RSHreceived-i, the key path,-oandIdentitiesOnly=yesas four arguments, followed by the host andrsync.IdentitiesOnly=yeskeeps keys held by ssh-agent out of the login;IdentityFilelines from~/.ssh/configstill apply, which is why step 11 tests the key with-F /dev/null. Expected:ssh-copy-idreports one key added. [UNVERIFIED: steps 1 and 2 not run against an SSH server for this manual. The original run used a temporary key; its setup commands were not recorded.]Check the path and rsync on the source without listing anything. The loopback run (its "source" is a second directory on the same machine, so it reports the local rsync):
$ $RSYNC_RSH "$SRC" "test -d $REMOTE && echo dir-ok; command -v rsync && rsync --version | head -1"; echo "rc=$?" dir-ok /usr/bin/rsync rsync version 3.5.1-g315819c5 protocol version 33 rc=0Expected: three lines,
dir-ok, the path of rsync on the source and its version. Nodir-okline:$REMOTEis not a directory there (in the loopback run, a path that does not exist printed only the two rsync lines). No path and version: rsync is not installed there, or not in the remote shell'sPATH. The finalrccomes from the last command and says nothing about the directory.Run the checksum dry run, from the copy to the source. The command from the original run, with
LOCAL,hostandREMOTEas its placeholders:rsync --dry-run -rlc --itemize-changes --out-format='%i %n' LOCAL/ host:REMOTE/The same command with the variables from Before you start, saving both output streams:$ rsync --dry-run -rlc --itemize-changes --out-format='%i %n' "$LOCAL"/ "$SRC":"$REMOTE"/ > dry.txt 2> dry.err; echo "rc=$?" rc=0Expected:
rc=0and an emptydry.err(step 4 shows its line count). Anything else: find the cause under Failure modes before going on; step 5 refuses to classify whiledry.errhas a line.--dry-run: rsync works out what it would send and reports it; no file contents are sent and nothing is written on the source. The copy's file list, with names, sizes and checksums, does go to the source (Notes). In the loopback runs, a listing of the source tree with sizes, mtimes and ctimes was the same before and after seven dry runs.-c: decide by checksum, not by size and modification time. The local side checksums its files while it builds the file list; the source checksums each of its files that has the same size as the local one, and a file whose size differs is reported without one. The algorithm is negotiated between the two ends (xxh128between the two rsync 3.5.1 ends of the loopback run), so "identical" below means the same checksum. Without-c, the loopback run missed a same-size edit with an unchanged mtime and flagged a file whose bytes matched but whose mtime did not (Notes).-r -l: recurse, and compare symlinks as links instead of following them. Not-a: its-t -p -o -gadd attribute-only lines, such as.f..t...... notes/touched.txtfor a file with identical bytes, and those would turn an identical entry intodiffers.--itemize-changes --out-format='%i %n': one line per item that would change, made of an 11-character change code, a space and the path relative to$LOCAL. The explicit format leaves out the-> targetthat--itemize-changesalone appends to symlinks, so every line splits the same way."$LOCAL"/with the trailing slash: compare the contents of the copy with the contents of$REMOTE. The form without the slash is Failure modes, row 4.- No
--delete: it would itemize files that exist only on the source, and without--dry-runit would delete them there.
Read the itemized lines.
$ cat dry.txt; wc -l dry.txt dry.err <fcsT...... edited/changed-size.txt <fc.T...... edited/same-size.txt cd+++++++++ gone-there/ <f+++++++++ gone-there/NOTES cd+++++++++ gone-there/src/ <f+++++++++ gone-there/src/main.c <f+++++++++ grown/new-here.txt <fcsT...... repo-index/.git/FETCH_HEAD <fc.T...... repo-index/.git/index 9 dry.txt 0 dry.err 9 totalIn this direction the local side is the sender, so every file line starts with
<, sent to the remote host. On the loopback trees,notes/,repo-same/,top-file.txtand.dotfileprint nothing: every file in them matched by checksum, including one whose mtime differs on the source, and the symlink innotes/points at the same target on both sides.Line Meaning here no line the item exists on the source with the same checksum (a symlink: the same target) <f+++++++++ pathfile missing on the source cd+++++++++ dir/directory missing on the source; everything under it follows as new cL+++++++++ linksymlink missing on the source <fcsT...... pathcontent ( c) and size (s) differ<fc.T...... pathsame size, different content A leading
cmeans the receiver would create the item, a directory or symlink here. TheTonly says the receiver would set the transfer time as mtime, because times are not preserved; it carries no information here. Line codes by kind:$ cut -c1-11 dry.txt | sort | uniq -c 2 cd+++++++++ 3 <f+++++++++ 2 <fcsT...... 2 <fc.T......GotchaCount
<f, not>f>marks an item received by the local side, which happens in a pull. In the original run my first classifier looked for>and counted zero.$ grep -c '^>f' dry.txt 0 $ grep -c '^<f' dry.txt 7Give each top-level entry a verdict. Save this as
classify.awkin the working directory:# classify.awk: one verdict per top-level entry of LOCAL, from a push-direction # rsync --dry-run -rlc --itemize-changes --out-format='%i %n' LOCAL/ host:REMOTE/ > dry.txt 2> dry.err # usage: awk -f classify.awk entries.txt dry.txt dry.err > verdicts.tsv # identical no line: every file under it exists on the source with the same checksum # git-noise only .git/index or .git/FETCH_HEAD differ # only-local the entry itself is missing on the source # differs anything else (new or changed files inside) # Any line in dry.err, or any line of dry.txt it cannot map to an entry, # aborts with exit 2 and no verdicts: an unreadable directory prints no line. FILENAME == ARGV[1] { order[++n] = $0; v[$0] = "identical"; next } FILENAME == ARGV[3] { print "rsync stderr: " $0 > "/dev/stderr"; bad = 1; next } { code = substr($0, 1, 11); path = substr($0, 13); sub(/\/$/, "", path) top = path; sub(/\/.*/, "", top) if (substr($0, 12, 1) != " " || !(top in v)) { print "unmapped line: " $0 > "/dev/stderr"; bad = 1; next } new = (substr(code, 3) == "+++++++++") if (path == top && new) v[top] = "only-local" else if (v[top] == "only-local") next else if (!new && path ~ /\/\.git\/(index|FETCH_HEAD)$/) { if (v[top] == "identical") v[top] = "git-noise" } else v[top] = "differs" } END { if (bad) exit 2 for (i = 1; i <= n; i++) printf "%s\t%s\n", v[order[i]], order[i] }List the entries, then classify:
$ find "$LOCAL" -mindepth 1 -maxdepth 1 -printf '%f\n' | sort > entries.txt; cat entries.txt .dotfile edited gone-there grown notes repo-index repo-same top-file.txt $ awk -f classify.awk entries.txt dry.txt dry.err > verdicts.tsv; echo "rc=$?"; cat verdicts.tsv rc=0 identical .dotfile differs edited only-local gone-there differs grown identical notes git-noise repo-index identical repo-same identical top-file.txtExpected:
rc=0and one verdict per entry.rc=2withrsync stderr:orunmapped line:messages and an emptyverdicts.tsv: see Failure modes.identical: no line under the entry; every file in it exists on the source with the same checksum, and every symlink with the same target.git-noise: the only differing files are.git/indexand.git/FETCH_HEAD, and every object, ref and work-tree file matched by checksum. The index caches each tracked file's stat data, which differs between two machines even for identical files, andgit statusrewrites it when it refreshes that data;FETCH_HEADrecords the lastgit fetch. On the loopback tree, onegit statuson the source side and a fetch there from a second URL produced exactly this. The index is also git's staging area, so whatever the copy had staged goes with it; the objects it names are on the source, because no file under.git/objectsdiffered. Any other differing file under.gitleaves the entry asdiffers.only-local: the entry itself is missing on the source; the copy is the only one.differs: new or changed files inside; either side may hold the newer version.
List the deletion candidates and look at what stays.
$ awk -F'\t' '$1 == "identical" || $1 == "git-noise" { print $2 }' verdicts.tsv | sort > planned.txt; cat planned.txt .dotfile notes repo-index repo-same top-file.txt $ awk -F'\t' '$1 == "only-local" || $1 == "differs"' verdicts.tsv differs edited only-local gone-there differs grownExpected:
planned.txtholds onlyidenticalandgit-noiseentries. Remove from it anything you want to keep anyway. The other entries stay: anonly-localentry exists nowhere else, and adiffersentry may hold the newer copy of a file.Re-run the dry run right before deleting, and keep only entries that still qualify. The source can change between step 3 and the deletion:
rsync --dry-run -rlc --itemize-changes --out-format='%i %n' "$LOCAL"/ "$SRC":"$REMOTE"/ > dry2.txt 2> dry2.err; echo "rc=$?"; cat dry2.err awk -f classify.awk entries.txt dry2.txt dry2.err > verdicts2.tsv; echo "rc=$?" awk -F'\t' '$1 == "identical" || $1 == "git-noise" { print $2 }' verdicts2.tsv | sort | comm -12 planned.txt - > delete.txt comm -23 planned.txt delete.txt | sed 's/^/dropped: /'; cat delete.txtIn the loopback run, a line was appended to
notes/b.txton the source between the two dry runs (a harness step, to show what a change does):$ rsync --dry-run -rlc --itemize-changes --out-format='%i %n' "$LOCAL"/ "$SRC":"$REMOTE"/ > dry2.txt 2> dry2.err; echo "rc=$?"; cat dry2.err rc=0 $ awk -f classify.awk entries.txt dry2.txt dry2.err > verdicts2.tsv; echo "rc=$?" awk -F'\t' '$1 == "identical" || $1 == "git-noise" { print $2 }' verdicts2.tsv | sort | comm -12 planned.txt - > delete.txt comm -23 planned.txt delete.txt | sed 's/^/dropped: /'; cat delete.txt rc=0 dropped: notes .dotfile repo-index repo-same top-file.txtExpected:
rc=0twice, anddelete.txtlists the planned entries that are stillidenticalorgit-noise;dropped:lines name those that are not. When the classifier refuses (exit 2),verdicts2.tsvis left empty (0 bytes in the slashless test), sodelete.txtcomes out empty.Delete the entries in
delete.txtfrom the copy.WarningThis step is irreversible on this machine
The entries are gone from the copy. Their files matched the source by checksum at step 7, apart from
.git/indexandFETCH_HEADingit-noiseentries, whose versions in the copy cannot be brought back. Undo pulls everything else back from the source while the source keeps it.Size first:
$ (cd "$LOCAL" && xargs -d '\n' du -shc --) < delete.txt 4.0K .dotfile 132K repo-index 128K repo-same 4.0K top-file.txt 268K totalThen delete:
while IFS= read -r e; do if [ -z "$e" ] || [ "$e" = . ] || [ "$e" = .. ] || [ "${e#*/}" != "$e" ]; then echo "skip: $e" >&2; continue; fi rm -rf -- "${LOCAL:?}/$e" && printf 'removed\t%s\n' "$e" done < delete.txt | tee -a removed.log$ while IFS= read -r e; do if [ -z "$e" ] || [ "$e" = . ] || [ "$e" = .. ] || [ "${e#*/}" != "$e" ]; then echo "skip: $e" >&2; continue; fi rm -rf -- "${LOCAL:?}/$e" && printf 'removed\t%s\n' "$e" done < delete.txt | tee -a removed.log removed .dotfile removed repo-index removed repo-same removed top-file.txtExpected: one
removedline per entry indelete.txtand noskip:line. The loop refuses empty names,.,..and anything containing/, and${LOCAL:?}makes the shell refuse thermwhen$LOCALis empty or unset instead of running it against/<entry>.Re-check the copy against the source while you still have access.
$ find "$LOCAL" -mindepth 1 -maxdepth 1 -printf '%f\n' | sort > entries-after.txt rsync --dry-run -rlc --itemize-changes --out-format='%i %n' "$LOCAL"/ "$SRC":"$REMOTE"/ > dry3.txt 2> dry3.err; echo "rc=$?" awk -f classify.awk entries-after.txt dry3.txt dry3.err rc=0 differs edited only-local gone-there differs grown differs notesExpected:
rc=0, and onlyonly-localanddiffersverdicts, plus any entry you took out ofplanned.txton purpose.notesisdiffershere because of the source-side edit from step 7.Remove the temporary key from the source. This is the one change on the source after step 1; ask its owner first.
BLOB=$(cut -d' ' -f2 ~/.ssh/<KEY>.pub) $RSYNC_RSH "$SRC" "cd ~/.ssh && cp -p authorized_keys authorized_keys.tmpkey-bak && { grep -vF '$BLOB' authorized_keys.tmpkey-bak > authorized_keys; [ \$? -le 1 ]; } && rm authorized_keys.tmpkey-bak; grep -cF '$BLOB' authorized_keys"Expected:
0, the number of lines still holding the key (the command exits 1 because that last grep matched nothing). This editsauthorized_keysonly, the filessh-copy-idappended to; sshd also readsauthorized_keys2by default, which step 1 did not touch.>rewrites the file in place, so it keeps its mode; the backup is deleted only after the filter succeeded. If grep reports an error instead,authorized_keysmay be cut short and the backup is still there:cp -p ~/.ssh/authorized_keys.tmpkey-bak ~/.ssh/authorized_keyson the source puts it back (not run). The same command, run through the loopback shell against a fake home, left a file that had held two other keys plus this one with exactly the two others, and a file that had held only this key empty (0 bytes); both kept mode 600, and the backup was gone both times. It needs a POSIX-style login shell on the source and was tested with/bin/shonly. [UNVERIFIED: not run against a real sshd.]Close any shared connection and check that the key no longer logs in.
ssh -O exit "$SRC" ssh -G -F /dev/null -i ~/.ssh/<KEY> -o IdentitiesOnly=yes -o BatchMode=yes -o ControlPath=none "$SRC" true | grep -E '^(identityfile|identitiesonly|batchmode|controlpath) ' ssh -F /dev/null -i ~/.ssh/<KEY> -o IdentitiesOnly=yes -o BatchMode=yes -o ControlPath=none "$SRC" true; echo "rc=$?"ssh -O exitcloses a master connection if one exists. Without one it fails harmlessly; with a control path that has no master behind it, a local test printed:$ ssh -F /dev/null -o ControlPath=/nonexistent/master.sock -O exit "$SRC"; echo "rc=$?" Control socket connect(/nonexistent/master.sock): No such file or directory rc=255ssh -Gprints the configuration the test login will use and exits without connecting. With-F /dev/null, ssh reads no~/.ssh/configand no system-wide config, so the temporary key is the only identity:$ ssh -G -F /dev/null -i keytest/tmp-key -o IdentitiesOnly=yes -o BatchMode=yes -o ControlPath=none "$SRC" true | grep -E '^(identityfile|identitiesonly|batchmode|controlpath) ' batchmode yes identitiesonly yes identityfile keytest/tmp-keyWithout
-F /dev/null, anIdentityFileline in a config still joins the list, even withIdentitiesOnly=yes:$ cat keytest/ssh_config; ssh -G -F keytest/ssh_config -i keytest/tmp-key -o IdentitiesOnly=yes "$SRC" true | grep '^identityfile ' Host * IdentityFile keytest/other-key identityfile keytest/tmp-key identityfile keytest/other-keyExpected: the test login fails with a
Permission deniedmessage andrc=255. Any other error (Could not resolve hostname,Connection refused) means the test never reached authentication; if the source needs settings from~/.ssh/config, such asPortorProxyJump, add them as-ooptions and test again. Only after aPermission denied, delete the local key files:rm ~/.ssh/<KEY> ~/.ssh/<KEY>.pub; unset RSYNC_RSH.GotchaAn open master connection logs in without the key
With
ControlMasterandControlPersistin~/.ssh/config, a master connection stays open in the background after the rsync runs, and later sessions reuse its network connection instead of opening a new one. A test login over it reuses a connection that was authenticated before the key was removed, so it says nothing about the key.-O exitcloses the master,ControlPath=nonekeeps the test off any master, andBatchMode=yesstops ssh from falling back to a password prompt.[UNVERIFIED: the test login was not run against a real sshd. In the original run the multiplexed connection was closed and a fresh login was checked to fail.]
Verify#
- The copy holds exactly the entries that were not deleted. This check is local, so it works after the key is gone:Expected: no line from the first
$ find "$LOCAL" -mindepth 1 -maxdepth 1 -printf '%f\n' | sort > entries-final.txt comm -12 delete.txt entries-final.txt comm -23 entries.txt delete.txt | diff - entries-final.txt && echo "copy holds entries.txt minus delete.txt" copy holds entries.txt minus delete.txtcomm(no deleted entry is back) and the linecopy holds entries.txt minus delete.txt. Step 9 showed that what remains isonly-localordiffers. - The temporary key no longer logs in: step 11.
Undo / rollback#
- A deleted entry comes back from the source while the source still has it. This needs access again: repeat step 1, with the source owner's permission, then for each entry:A file entry and a
rsync -a "$SRC":"$REMOTE"/<ENTRY> "$LOCAL"/; echo "rc=$?" rsync --dry-run -rlc --itemize-changes --out-format='%i %n' "$LOCAL"/<ENTRY> "$SRC":"$REMOTE"/; echo "rc=$?"git-noisedirectory, on the loopback trees:Expected:$ rsync -a "$SRC":"$REMOTE"/top-file.txt "$LOCAL"/; echo "rc=$?" rsync --dry-run -rlc --itemize-changes --out-format='%i %n' "$LOCAL"/top-file.txt "$SRC":"$REMOTE"/; echo "rc=$?" rc=0 rc=0 $ rsync -a "$SRC":"$REMOTE"/repo-index "$LOCAL"/; echo "rc=$?" rsync --dry-run -rlc --itemize-changes --out-format='%i %n' "$LOCAL"/repo-index "$SRC":"$REMOTE"/; echo "rc=$?" rc=0 rc=0rc=0twice per entry and no itemize line, so the restored entry matches the source. Keep both paths without a trailing slash; with slashes the restore fails for a file entry (Failure modes, row 8). This copies the entry's contents from the source, which the comparison never did. Mtimes and modes are the source's, and agit-noiseentry comes back with the source's.git/indexandFETCH_HEAD, not the copy's. Then run steps 10 and 11 again. - The key: nothing to undo. Repeat step 1 if you need access again, and steps 10 and 11 when you are done.
- Steps 2 to 7 and step 9 change nothing in the copy or on the source; they only write files in the working directory.
Failure modes#
| Symptom (exact message) | Cause | Fix |
|---|---|---|
grep -c '^>f' dry.txt prints 0 although files differ | Push direction: items sent start with <; > marks items received, in a pull | Count ^<f, or classify with classify.awk (step 5) |
rsync: [sender] opendir "…/proj3/db" failed: Permission denied (13), then rsync error: some files/attrs were not transferred (see previous errors) (code 23) | A directory under $LOCAL that rsync cannot open: mode 000, or mode 444 (readable but not searchable) in the extra test. rsync prints no itemize line for its contents, and when the source has a directory of the same name, nothing at all: on the loopback tree, proj3 came out identical once the stderr was ignored | Run the readability check from Before you start, resolve what it lists, re-run step 3. classify.awk stops with rsync stderr: … and exit 2 while dry.err has a line |
No line and no error for a file that is unreadable both here and on the source, with the same size; its entry comes out identical | rsync cannot checksum either copy and reports no difference; in the extra test the two files had different bytes | Only the readability check from Before you start catches this: it lists the local file. Fix its mode and re-run step 3 |
unmapped line: cd+++++++++ local/ from classify.awk, exit 2 | $LOCAL given without the trailing slash: rsync compares the copy as a new subdirectory of $REMOTE, so every line starts with the copy's own directory name | Write "$LOCAL"/ |
unmapped line: cd+++++++++ caf\#303\#251/, exit 2 | The local rsync ran under LC_ALL=C and printed non-ASCII names as \#ooo escapes | Run in a UTF-8 locale, or add -8 (--8-bit-output): under LC_ALL=C it printed cd+++++++++ café/ |
unmapped line: skipping non-regular file "x/pipe", exit 2; rsync itself exited 0 with an empty dry.err | A FIFO in the copy. Without -D, rsync skips special files and says so on standard output, in dry.txt | A FIFO or socket holds no data on disk: delete it from the copy, or move its entry out of $LOCAL and decide on it separately, then re-run step 3 |
rsync: connection unexpectedly closed (0 bytes received so far) [sender], then rsync error: remote command not found (code 127) | No rsync at that path on the source. Reproduced on loopback with --rsync-path=/nonexistent/rsync; the line before them comes from the remote shell, there sh: line 1: /nonexistent/rsync: No such file or directory. Code 127 is not in the man page's table of exit values; rsync printed it | Install rsync on the source, or point --rsync-path at it |
rsync: [sender] change_dir "…/.dotfile" failed: Not a directory (20), rc 23, during Undo | The restore was written as "$REMOTE"/<ENTRY>/ "$LOCAL"/<ENTRY>/; with the trailing slash rsync treats a file entry as a directory | Use the form from Undo, without trailing slashes; it works for files and directories |
<fc.T...... proj2/blob.bin for a file whose bytes match the source | The local file is unreadable (mode 000) and the source's copy is readable; rsync 3.5.1 reported the content as changed and printed no error line naming it | The readability check lists it; fix its mode and re-run step 3. Until then the entry stays differs, so nothing is lost |
Field notes#
- 2026-10-25, Fedora 44 workstation (kernel 7.2.10), rsync 3.5.1 protocol 33, findutils 4.10.0, GNU Awk 5.3.2, OpenSSH 10.2p1 for
ssh -G, loopback remote shell, synthetic trees: The "source" was a second directory, reached through the shim from Notes set asRSYNC_RSH: it drops the host argument and runs thersync --server …command throughsh -c, the way sshd runs a remote command, so the whole rsync protocol ran over a pipe. Eleven runs of the main sequence and four of the extra cases, all on 2026-10-25; the console blocks above come from one main run and one extra run made after the last harness change, and the itemized output, verdicts and delete list were byte-identical in all eleven main runs. In order: (1) atee | sh | teevariant of the shim, meant to capture the traffic, hung: its parent shell held rsync's pipe open, so rsync never saw end-of-file.execwith process substitution fixed it. (2) The first unreadable-directory test did not show the trap: itsdb/was missing on the source, so rsync printedcd+++++++++ proj/db/and the entry came outdiffers. A second case withdb/on both sides printed nothing for its entry, which was classifiedidenticalwhile rsync exited 23;classify.awknow refuses to run whiledry.errhas a line. (3) A capture of both pipe directions during one dry run held 4,686 bytes sent to the source and 147 bytes back (ten complete captures: 4,565 to 4,686 bytes out, 147 back every time). Neither direction contained any of the source-only file contents or names, no file content of the copy went out, and a directory name from the copy appeared twice in the outgoing stream, which shows the search would have found names. (4) Without-c, the quick check missededited/same-size.txt(same size and mtime, different bytes) and flaggednotes/touched.txt(same bytes, newer mtime on the source). With-ainstead of-rl,notes/touched.txtcame back as.f..t......, and in one of the eight runs of that case alsonotes/as.d..t....... (5) A listing of the source tree with sizes, mtimes and ctimes was identical before and after seven dry runs. (6) Extra cases: a file that was mode 000 on both sides, with the same size and different bytes, printed no line and no error and came outidentical. A local directory with mode 444 failedopendirlike a mode-000 one, butfind ! -readabledid not list it.--debug=nstrprintedClient negotiated checksum: xxh128. (7) Later cases: the restore written with trailing slashes failed for a file entry withchange_dir … Not a directory (20), while the slashless form restored a file and agit-noisedirectory with no itemize line afterwards.ssh -Gwith a synthetic config listed that config'sIdentityFilenext to the-ikey despiteIdentitiesOnly=yes, and with-F /dev/nulllisted only the-ikey. The readability check without! -type llisted a dangling symlink that rsync compared without complaint. A FIFO producedskipping non-regular fileon standard output with rsync exiting 0. - 2026-10-23 or 2026-10-24 (the day was not recorded), Fedora workstation with btrfs
/hometo an older laptop on the LAN over SSH, rsync versions and the laptop's OS not recorded: The copy was an 18 GB one-time sync of a tree from that laptop, 504 repositories. Most of it was one 15.8 GB synthetic Postgres benchmark database in a directory owned by the container's uid with mode 700, handled separately and deleted through a container. My rules for this part: compare only, never look at the contents of files on the laptop, and delete nothing there. The work ran with a temporary SSH key and the command quoted in step 3, and each destructive step waited for my approval. My first classifier counted lines starting with>and got zero; in this direction the markers are<f. Totals: 122,668 files missing on the laptop and 6,677 different, with whole projects gone there, so the copy was not redundant. Per top-level entry: 201 identical entries (928 MB) were deleted locally; 100 more differed only in.git/indexorFETCH_HEADand were deleted after a fresh re-verification; 83 entries that existed only on the workstation (869 MB) and about 70 that really differed stayed. Afterwards the key was removed from the laptop; that removal was the one exception I had made to deleting nothing there; the multiplexed SSH connection was closed, and a fresh login was checked to fail. A review of the run had pointed out that a key left in place would keep the workstation's passwordless access to that laptop open indefinitely.
Notes#
- Why
-c: rsync's default quick check compares size and modification time. On the loopback trees it missed a same-size edit whose mtime matched and reported a file that differed only in mtime; with-cboth came out right. - What
-ccosts: every byte of the copy is read, and on the source every file that has the same size as its local counterpart. Those reads can update access times there, depending on how the source's filesystem is mounted; the before-and-after listing did not include atimes. rsync has--open-noatimefor this; it was not used here. - What crosses the connection: in push direction the copy is the sender, so its file list goes to the source; per the rsync 3.5.1 source (
flist.c), that list carries each file's checksum under-c, and the per-item results come back. The loopback capture found the copy's names in the outgoing stream and none of the source's names or contents in either direction. Without--delete, files that exist only on the source are never looked for, soidenticalmeans every local file exists on the source with the same checksum, and the source may hold more. - Copies laid out as
<group>/<project>: run steps 3 to 9 once per group, withLOCALandREMOTEpointing at the group directory on both sides. Not run for this manual. - Rehearse on test trees without a second machine: save this as
loopback-rsh, make it executable, andexport RSYNC_RSH=<ABSOLUTE_PATH_OF_SHIM>instead of the ssh value from step 1.$REMOTEis then a directory on this machine,$SRCcan be any word, and steps 2 to 9 run unchanged.#!/bin/sh # Test-only "remote shell" for rsync -e / RSYNC_RSH. rsync calls it as # loopback-rsh [-l user] host rsync --server ... # Drop the login options and the host, then hand the rest to a shell as one # string, the way sshd runs a remote command. Nothing leaves this machine. while [ "$#" -gt 0 ]; do if [ "$1" = "-l" ]; then shift 2; continue; fi shift break done exec sh -c "$*" - No rsync on the source: comparing each repository's refs and
git statusover plain ssh is an alternative that covers committed state only. Not verified here. - Background, from the same cleanup: the Postgres directory that
ducould not read is in 12.4 GB of duplicates, 3.4 GB on disk, and the review notes, including the lingering key, are in I kept measuring the thing next door.
Sources#
- rsync(1), current for 3.5.1, identical to the man page installed with rsync 3.5.1 on the test box for every part used here:
--checksum(who checksums what, same-size rule, negotiated algorithm),--dry-run("does not send the actual data for file transfers"),--itemize-changes(YXcstpoguax,<sent,>received,ccreated,+new,Twithout--times, equal to--out-format='%i %n%L'),--archive,--links,--specials,--delete,--rshandRSYNC_RSH,--rsync-path,--8-bit-output,--open-noatime, the trailing-slash rule under USAGE, exit value 23. - rsyncd.conf(5),
log format:%nis the short name with a trailing/on directories,%Lthe-> SYMLINKsuffix. - ssh_config(5):
ControlMaster,ControlPath none,ControlPersist(a persisting master stays untilssh -O exit),IdentitiesOnly(configured identity files still count),BatchMode. Same text in the OpenSSH 10.2p1 pages on the test box. - ssh(1):
-F(a file given on the command line replaces~/.ssh/configand makes ssh ignore the system-wide file),-G(print the configuration and exit),-O exit, and exit status 255 on error. - sshd_config(5):
AuthorizedKeysFiledefaults to.ssh/authorized_keys .ssh/authorized_keys2. - ssh-copy-id(1): logs in, usually with a password, and appends the key to the remote
~/.ssh/authorized_keys. - git-status(1), BACKGROUND REFRESH:
git statusrewrites the index with refreshed stat data. gitformat-index(5): the stat fields stored per entry. racy-git: the index as staging area and stat cache. git-fetch(1):FETCH_HEADis overwritten by each fetch. - GNU findutils, Mode Bits:
-readableand-executableuseaccess(2); Directories:-prune.