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:
@@ -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 }>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -143,12 +143,36 @@ describe('applyPushActions — update (collab path, SPEC §2/§15.6)', () => {
|
||||
expect(res.updated).toBe(1);
|
||||
// The collab/Yjs write path is used — NOT a raw jsonb overwrite.
|
||||
expect(client.importPageMarkdown).toHaveBeenCalledTimes(1);
|
||||
expect(client.importPageMarkdown).toHaveBeenCalledWith('p-1', fileBody);
|
||||
expect(client.importPageMarkdown).toHaveBeenCalledWith('p-1', fileBody, null);
|
||||
// No raw-overwrite path exists on the injected client surface at all.
|
||||
expect((client as any).updatePageJson).toBeUndefined();
|
||||
expect(client.createPage).not.toHaveBeenCalled();
|
||||
expect(client.deletePage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards the last-pushed base body (3-way merge ancestor) when present', async () => {
|
||||
const baseBody =
|
||||
'<!-- docmost:meta\n{"version":1,"pageId":"p-1"}\n-->\n\nbase body\n';
|
||||
const fileBody =
|
||||
'<!-- docmost:meta\n{"version":1,"pageId":"p-1"}\n-->\n\nupdated body\n';
|
||||
const client = makeClient();
|
||||
// The pre-image (refs/docmost/last-pushed) carries the base version.
|
||||
const { git } = makeGit({ prevTree: { 'Doc.md': baseBody } });
|
||||
const fs = makeFs({ 'Doc.md': fileBody });
|
||||
|
||||
await applyPushActions(
|
||||
deps(client, git, fs),
|
||||
actions({ updates: [{ pageId: 'p-1', path: 'Doc.md' }] }),
|
||||
);
|
||||
|
||||
// importPageMarkdown receives the base so the server can 3-way merge it.
|
||||
expect(client.importPageMarkdown).toHaveBeenCalledWith(
|
||||
'p-1',
|
||||
fileBody,
|
||||
baseBody,
|
||||
);
|
||||
expect(git.showFileAtRef).toHaveBeenCalledWith(LAST_PUSHED_REF, 'Doc.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPushActions — create (assigned pageId written back to meta)', () => {
|
||||
@@ -542,8 +566,8 @@ describe('applyPushActions — per-page error isolation + refs gated on success
|
||||
// The 1st and 3rd were applied; the 2nd threw.
|
||||
expect(res.updated).toBe(2);
|
||||
expect(client.importPageMarkdown).toHaveBeenCalledTimes(3);
|
||||
expect(client.importPageMarkdown).toHaveBeenNthCalledWith(1, 'p-a', 'a body');
|
||||
expect(client.importPageMarkdown).toHaveBeenNthCalledWith(3, 'p-c', 'c body');
|
||||
expect(client.importPageMarkdown).toHaveBeenNthCalledWith(1, 'p-a', 'a body', null);
|
||||
expect(client.importPageMarkdown).toHaveBeenNthCalledWith(3, 'p-c', 'c body', null);
|
||||
|
||||
// The failure is recorded with kind/pageId/path/error.
|
||||
expect(res.failures).toEqual([
|
||||
@@ -649,7 +673,7 @@ describe('applyPushActions — mixed batch + skipped passthrough', () => {
|
||||
expect(res.writtenBack).toEqual([{ path: 'N.md', pageId: 'created-1' }]);
|
||||
expect(res.skipped).toEqual(skipped);
|
||||
expect(updateRefCalls).toEqual([{ ref: LAST_PUSHED_REF, target: 'sha-9' }]);
|
||||
expect(client.importPageMarkdown).toHaveBeenCalledWith('u-1', updFile);
|
||||
expect(client.importPageMarkdown).toHaveBeenCalledWith('u-1', updFile, null);
|
||||
expect(client.deletePage).toHaveBeenCalledWith('d-1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -300,7 +300,11 @@ describe('applyPushActions (push.ts) — move prefetch isolation', () => {
|
||||
// The update and the delete in the SAME batch still applied.
|
||||
expect(res.updated).toBe(1);
|
||||
expect(res.deleted).toBe(1);
|
||||
expect(client.importPageMarkdown).toHaveBeenCalledWith('u1', store['Up.md']);
|
||||
expect(client.importPageMarkdown).toHaveBeenCalledWith(
|
||||
'u1',
|
||||
store['Up.md'],
|
||||
null,
|
||||
);
|
||||
expect(client.deletePage).toHaveBeenCalledWith('d1');
|
||||
|
||||
// The broken move was ISOLATED: no movePage/renamePage call, recorded as a
|
||||
|
||||
@@ -312,7 +312,7 @@ describe('runPush — --apply is the ONLY write path', () => {
|
||||
|
||||
const res = await runPush(deps, { dryRun: false });
|
||||
|
||||
expect(client.importPageMarkdown).toHaveBeenCalledWith('p-9', file);
|
||||
expect(client.importPageMarkdown).toHaveBeenCalledWith('p-9', file, null);
|
||||
expect(res.applied?.updated).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user