@@ -996,7 +996,8 @@ Thursday.` and clicks `[Sync]`. The phone's `drain()` runs:
996996 ` github-easy-sync-conflicts-Phone-20260508153022-847 ` on the
997997 current ` main ` HEAD and pushes the Phone's version of ` idea.md `
998998 to it.
999- 3 . A sibling is written to the vault using the ` .sync-bak ` protocol:
999+ 3 . A sibling is written to the vault using the ` .sync-tmp ` staging
1000+ protocol (§9.4):
10001001 ` Notes/idea.conflict-from-Laptop-2026-05-08T15-30-00Z.md `
10011002 containing ` Launch by Monday. `
100210034 . A conflict record is persisted under
@@ -1122,18 +1123,20 @@ conflict" until the last one is settled.
11221123### Scenario D: Crash Mid-Create
11231124
11241125The Phone is processing the conflict from Scenario A. Step 1 (write
1125- ` idea.sync-bak.md ` ) completes. Step 2 (write ` meta.json ` ) begins.
1126- The OS suspends the app before ` meta.json ` is fully written.
1126+ ` idea.conflict-from-Laptop-...sync-tmp.md ` ) completes. Step 2 (write
1127+ ` meta.json ` ) begins. The OS suspends the app before ` meta.json ` is
1128+ fully written.
11271129
11281130On the next plugin load:
11291131
1130- 1 . The atomic-write recovery sweep walks the vault, finds
1131- ` Notes/idea.sync-bak.md ` , and consults the conflict-record store.
1132- The record does not exist (Step 2 never finished). The recovery
1133- matrix's row 3 applies: the staging file is an orphan; the sweep
1134- removes it.
1132+ 1 . The atomic-write recovery sweep walks the vault, finds the
1133+ ` .sync-tmp ` staging file, and consults the conflict-record store.
1134+ The record does not exist (Step 2 never finished). Per §9.5's
1135+ ` .sync-tmp ` pass: no record names this final path → the staging
1136+ file is dropped as a Path A transient (the only safe assumption
1137+ when there is no ownership claim).
113511382 . The conflict store loads (zero records).
1136- 3 . The next ` [Sync] ` runs as if the conflict never happened. The
1139+ 3 . The next ` [Sync] ` runs as if the confliet never happened. The
11371140 pull re-encounters the divergence between Phone and ` main ` ,
11381141 auto-merge fails again, and the protocol restarts at Step 1 with
11391142 a fresh record. The user sees the same end state as if the crash
@@ -1142,13 +1145,191 @@ On the next plugin load:
11421145
11431146Had the crash occurred between Step 2 and Step 3 instead — i.e.,
11441147the record was written but the final rename did not happen — the
1145- recovery sweep would find both the staging file and a record naming
1146- the final path. The SHA-verify check would succeed (the bytes match
1147- ` record.theirsBlobSha ` because they were written from
1148- ` theirsContent ` in Step 1), and the sweep would complete Step 3 by
1149- performing the rename. The user opens Obsidian and sees the
1150- sibling at its final name; nothing visible suggests anything went
1151- wrong.
1148+ recovery sweep would find both the ` .sync-tmp ` staging file and a
1149+ record naming the final path. The SHA-verify check would succeed
1150+ (the bytes match ` record.theirsBlobSha ` because they were written
1151+ from ` theirsContent ` in Step 1), and the sweep would complete
1152+ Step 3 by performing the rename. The user opens Obsidian and sees
1153+ the sibling at its final name; nothing visible suggests anything
1154+ went wrong.
1155+
1156+ ### Scenario E: Partial Resolution of a Multi-Path Conflict Session
1157+
1158+ Scenarios A through D all involved a single conflicted path. Real
1159+ multi-device usage routinely produces several conflicts in one
1160+ sync. This scenario traces how those resolve one at a time, and
1161+ what the relationship is between per-file resolution and the
1162+ conflict branch's final merge.
1163+
1164+ Consider a wider divergence than Scenario A: while the Phone was
1165+ offline, the user edited three files on it — ` Notes/idea.md ` ,
1166+ ` Notes/plan.md ` , and ` Notes/todo.md ` — and the Laptop, online,
1167+ edited the same three files. All three pairs of edits land on
1168+ overlapping lines, so auto-merge fails on each.
1169+
1170+ When the Phone reconnects and clicks ` [Sync] ` , the drain processes
1171+ its queued batch:
1172+
1173+ 1 . Pull from ` main ` discovers that all three files diverge.
1174+ Auto-merge attempts run, all three fail, and three conflicts are
1175+ registered in sequence:
1176+ - A conflict branch is created on the first registration —
1177+ ` github-easy-sync-conflicts-Phone-20260508153022-847 ` — at
1178+ the current ` main ` HEAD. The Phone's pre-conflict version of
1179+ ` idea.md ` is pushed to it as the first commit
1180+ (` message: "conflict (Phone)" ` ).
1181+ - The second and third registrations each append one commit to
1182+ the ** same** conflict branch, carrying the Phone's
1183+ pre-conflict versions of ` plan.md ` and ` todo.md ` respectively.
1184+ - Three sibling files are written next to the originals:
1185+ - ` Notes/idea.conflict-from-Laptop-...md `
1186+ - ` Notes/plan.conflict-from-Laptop-...md `
1187+ - ` Notes/todo.conflict-from-Laptop-...md `
1188+ - Three conflict records are persisted, one per file.
1189+
1190+ State after the drain:
1191+
1192+ ```
1193+ GitHub:
1194+ main: ── ... ── Cn (Laptop's three edits) ───────────
1195+ │
1196+ └── X1 ── X2 ── X3
1197+ (idea (plan (todo
1198+ Phone) Phone) Phone)
1199+ ↑
1200+ conflict branch tip
1201+
1202+ Phone vault:
1203+ Notes/idea.md ← Phone's version
1204+ Notes/idea.conflict-from-Laptop-...md ← sibling (Laptop's)
1205+ Notes/plan.md ← Phone's version
1206+ Notes/plan.conflict-from-Laptop-...md ← sibling (Laptop's)
1207+ Notes/todo.md ← Phone's version
1208+ Notes/todo.conflict-from-Laptop-...md ← sibling (Laptop's)
1209+
1210+ Status bar: 🔀 3
1211+ ```
1212+
1213+ Three records in the conflict store, one conflict branch with three
1214+ commits, three pairs of files in the vault. ` main ` is unchanged
1215+ relative to before the Phone's sync — none of the conflicted files
1216+ have reached it.
1217+
1218+ ** First resolution — ` idea.md ` , by deleting the sibling.** The user
1219+ inspects, decides their Phone version of ` idea.md ` is correct, and
1220+ deletes the sibling ` idea.conflict-from-Laptop-...md ` . On the next
1221+ ` [Sync] ` :
1222+
1223+ - ** Phase A** finds the record for ` idea.md ` has ` !siblingExists `
1224+ (user-deleted) → record dropped from store.
1225+ - ** Phase B** observes that ` idea.md ` now has zero records →
1226+ `enqueueSynthetic({path: "idea.md", content: <Phone's bytes>,
1227+ parentCommitSha: lastSync, ...})` adds a side-batch to the queue.
1228+ - ` processBatch ` runs over the side-batch. The path is no longer in
1229+ ` inConflictFiles ` (no records), so it partitions into ` plainPaths ` ,
1230+ and the new content of ` idea.md ` is pushed ** to ` main ` ** as an
1231+ ordinary commit (` message: "resolve conflict (Phone)" ` ,
1232+ ` meta.synthetic: true ` ).
1233+ - ** Finalise gate:** ` store.records.length === 2 ` — ` plan.md ` and
1234+ ` todo.md ` still have records. The finalise block at the end of the
1235+ drain skips. The conflict branch is untouched.
1236+
1237+ ```
1238+ GitHub:
1239+ main: ── ... ── Cn ── M1
1240+ │ ↑
1241+ │ "resolve conflict (Phone)" — idea.md
1242+ │
1243+ └── X1 ── X2 ── X3 (unchanged)
1244+
1245+ Status bar: 🔀 2
1246+ ```
1247+
1248+ ** Second resolution — ` plan.md ` , by renaming the sibling onto the
1249+ base.** The user prefers the Laptop's version of ` plan.md ` . They
1250+ rename ` plan.conflict-from-Laptop-...md ` → ` plan.md ` , overwriting
1251+ the Phone's version. On the next ` [Sync] ` :
1252+
1253+ - ** Phase A** finds the record for ` plan.md ` has ` !siblingExists `
1254+ (renamed away) → record dropped.
1255+ - ** Phase B** synthesises a side-batch for ` plan.md ` containing the
1256+ bytes now sitting at ` plan.md ` (the Laptop's version).
1257+ - ` processBatch ` pushes the new ` plan.md ` content to ** ` main ` ** as
1258+ another ordinary commit.
1259+ - ** Finalise gate:** ` store.records.length === 1 ` — ` todo.md ` still
1260+ pending. Finalise still does not run.
1261+
1262+ ```
1263+ GitHub:
1264+ main: ── ... ── Cn ── M1 ── M2
1265+ │ ↑
1266+ │ "resolve conflict (Phone)" — plan.md
1267+ │
1268+ └── X1 ── X2 ── X3 (still unchanged)
1269+
1270+ Status bar: 🔀 1
1271+ ```
1272+
1273+ ** Third (and last) resolution — ` todo.md ` , by hand-merging.** The
1274+ user opens both ` todo.md ` and its sibling, copies the wanted lines
1275+ from each into ` todo.md ` , then deletes the sibling. On the next
1276+ ` [Sync] ` :
1277+
1278+ - ** Phase A** finds the record for ` todo.md ` has ` !siblingExists `
1279+ → record dropped.
1280+ - ** Phase B** synthesises a side-batch for ` todo.md ` with the
1281+ merged content.
1282+ - ` processBatch ` pushes the merged ` todo.md ` to ` main ` as the third
1283+ "resolve conflict (Phone)" commit.
1284+ - ** Finalise gate:** ` store.records.length === 0 ` ** and** the
1285+ conflict branch exists → the finalise block fires:
1286+ 1 . A marker commit (` final state (Phone) ` ) is written to the
1287+ branch's tip, preserving the Phone's final state on the branch.
1288+ 2 . A manual `createCommit({message: "merge conflict-branch
1289+ (Phone)", treeSha: main.tree, parents: [ main.head,
1290+ branch.head] })` constructs the merge-commit on ` main`.
1291+ 3 . ` updateBranchHead ` makes the merge-commit ` main ` 's new tip.
1292+ 4 . ` deleteReference("heads/github-easy-sync-conflicts-Phone-...") `
1293+ removes the branch label. The four branch commits remain
1294+ reachable through the merge-commit's second parent (§2.3).
1295+ 5 . ` lastSyncCommitSha ` is advanced to the merge-commit.
1296+
1297+ ```
1298+ GitHub:
1299+ main: ── ... ── Cn ── M1 ── M2 ── M3 ── M (merge-commit)
1300+ │ /
1301+ └── X1 ── X2 ── X3 ── X4 (final state)
1302+ (branch label deleted; commits still reachable through M)
1303+
1304+ Status bar: (empty — no conflicts pending)
1305+ ```
1306+
1307+ ** Net history view.** The repository now shows, in chronological
1308+ order on ` main ` :
1309+
1310+ - ` Cn ` — the Laptop's pre-conflict commits.
1311+ - ` M1 ` , ` M2 ` , ` M3 ` — three separate "resolve conflict (Phone)"
1312+ commits, one per resolved file. Each commit touches exactly its
1313+ one file; no batching, no cross-contamination between resolutions.
1314+ - ` M ` — the merge-commit that joins the conflict branch back into
1315+ ` main ` . Its second parent is the branch tip; the four branch
1316+ commits (X1, X2, X3, X4) remain part of the repository's history
1317+ forever, visible in the GitHub Network graph as a four-commit side
1318+ arm joining ` main ` at ` M ` .
1319+
1320+ ** The structural property to note.** The conflict branch was alive
1321+ for the full duration of the multi-conflict session — through all
1322+ three per-file resolutions — and was only finalised when the
1323+ ** last** record disappeared from the store. Each per-file resolution
1324+ published its result to ` main ` as an ordinary commit by the same
1325+ Phase B → side-batch → ` processBatch ` → main-push pipeline that any
1326+ non-conflicting file would use. The branch and its merge-commit
1327+ exist for one reason only: to keep the Phone's pre-conflict and
1328+ in-conflict edits to * every* file in this session permanently
1329+ reachable from ` main ` . Per-file closure is therefore cheap and
1330+ local — one ordinary commit on ` main ` , no branch round-trips — and
1331+ the branch lifecycle is governed strictly by the "store empty?"
1332+ predicate at drain end.
11521333
11531334---
11541335
@@ -1307,10 +1488,15 @@ Used by the change detector and by pull-side reconciliation to
13071488distinguish "this file changed locally" from "this file changed
13081489remotely" from "this file changed on both sides."
13091490
1310- ** Staging path / ` .sync-bak ` ** — The intermediate location of a
1311- sibling file during the three-step atomic write protocol. Named
1312- ` <stem>.sync-bak<ext> ` (pre-suffix form) so that the file extension
1313- is preserved and gitignore's ` *.sync-bak* ` pattern catches it.
1491+ ** Staging path / ` .sync-tmp ` / ` .sync-bak ` ** — The intermediate
1492+ location of a file during the atomic write protocols. ` .sync-tmp `
1493+ holds new bytes destined for a target path (forward direction;
1494+ both Path A and Path B produce these). ` .sync-bak ` holds the old
1495+ bytes of a file moved aside before an overwrite (rollback
1496+ direction; only Path A produces these). Both use pre-suffix form
1497+ (e.g., ` note.sync-tmp.md ` , ` note.sync-bak.md ` ) so that the file
1498+ extension is preserved and gitignore's ` *.sync-tmp* ` / ` *.sync-bak* `
1499+ patterns catch them. See §9.
13141500
13151501** Tree** — A directory listing in git's object storage: an ordered
13161502collection of ` (name, mode, sha) ` entries where each ` sha ` points
0 commit comments