feat(git-sync): three-way body merge using the last-synced base (no edit loss)

Upgrades the 2-way body merge to a real diff3 three-way merge (review #5), so a
block ONLY the human changed is KEPT when git changed a DIFFERENT block — the
2-way merge would revert it to git's stale version.

Engine: the push update loop reads the last-synced pre-image
(`git.showFileAtRef(refs/docmost/last-pushed, path)`) and passes it as the
optional `baseMarkdown` to `client.importPageMarkdown` (the common ancestor).

Server: gitmost-datasource converts base+incoming, and writeBody runs a block-
level diff3 (new three-way-merge.ts `diff3Plan`): live-only change -> keep live,
git-only change -> take git, both-changed -> git wins (conflict policy), inserts/
deletes from either side preserved. Without a base (createPage) it falls back to
the 2-way merge. Crash-safety unchanged (docs built before the connection opens).

Tests: three-way-merge.spec.ts (14 — every diff3 case incl. the cross-block
preservation and conflict policy), yjs-body-merge 3-way (real Y.Docs: human's
block instance preserved while git's block is applied), plus an engine test that
the base is forwarded from showFileAtRef. Existing push assertions updated for the
new base arg. git-sync 589 pass; server merge/datasource/gate 62 pass; typecheck
clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-23 15:47:56 +03:00
parent e53b962c68
commit 8a5c69a2f9
10 changed files with 429 additions and 27 deletions

View File

@@ -73,14 +73,19 @@ export interface GitSyncClient {
// --- writes (push) --------------------------------------------------------
/**
* Replace a page's body from a self-contained markdown file (meta + body).
* The collab/Yjs write path (SPEC §2/§15.6) — never a raw jsonb overwrite.
* Merge a page's body from a self-contained markdown file (meta + body). The
* collab/Yjs write path (SPEC §2/§15.6) — never a raw jsonb overwrite.
* `applyPushActions` reads only an optional `updatedAt` off the result
* (via `extractUpdatedAt`, tolerant of extra fields).
*
* `baseMarkdown` is the last-synced version of the file (`refs/docmost/
* last-pushed`), the common ancestor for a THREE-WAY merge against the live
* doc so concurrent human edits survive (review #5). Optional/null -> 2-way.
*/
importPageMarkdown(
pageId: string,
fullMarkdown: string,
baseMarkdown?: string | null,
): Promise<{ updatedAt?: string; [key: string]: unknown }>;
/**

View File

@@ -552,7 +552,18 @@ export async function applyPushActions(
for (const u of actions.updates) {
try {
const fullMarkdown = await deps.readFile(u.path);
const result = await client.importPageMarkdown(u.pageId, fullMarkdown);
// The last-synced version of this file (pre-image) is the common ancestor
// for a 3-way merge against the live page, so concurrent human edits are
// not clobbered (review #5). Null when the file is new at last-pushed.
const baseMarkdown = await deps.git.showFileAtRef(
LAST_PUSHED_REF,
u.path,
);
const result = await client.importPageMarkdown(
u.pageId,
fullMarkdown,
baseMarkdown,
);
updated++;
// §10 loop-guard data: hash the body we pushed + capture `updatedAt`.
pushed.push({