Skip to content

Commit a8f3f01

Browse files
dbdeveloperclaude
andcommitted
docs: add Scenario E — partial multi-path resolution
Scenarios A-D all use a single conflicted path; real multi-device usage routinely produces several conflicts in one sync. Scenario E walks through a three-file conflict (Phone vs Laptop, idea.md + plan.md + todo.md, all auto-merge failures) resolved one file at a time and shows what the article's mechanics imply about the branch-vs-main interaction: - Per-file closure runs through Phase B → enqueueSynthetic → ordinary processBatch → main push. The new content lands on main as a regular "resolve conflict (deviceLabel)" commit. The conflict branch is NOT touched. - The finalise gate (store.records.length === 0) keeps the branch alive across all partial closures. - Only the LAST per-file closure triggers the marker commit + manual merge-commit + deleteRef on main. Includes four ASCII state diagrams (initial 3-conflict state, state after each of the three resolutions) and a "Net history view" paragraph spelling out what the resulting GitHub repository looks like. Also fixes three residual `.sync-bak` references that should have been `.sync-tmp` after the suffix-semantic swap (Scenario A step 3, Scenario D crash narrative + recovery, Glossary "Staging path" entry rewritten to cover both suffixes). No technical content changed beyond the residual-reference fixes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6997ea7 commit a8f3f01

1 file changed

Lines changed: 206 additions & 20 deletions

File tree

docs/PSEUDO-MERGE-MODE.md

Lines changed: 206 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -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.`
10021003
4. A conflict record is persisted under
@@ -1122,18 +1123,20 @@ conflict" until the last one is settled.
11221123
### Scenario D: Crash Mid-Create
11231124

11241125
The 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

11281130
On 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).
11351138
2. 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

11431146
Had the crash occurred between Step 2 and Step 3 instead — i.e.,
11441147
the 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
13071488
distinguish "this file changed locally" from "this file changed
13081489
remotely" 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
13161502
collection of `(name, mode, sha)` entries where each `sha` points

0 commit comments

Comments
 (0)