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>
113 lines
3.3 KiB
TypeScript
113 lines
3.3 KiB
TypeScript
import { diff3Plan, type Pick } from './three-way-merge';
|
|
|
|
// Materialize a plan into the merged key sequence for assertion.
|
|
function apply(plan: Pick[], live: string[], target: string[]): string[] {
|
|
return plan.map((p) => (p.src === 'live' ? live[p.index] : target[p.index]));
|
|
}
|
|
|
|
const merge = (o: string[], a: string[], b: string[]): string[] =>
|
|
apply(diff3Plan(o, a, b), a, b);
|
|
|
|
describe('diff3Plan (block-level three-way merge)', () => {
|
|
it('identical on all three sides -> unchanged (all from live)', () => {
|
|
const plan = diff3Plan(['1', '2', '3'], ['1', '2', '3'], ['1', '2', '3']);
|
|
expect(plan.every((p) => p.src === 'live')).toBe(true);
|
|
expect(apply(plan, ['1', '2', '3'], ['1', '2', '3'])).toEqual(['1', '2', '3']);
|
|
});
|
|
|
|
it('git changed a block the human did not -> takes git', () => {
|
|
expect(merge(['1', '2', '3'], ['1', '2', '3'], ['1', '9', '3'])).toEqual([
|
|
'1',
|
|
'9',
|
|
'3',
|
|
]);
|
|
});
|
|
|
|
it('human changed a block git did not -> KEEPS the human edit (the core 3-way win)', () => {
|
|
expect(merge(['1', '2', '3'], ['1', 'H', '3'], ['1', '2', '3'])).toEqual([
|
|
'1',
|
|
'H',
|
|
'3',
|
|
]);
|
|
});
|
|
|
|
it('human and git changed DIFFERENT blocks -> both preserved', () => {
|
|
// human rewrote block 1, git rewrote block 3.
|
|
expect(merge(['1', '2', '3'], ['H', '2', '3'], ['1', '2', 'G'])).toEqual([
|
|
'H',
|
|
'2',
|
|
'G',
|
|
]);
|
|
});
|
|
|
|
it('human inserted a block AND git changed a different block -> both preserved', () => {
|
|
expect(
|
|
merge(['1', '2', '3'], ['1', '1.5', '2', '3'], ['1', '2', 'G']),
|
|
).toEqual(['1', '1.5', '2', 'G']);
|
|
});
|
|
|
|
it('both changed the SAME block -> conflict resolves to git', () => {
|
|
expect(merge(['1', '2', '3'], ['1', 'H', '3'], ['1', 'G', '3'])).toEqual([
|
|
'1',
|
|
'G',
|
|
'3',
|
|
]);
|
|
});
|
|
|
|
it('both made the SAME edit -> that edit (no duplication)', () => {
|
|
expect(merge(['1', '2', '3'], ['1', 'X', '3'], ['1', 'X', '3'])).toEqual([
|
|
'1',
|
|
'X',
|
|
'3',
|
|
]);
|
|
});
|
|
|
|
it('human deleted a block git left alone -> deletion preserved', () => {
|
|
expect(merge(['1', '2', '3'], ['1', '3'], ['1', '2', '3'])).toEqual([
|
|
'1',
|
|
'3',
|
|
]);
|
|
});
|
|
|
|
it('git deleted a block the human left alone -> deletion applied', () => {
|
|
expect(merge(['1', '2', '3'], ['1', '2', '3'], ['1', '3'])).toEqual([
|
|
'1',
|
|
'3',
|
|
]);
|
|
});
|
|
|
|
it('both deleted the same block -> gone (no conflict)', () => {
|
|
expect(merge(['1', '2', '3'], ['1', '3'], ['1', '3'])).toEqual(['1', '3']);
|
|
});
|
|
|
|
it('git appended a trailing block -> appended', () => {
|
|
expect(merge(['1', '2'], ['1', '2'], ['1', '2', '3'])).toEqual([
|
|
'1',
|
|
'2',
|
|
'3',
|
|
]);
|
|
});
|
|
|
|
it('human appended a trailing block git did not -> kept', () => {
|
|
expect(merge(['1', '2'], ['1', '2', '3'], ['1', '2'])).toEqual([
|
|
'1',
|
|
'2',
|
|
'3',
|
|
]);
|
|
});
|
|
|
|
it('empty base, git provides content (brand-new page body) -> git content', () => {
|
|
expect(merge([], [], ['1', '2'])).toEqual(['1', '2']);
|
|
});
|
|
|
|
it('git changed block 1, human edited block 3, far apart -> both kept', () => {
|
|
expect(
|
|
merge(
|
|
['a', 'b', 'c', 'd', 'e'],
|
|
['a', 'b', 'c', 'd', 'E'],
|
|
['A', 'b', 'c', 'd', 'e'],
|
|
),
|
|
).toEqual(['A', 'b', 'c', 'd', 'E']);
|
|
});
|
|
});
|