Skip to content
C.W.K.
Lesson 02 of 05 · published

Newer Wins, the Loser Stays

~14 min · conflict-resolution, backups, mtime, etag, two-way-sync

Level 0Loose File
0 XP0/43 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

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:

StateMeans
absentnot on this side, not in the archive
newon this side, not in the archive
deletedin the archive, gone from this side
changedanother kind, another link target, or another size
samesame size and this side's own mtime unchanged
maybesame 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 move into 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 past versions (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.

Code

classify() and the decision loop (trimmed excerpt)·python
# delta/pair.py (trimmed excerpt): one decision per path, against the archive
def classify(entry, rec, side):
    """same | maybe (same size, other mtime: the hash decides) | changed | new | deleted | absent."""
    if entry is None:
        return "deleted" if rec else "absent"
    if rec is None:
        return "new"
    if entry["kind"] != rec["kind"]:
        return "changed"
    if entry["kind"] == "link":
        return "same" if entry.get("target") == rec.get("target") else "changed"
    if entry.get("size") != rec["size"]:
        return "changed"
    return "same" if entry.get("mtime_ns") == rec["mt_" + side] else "maybe"


live = ("changed", "new")
for rel in rels:
    h, t = st_h[rel], st_t[rel]
    l, r = L.get(rel), R.get(rel)
    if h in ("same", "absent") and t in ("same", "absent"):
        continue
    if h in live and t in ("same", "absent"):
        push.append((rel, expect_of(r), r is not None, False))
    elif h == "deleted" and t == "same":
        del_there.append((rel, expect_of(r)))
    elif t in live and h in ("same", "absent"):
        pull.append((rel, expect_of(l), l is not None, False))
    elif t == "deleted" and h == "same":
        del_here.append((rel, expect_of(l)))
    elif h == "deleted" and t == "deleted":
        continue
    elif h == "deleted" and t in live:
        pull.append((rel, "", False, False))           # an edit beats a delete: nothing is lost
    elif t == "deleted" and h in live:
        push.append((rel, "", False, False))
    elif rel in agreed:
        continue                                       # changed on both sides, to the same content
    else:
        conflicts.append(rel)
        if int(l.get("mtime_ns", 0)) >= int(r.get("mtime_ns", 0)):
            push.append((rel, expect_of(r), True, True))
        else:
            pull.append((rel, expect_of(l), True, True))
Central backups the Unison way: newest at its own name, older ones .bak.N·python
# keep_versions.py: Unison-style central backups, the newest at its own name
import os
import tempfile
from pathlib import Path


def keep_version(src: Path, folder: Path, versions: int = 3) -> Path:
    """Move src into folder; older copies shift to .bak.2.<name>, .bak.3.<name> ..."""
    folder.mkdir(parents=True, exist_ok=True)
    name = src.name
    dest = folder / name
    # Shift from the oldest down, dropping anything past `versions`.
    for n in range(versions, 1, -1):
        older = folder / f".bak.{n}.{name}"
        if older.exists():
            if n == versions:
                older.unlink()
            else:
                os.rename(older, folder / f".bak.{n + 1}.{name}")
    if dest.exists():
        os.rename(dest, folder / f".bak.2.{name}")
    os.rename(src, dest)
    return dest


with tempfile.TemporaryDirectory() as tmp:
    root = Path(tmp)
    backups = root / "backups"
    for edit in range(1, 5):
        note = root / "note.md"
        note.write_text(f"edit {edit}\n")
        keep_version(note, backups, versions=3)
    for path in sorted(backups.iterdir()):
        print(f"{path.name:16} {path.read_text().strip()}")

External links

Exercise

Take two copies of a small folder on one Mac (or two Macs) and write down, for each of these five histories, what the decision table does and what both folders hold afterwards: (1) a note edited on one side, (2) a note deleted on one side, (3) a note deleted on one side and edited on the other, (4) the same edit made on both sides, (5) different edits on both sides, the later one on the other Mac. Then run the backup demo with versions=3 and five edits instead of four, and predict the listing before you run it.
Hint
Case 4 is the one people get wrong: equal content on both sides is agreement, not a conflict, so no conflict copy appears. In the backup demo, only three versions ever exist at once; the first two edits are gone by the fifth.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.