# Prune a stale synced copy with an rsync checksum dry run

> A push-direction rsync --dry-run -c gives each top-level entry of an old copy a verdict; delete only what the source holds identically, git index aside.

Published 2026-09-23. HTML version: https://guides.tsvinc.dev/rsync-checksum-compare-synced-copy/


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.

> **Scope** — Verified 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_keys` through the loopback shell, and step 11's identity check only as `ssh -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/index` and `.git/FETCH_HEAD` in `git-noise` entries: 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 example `tmp-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_RSH` into words):
  ```bash
  LOCAL=<ABSOLUTE_PATH_OF_COPY>
  SRC=<USER>@<HOST>
  REMOTE=<PATH_ON_SOURCE>
  ```
- Work from a directory outside `$LOCAL`, for example `mkdir -p ~/copy-compare && cd ~/copy-compare`. The steps write `dry.txt`, `verdicts.tsv` and 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):
  ```console
  $ 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-8
  ```
  Expected: the last line is `UTF-8`.
- Nothing under `$LOCAL` is unreadable to you:
  ```console
  $ find "$LOCAL" \( ! -type l ! -readable -o -type d ! -executable \) -prune -printf '%U %m %P\n'
  ```
  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:
  ```console
  $ 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
  ```
  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 `$LOCAL` and decide on them separately, before step 3. `! -readable` alone misses the mode-444 directory, hence the `-executable` test. `! -type l` leaves 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 to `du` as 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

1. **Create a temporary key and install it on the source.**
   ```bash
   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-id` appends the public key to `~/.ssh/authorized_keys` on 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_RSH` is the remote shell rsync uses when no `-e` is given, so every rsync command below goes through this ssh command. rsync splits the value into separate arguments: a logging shim set as `RSYNC_RSH` received `-i`, the key path, `-o` and `IdentitiesOnly=yes` as four arguments, followed by the host and `rsync`. `IdentitiesOnly=yes` keeps keys held by ssh-agent out of the login; `IdentityFile` lines from `~/.ssh/config` still apply, which is why step 11 tests the key with `-F /dev/null`.
   Expected: `ssh-copy-id` reports 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.]

2. **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):
   ```console
   $ $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=0
   ```
   Expected: three lines, `dir-ok`, the path of rsync on the source and its version. No `dir-ok` line: `$REMOTE` is 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's `PATH`. The final `rc` comes from the last command and says nothing about the directory.

