Six Words for One Path
A sync run lists both trees, then walks the union of three sets of paths: what is here, what is there, and what the archive remembers. For every path it asks classify() the same question twice, once per side, comparing that side's listing entry with the archive record:
| State | Means |
|---|---|
absent | not on this side, not in the archive |
new | on this side, not in the archive |
deleted | in the archive, gone from this side |
changed | another kind, another link target, or another size |
same | same size and this side's own mtime unchanged |
maybe | same size, but the mtime moved: a touch or an edit, and only the bytes can say which |
maybe is the honest state. A file saved with identical bytes, or a tool that bumps mtimes, looks like a change by metadata and is not one. So every maybe is hashed (locally on this Mac, through batched hashes requests on the other, up to 1,000 paths each) and compared with the archive's SHA-256: equal means same (a touch), different means changed. The hash is spent only where size and time disagree, never on the whole tree.
The Decision Table
Two states per path give one decision. new and changed count as live:
- Live on one side, same or absent on the other: that side's version crosses.
- Deleted on one side, same on the other: the deletion crosses.
- Deleted on both: nothing to do.
- Deleted on one side, live on the other: an edit beats a delete. The edited version crosses back to where it was deleted. Losing an edit is unrecoverable; restoring a file someone meant to delete costs them one more delete.
- Live on both, equal content: same kind and size, then the same SHA-256 (or the same link target). Two Macs that made the same change agree, and no conflict is invented.
- Live on both, different content: a real conflict. The newer mtime wins (on an exact tie this Mac's side wins), and the loser is not thrown away.
The Loser Stays
Before the winner overwrites the losing side, the losing side's file is copied, on its own Mac, to name (conflict_on_<date>).ext in the same folder, using the unique scheme so an existing conflict copy is never replaced (name (2) and on). The next run sees that copy as a new file and carries it across like any other, so both Macs end with the winner at the real name and the loser beside it, where a person will notice it. Newer-wins is a guess about intent; keeping the loser is what makes a wrong guess cheap.
Every Overwrite and Delete Is Kept First
A pair's sync refuses to start unless it names a backup folder on each side (backups.here, backups.there, both under ~/). Every file a run will overwrite or delete is first set aside there through the keep verb, on the Mac that holds it, so the backup's bytes never cross the network:
- A delete is a
moveinto the backup tree: the file leaves its place and is kept. - An overwrite is a
link: the old version is hard-linked into the backup tree, and the new version then replaces the path by rename, so the kept copy costs no extra disk (on another volume, a copy stands in). - Naming is Unison's central scheme (
versions): the newest backup sits at the file's own name, older ones shift to.bak.2.<name>,.bak.3.<name>and on, and anything pastversions(default 1,000) is dropped.
Links get one more rule: a link is never written through. A link (or a file a link replaces) is moved out of its place before the new entry is made.
A Decision Is Only Good for the Version It Saw
A plan is made from listings, and listings age. If Dad edits a note one second after the run listed it, the run's decision ("overwrite this with the other Mac's version") is about a version that no longer exists. So every file write the run makes carries the destination's etag as it was listed, as expect (a link has no etag, so a link write carries none); a keep or a write whose target no longer matches is refused. That file is not overwritten. It goes into the job's errors as "left for the next run", the job's cause becomes items_skipped, and the next run sees both changes and decides again. A file that should not exist yet carries expect of empty, so a file that appeared in the meantime is not replaced either.
Folders follow the same caution. A new folder crosses. A folder deleted on one side is removed on the other only if it is empty; if it still holds something, that something is new, and the next run carries it across instead.