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 3386bf2865
commit fa89cba023
10 changed files with 429 additions and 27 deletions

View File

@@ -14,7 +14,7 @@ import { KyselyDB } from '@docmost/db/types/kysely.types';
import { PageService } from '../../../core/page/services/page.service';
import { CollaborationGateway } from '../../../collaboration/collaboration.gateway';
import { tiptapExtensions } from '../../../collaboration/collaboration.util';
import { mergeXmlFragments } from './yjs-body-merge';
import { mergeXmlFragments, mergeXmlFragments3Way } from './yjs-body-merge';
import { AuthProvenanceData } from '../../../common/decorators/auth-provenance.decorator';
/**
@@ -74,8 +74,8 @@ export class GitmostDataSourceService {
listSpaceTree: (spaceId, rootPageId) =>
this.listSpaceTree(ctx, spaceId, rootPageId),
getPageJson: (pageId) => this.getPageJson(ctx, pageId),
importPageMarkdown: (pageId, fullMarkdown) =>
this.importPageMarkdown(ctx, pageId, fullMarkdown),
importPageMarkdown: (pageId, fullMarkdown, baseMarkdown) =>
this.importPageMarkdown(ctx, pageId, fullMarkdown, baseMarkdown),
createPage: (title, content, spaceId, parentPageId) =>
this.createPage(ctx, title, content, spaceId, parentPageId),
deletePage: (pageId) => this.deletePage(ctx, pageId),
@@ -164,18 +164,29 @@ export class GitmostDataSourceService {
// --- writes (push) --------------------------------------------------------
/**
* Replace a page's body from a self-contained markdown file: parse the meta+
* body envelope, convert the body to ProseMirror, then write it through collab
* (§3.3). Returns the fresh page's `updatedAt` for the loop-guard.
* Merge a page's body from a self-contained markdown file: parse the meta+body
* envelope, convert the body to ProseMirror, then merge it through collab
* (§3.3). When `baseMarkdown` (the last-synced version of the file) is given,
* the body write is a THREE-WAY merge against the live doc so concurrent human
* edits survive (review #5); without it, a 2-way merge. Returns the fresh
* page's `updatedAt` for the loop-guard.
*/
private async importPageMarkdown(
ctx: GitSyncBindContext,
pageId: string,
fullMarkdown: string,
baseMarkdown?: string | null,
): Promise<{ updatedAt?: string }> {
const { body } = parseDocmostMarkdown(fullMarkdown);
const doc = await markdownToProseMirror(body);
await this.writeBody(pageId, doc, ctx.userId);
let baseDoc: unknown;
if (baseMarkdown != null) {
const { body: baseBody } = parseDocmostMarkdown(baseMarkdown);
baseDoc = await markdownToProseMirror(baseBody);
}
await this.writeBody(pageId, doc, ctx.userId, baseDoc);
const page = await this.pageRepo.findById(pageId);
return {
@@ -378,18 +389,23 @@ export class GitmostDataSourceService {
pageId: string,
prosemirrorJson: unknown,
userId: string,
baseProsemirrorJson?: unknown,
): Promise<void> {
const documentName = `page.${pageId}`;
// Build the incoming Yjs doc BEFORE opening the connection / touching the
// live doc. If the transform throws (a malformed/unsupported doc) we must NOT
// have mutated the live body — otherwise a conversion failure could leave the
// page empty (review #5 — crash-safe conversion).
// Build the incoming (and base) Yjs docs BEFORE opening the connection /
// touching the live doc. If a transform throws (a malformed/unsupported doc)
// we must NOT have mutated the live body — otherwise a conversion failure
// could leave the page empty (review #5 — crash-safe conversion).
const targetDoc = TiptapTransformer.toYdoc(
prosemirrorJson,
'default',
tiptapExtensions,
);
const baseDoc =
baseProsemirrorJson != null
? TiptapTransformer.toYdoc(baseProsemirrorJson, 'default', tiptapExtensions)
: null;
const conn = await this.collabGateway.openDirectConnection(documentName, {
actor: 'git-sync',
@@ -400,15 +416,24 @@ export class GitmostDataSourceService {
});
try {
await conn.transact((doc) => {
const liveFrag = doc.getXmlFragment('default');
const targetFrag = targetDoc.getXmlFragment('default');
// Block-level MERGE rather than a full-body replace (review #5): diff the
// live body against the incoming git body and apply only the blocks that
// actually changed. Blocks a human is concurrently editing — anything git
// did not change — are left untouched, and an unchanged resync is a 0-op
// write. Yjs CRDT-merges the minimal ops with live edits.
mergeXmlFragments(
doc.getXmlFragment('default'),
targetDoc.getXmlFragment('default'),
);
// actually changed; concurrently-edited blocks are left untouched and an
// unchanged resync is a 0-op write. With a `base` (the last-synced
// version) do a THREE-WAY merge so a block ONLY the human changed is kept
// and a block ONLY git changed is taken (conflicts -> git). Without a base
// (e.g. createPage), fall back to the 2-way merge.
if (baseDoc) {
mergeXmlFragments3Way(
liveFrag,
targetFrag,
baseDoc.getXmlFragment('default'),
);
} else {
mergeXmlFragments(liveFrag, targetFrag);
}
});
} finally {
await conn.disconnect();