3. **Run the checksum dry run, from the copy to the source.**
   The command from the original run, with `LOCAL`, `host` and `REMOTE` as 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:
   ```console
   $ rsync --dry-run -rlc --itemize-changes --out-format='%i %n' "$LOCAL"/ "$SRC":"$REMOTE"/ > dry.txt 2> dry.err; echo "rc=$?"
   rc=0
   ```
   Expected: `rc=0` and an empty `dry.err` (step 4 shows its line count). Anything else: find the cause under Failure modes before going on; step 5 refuses to classify while `dry.err` has 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 (`xxh128` between 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 -g` add attribute-only lines, such as `.f..t...... notes/touched.txt` for a file with identical bytes, and those would turn an identical entry into `differs`.
   - `--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 ` -> target` that `--itemize-changes` alone 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-run` it would delete them there.

4. **Read the itemized lines.**
   ```console
   $ 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 total
   ```
   In 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.txt` and `.dotfile` print nothing: every file in them matched by checksum, including one whose mtime differs on the source, and the symlink in `notes/` 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+++++++++ path` | file missing on the source |
   | `cd+++++++++ dir/` | directory missing on the source; everything under it follows as new |
   | `cL+++++++++ link` | symlink missing on the source |
   | `<fcsT...... path` | content (`c`) and size (`s`) differ |
   | `<fc.T...... path` | same size, different content |

   A leading `c` means the receiver would create the item, a directory or symlink here. The `T` only says the receiver would set the transfer time as mtime, because times are not preserved; it carries no information here. Line codes by kind:
   ```console
   $ cut -c1-11 dry.txt | sort | uniq -c
         2 cd+++++++++
         3 <f+++++++++
         2 <fcsT......
         2 <fc.T......
   ```
   > [!GOTCHA] Count `<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.
   ```console
   $ grep -c '^>f' dry.txt
   0
   $ grep -c '^<f' dry.txt
   7
   ```

5. **Give each top-level entry a verdict.**
   Save this as `classify.awk` in the working directory:
   ```awk
   # 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:
   ```console
   $ 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.txt
   ```
   Expected: `rc=0` and one verdict per entry. `rc=2` with `rsync stderr:` or `unmapped line:` messages and an empty `verdicts.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/index` and `.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, and `git status` rewrites it when it refreshes that data; `FETCH_HEAD` records the last `git fetch`. On the loopback tree, one `git status` on 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/objects` differed. Any other differing file under `.git` leaves the entry as `differs`.
   - `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.

6. **List the deletion candidates and look at what stays.**
   ```console
   $ 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	grown
   ```
   Expected: `planned.txt` holds only `identical` and `git-noise` entries. Remove from it anything you want to keep anyway. The other entries stay: an `only-local` entry exists nowhere else, and a `differs` entry may hold the newer copy of a file.

7. **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:
   ```bash
   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.txt
   ```
   In the loopback run, a line was appended to `notes/b.txt` on the source between the two dry runs (a harness step, to show what a change does):
   ```console
   $ 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.txt
   ```
   Expected: `rc=0` twice, and `delete.txt` lists the planned entries that are still `identical` or `git-noise`; `dropped:` lines name those that are not. When the classifier refuses (exit 2), `verdicts2.tsv` is left empty (0 bytes in the slashless test), so `delete.txt` comes out empty.

8. **Delete the entries in `delete.txt` from the copy.**
   > [!WARNING] This 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/index` and `FETCH_HEAD` in `git-noise` entries, whose versions in the copy cannot be brought back. Undo pulls everything else back from the source while the source keeps it.

   Size first:
   ```console
   $ (cd "$LOCAL" && xargs -d '\n' du -shc --) < delete.txt
   4.0K	.dotfile
   132K	repo-index
   128K	repo-same
   4.0K	top-file.txt
   268K	total
   ```
   Then delete:
   ```bash
   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
   ```
   ```console
   $ 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.txt
   ```
   Expected: one `removed` line per entry in `delete.txt` and no `skip:` line. The loop refuses empty names, `.`, `..` and anything containing `/`, and `${LOCAL:?}` makes the shell refuse the `rm` when `$LOCAL` is empty or unset instead of running it against `/<entry>`.

9. **Re-check the copy against the source while you still have access.**
   ```console
   $ 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	notes
   ```
   Expected: `rc=0`, and only `only-local` and `differs` verdicts, plus any entry you took out of `planned.txt` on purpose. `notes` is `differs` here because of the source-side edit from step 7.

10. **Remove the temporary key from the source.**
    This is the one change on the source after step 1; ask its owner first.
    ```bash
    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 edits `authorized_keys` only, the file `ssh-copy-id` appended to; sshd also reads `authorized_keys2` by 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_keys` may be cut short and the backup is still there: `cp -p ~/.ssh/authorized_keys.tmpkey-bak ~/.ssh/authorized_keys` on 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/sh` only. [UNVERIFIED: not run against a real sshd.]

11. **Close any shared connection and check that the key no longer logs in.**
    ```bash
    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 exit` closes 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:
    ```console
    $ 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=255
    ```
    `ssh -G` prints the configuration the test login will use and exits without connecting. With `-F /dev/null`, ssh reads no `~/.ssh/config` and no system-wide config, so the temporary key is the only identity:
    ```console
    $ 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-key
    ```
    Without `-F /dev/null`, an `IdentityFile` line in a config still joins the list, even with `IdentitiesOnly=yes`:
    ```console
    $ 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-key
    ```
    Expected: the test login fails with a `Permission denied` message and `rc=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 as `Port` or `ProxyJump`, add them as `-o` options and test again. Only after a `Permission denied`, delete the local key files: `rm ~/.ssh/<KEY> ~/.ssh/<KEY>.pub; unset RSYNC_RSH`.
    > [!GOTCHA] An open master connection logs in without the key
    > With `ControlMaster` and `ControlPersist` in `~/.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 exit` closes the master, `ControlPath=none` keeps the test off any master, and `BatchMode=yes` stops 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:
  ```console
  $ 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.txt
  ```
  Expected: no line from the first `comm` (no deleted entry is back) and the line `copy holds entries.txt minus delete.txt`. Step 9 showed that what remains is `only-local` or `differs`.
- 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:
  ```bash
  rsync -a "$SRC":"$REMOTE"/<ENTRY> "$LOCAL"/; echo "rc=$?"
  rsync --dry-run -rlc --itemize-changes --out-format='%i %n' "$LOCAL"/<ENTRY> "$SRC":"$REMOTE"/; echo "rc=$?"
  ```
  A file entry and a `git-noise` directory, on the loopback trees:
  ```console
  $ 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=0
  ```
  Expected: `rc=0` twice 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 a `git-noise` entry comes back with the source's `.git/index` and `FETCH_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 as `RSYNC_RSH`: it drops the host argument and runs the `rsync --server …` command through `sh -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) a `tee | sh | tee` variant of the shim, meant to capture the traffic, hung: its parent shell held rsync's pipe open, so rsync never saw end-of-file. `exec` with process substitution fixed it. (2) The first unreadable-directory test did not show the trap: its `db/` was missing on the source, so rsync printed `cd+++++++++ proj/db/` and the entry came out `differs`. A second case with `db/` on both sides printed nothing for its entry, which was classified `identical` while rsync exited 23; `classify.awk` now refuses to run while `dry.err` has 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 missed `edited/same-size.txt` (same size and mtime, different bytes) and flagged `notes/touched.txt` (same bytes, newer mtime on the source). With `-a` instead of `-rl`, `notes/touched.txt` came back as `.f..t......`, and in one of the eight runs of that case also `notes/` 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 out `identical`. A local directory with mode 444 failed `opendir` like a mode-000 one, but `find ! -readable` did not list it. `--debug=nstr` printed `Client negotiated checksum: xxh128`. (7) Later cases: the restore written with trailing slashes failed for a file entry with `change_dir … Not a directory (20)`, while the slashless form restored a file and a `git-noise` directory with no itemize line afterwards. `ssh -G` with a synthetic config listed that config's `IdentityFile` next to the `-i` key despite `IdentitiesOnly=yes`, and with `-F /dev/null` listed only the `-i` key. The readability check without `! -type l` listed a dangling symlink that rsync compared without complaint. A FIFO produced `skipping non-regular file` on standard output with rsync exiting 0.
- **2026-10-23 or 2026-10-24 (the day was not recorded), Fedora workstation with btrfs `/home` to 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/index` or `FETCH_HEAD` and 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 `-c` both came out right.
- What `-c` costs: 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-noatime` for 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, so `identical` means 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, with `LOCAL` and `REMOTE` pointing 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, and `export RSYNC_RSH=<ABSOLUTE_PATH_OF_SHIM>` instead of the ssh value from step 1. `$REMOTE` is then a directory on this machine, `$SRC` can be any word, and steps 2 to 9 run unchanged.
  ```sh
  #!/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 status` over plain ssh is an alternative that covers committed state only. Not verified here.
- Background, from the same cleanup: the Postgres directory that `du` could not read is in [12.4 GB of duplicates, 3.4 GB on disk](https://tsvinc.dev/posts/btrfs-duplicates-that-werent/), and the review notes, including the lingering key, are in [I kept measuring the thing next door](https://tsvinc.dev/posts/measuring-the-thing-next-door/).

## Sources

- [rsync(1), current for 3.5.1](https://download.samba.org/pub/rsync/rsync.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, `c` created, `+` new, `T` without `--times`, equal to `--out-format='%i %n%L'`), `--archive`, `--links`, `--specials`, `--delete`, `--rsh` and `RSYNC_RSH`, `--rsync-path`, `--8-bit-output`, `--open-noatime`, the trailing-slash rule under USAGE, exit value 23.
- [rsyncd.conf(5)](https://download.samba.org/pub/rsync/rsyncd.conf.5), `log format`: `%n` is the short name with a trailing `/` on directories, `%L` the ` -> SYMLINK` suffix.
- [ssh_config(5)](https://man.openbsd.org/ssh_config.5): `ControlMaster`, `ControlPath none`, `ControlPersist` (a persisting master stays until `ssh -O exit`), `IdentitiesOnly` (configured identity files still count), `BatchMode`. Same text in the OpenSSH 10.2p1 pages on the test box.
- [ssh(1)](https://man.openbsd.org/ssh.1): `-F` (a file given on the command line replaces `~/.ssh/config` and makes ssh ignore the system-wide file), `-G` (print the configuration and exit), `-O exit`, and exit status 255 on error.
- [sshd_config(5)](https://man.openbsd.org/sshd_config.5): `AuthorizedKeysFile` defaults to `.ssh/authorized_keys .ssh/authorized_keys2`.
- [ssh-copy-id(1)](https://manpages.debian.org/testing/openssh-client/ssh-copy-id.1.en.html): logs in, usually with a password, and appends the key to the remote `~/.ssh/authorized_keys`.
- [git-status(1)](https://git-scm.com/docs/git-status), BACKGROUND REFRESH: `git status` rewrites the index with refreshed stat data. [gitformat-index(5)](https://git-scm.com/docs/gitformat-index): the stat fields stored per entry. [racy-git](https://git-scm.com/docs/racy-git): the index as staging area and stat cache. [git-fetch(1)](https://git-scm.com/docs/git-fetch): `FETCH_HEAD` is overwritten by each fetch.
- [GNU findutils, Mode Bits](https://www.gnu.org/software/findutils/manual/html_node/Mode-Bits.html): `-readable` and `-executable` use `access(2)`; [Directories](https://www.gnu.org/software/findutils/manual/html_node/Directories.html): `-prune`.

