The Edges of a Tree
Copying a folder of regular files is the easy case. Real trees have edges: symbolic links, files you want and files you do not, a destination where a name is already taken, and folders that refuse to open. Each edge has a tempting shortcut, and each shortcut has damaged someone's data with some tool. Delta takes rsync's answers where rsync had good ones, and the code says so in its comments.
Links Cross as Links
A symbolic link is copied as a link, never followed into a copy of its target. Its target may be anything that stays inside the share and that the share would serve. node_modules/.bin/tool pointing at ../pkg/bin/tool.js crosses intact, so a JavaScript project still works on the other Mac. A link whose target leaves the share, or leads into a refused place (a credential folder, a folder macOS guards), is not copied. It is left out, listed in the job's left_out as name -> target, and the job goes on. This is rsync's --safe-links. The final message says how many links were left out and names the first.
Two follow-ups came from the review. The first version refused the whole job on one relative link with .. in it, so every node_modules tree failed. And a link whose target had changed was never updated. Now a changed target replaces the old link on the next sync. A link is also deleted and set aside as what it is, a link, and is never followed or written through.
Patterns, Read the Way rsync Reads Them
--exclude skips names anywhere in the tree:
- a bare pattern (
*.tmp) matches a file or a folder by name at any depth, and a folder takes its whole subtree with it; - a path tail (
.editor/cache) matches those trailing parts at any depth; - a trailing slash (
.locks/) matches folders only, so a file named.locksstill moves; - a leading slash (
/session_index.jsonl) matches only at the transfer's top level.
--include keeps only the files that match, like rsync's --include=*/ --include=<p> --exclude=* --prune-empty-dirs. A bare pattern matches a file's name, a pattern with a slash matches the same number of trailing path parts (evidence/*), and a leading slash anchors it at the root. Folders that end up holding no wanted file are not created.
Two rules make patterns safe with --delete. Excluded paths neither move nor count for deletion, on either side, so excluding .locks/ never deletes the destination's lock folder. And files outside an include set on the destination are not deleted either. When both are given, an excluded name never moves, even if it also matches an include. Each list takes at most 50 patterns. An exclude pattern with an empty, . or .. part is refused; an include pattern only has to be a non-empty string.
Create-Only Delivery
delta cp --unique never replaces anything. The file takes its own name if that name is free, otherwise name (2), then name (3), up to name (50). The job reports the name it took in landed. The landing is a hard link from the verified data file to the candidate name. os.link fails if the name exists, so "is it free?" and "take it" are one atomic step and two deliveries can never claim the same name. This is the shape for an inbox: things arrive, and nothing that was already there is overwritten.
Unreadable Is Not Gone
This is the edge with the sharpest teeth. In the first version, the listing skipped a folder it could not open and logged a warning. Think through a sync --delete with that listing. The source folder is there, but a permission problem makes it look absent. The planner sees the destination's copy of that folder, with no counterpart in the source, and deletes it. A permission error would have erased a mirror.
The fix borrows rsync's rule ("IO error encountered -- skipping file deletion") and makes it explicit:
- a folder that will not open is listed with the kind
unreadable, never as an empty folder and never silently skipped; - nothing under it moves, in either direction;
- a run that met any unreadable folder, on either side, deletes nothing at all;
- the job fails, naming the folder, while everything else lands;
- a transfer root that will not open fails the plan before anything moves.
The sync plan shows it before you confirm: UNREADABLE: <where> (nothing under it moves, and this run deletes nothing). The principle generalizes to any tool that compares two listings. "I could not look" is a third answer, separate from "present" and "absent", and it must never collapse into either.
The Plan Comes First
All of these rules land in the sync plan, and the plan is shown before anything changes: files to send and their size, files already in place, paths to delete, file-versus-folder conflicts, unreadable folders, and an estimate when there is enough to measure. --dry-run prints the plan and stops. --yes skips the question, and without a terminal to ask, the CLI refuses to proceed unless --yes was given. Deleting on the destination happens only with --delete. Nothing in Delta infers it.