Files
gitmost/apps/server/src/collaboration/merge/three-way-merge.ts
claude code agent 227 b7e5cb6970 fix(git-sync): push 503 starvation + concurrent-edit marker leak/silent loss
Bug #1 (push 503 starvation): an external receive-pack that briefly overlapped
a poll cycle immediately 503'd because the per-space single-writer lock was
held. Add a BOUNDED retry-acquire on the PUSH path only (SpaceLockService
.withSpaceLock acquireRetry: capped exponential backoff up to ~5s); a transient
overlap now waits and succeeds, a genuinely stuck cycle still 503s after the
bound. The poll cycle passes no retry (immediate skip). Push result stays
deterministic: the receive-pack only runs once the lock is held, so a 503 never
leaves a half-applied ref.

Bug #2 (concurrent-edit marker leak + silent same-block loss):
- Marker leak (a): the push UPDATE path stripped markers for the body sent to
  Docmost but left raw <<<<<<</>>>>>>> committed on the published `main` vault
  forever (autoMergeConflicts ON). Now the cleaned body is written back to the
  vault file + recorded in writtenBack so runPush commits it on `main` and the
  vault converges to clean bytes.
- Marker leak (b): pin merge.conflictStyle=merge in ensureRepo and teach
  stripConflictMarkers/hasConflictMarkers about the diff3 `|||||||` base section
  (drop the marker AND the stale base region) so diff3/zdiff3 conflicts can
  never leak `|||||||` + base content into a page. Also scrub the 3-way merge
  BASE markdown.
- Silent same-block loss: the block 3-way merge still resolves same-block
  conflicts deterministically to git, but it is no longer silent: diff3Plan now
  reports a conflict count (mergeXmlFragments3WayWithStats), gitSyncWriteBody
  logs it, and the persistence boundary-snapshot now fires for git-sync writes
  over a non-git-sync baseline so the human's pre-merge content is preserved in
  page history (recoverable). Full both-preserved persisted-conflict UI remains
  the deferred redesign.

Tests: space-lock bounded-retry (success/stuck/poll-immediate); push vault-clean
+ diff3 |||||||  strip; ensureRepo conflictStyle pin; diff3Plan/3-way conflict
counts; persistence git-sync boundary snapshot. Server tsc clean; git-sync
vitest + server collaboration/git-sync jest all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 20:03:21 +03:00

275 lines
9.7 KiB
TypeScript

/**
* Pure block-level THREE-WAY merge planner (diff3) over arrays of opaque block
* keys. Used by the git-sync body write to merge an incoming git body into the
* live page using the last-synced version as the common ancestor (review #5):
*
* - a block only the human changed (live != base, git == base) -> keep LIVE
* - a block only git changed (git != base, live == base) -> take GIT
* - a block both sides changed (a real conflict) -> GIT wins
* - inserts/deletes from either side are preserved when unambiguous
*
* Content-agnostic: it works on string keys and returns the merged block order as
* picks ({ src: 'live'|'target', index }) — the caller (the Yjs applier)
* materializes them — so the whole algorithm is unit-testable on plain arrays.
*
* Algorithm: anchor on base blocks present (unchanged) in BOTH live and target
* (their LCS-with-base intersection). Between consecutive anchors lies one region
* the human and/or git rewrote; resolve each region three-way. Stable anchor
* blocks are emitted from LIVE so the applier keeps the existing Yjs block
* instances (and the human's in-flight edits) in place.
*
* LOCATION (deferred): this and its `lcs.ts` sibling are pure, framework-free and
* could conceptually live in `packages/git-sync` (the engine). They are kept in
* the server integration on purpose: `packages/git-sync` is a VENDORED engine
* (pinned upstream, manually re-synced), so adding first-party files there
* complicates the re-sync story, and the only consumer today is the server. Move
* them into the engine only once the vendoring re-sync story is settled.
*/
import { buildLcsTable } from './lcs';
/** Matched index pairs of the longest common subsequence of `a` and `b`. */
function lcsPairs(a: string[], b: string[]): Array<[number, number]> {
const n = a.length;
const m = b.length;
const dp = buildLcsTable(a, b);
const pairs: Array<[number, number]> = [];
let i = 0;
let j = 0;
while (i < n && j < m) {
if (a[i] === b[j]) {
pairs.push([i, j]);
i++;
j++;
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
i++;
} else {
j++;
}
}
return pairs;
}
/** o-index -> matched index in the other side (only for LCS-matched blocks). */
function matchMap(pairs: Array<[number, number]>): Map<number, number> {
const m = new Map<number, number>();
for (const [o, x] of pairs) m.set(o, x);
return m;
}
/**
* One change `side` made to `base` within a region: base blocks `[oStart,oEnd)`
* were replaced by the side's blocks listed in `content` (region-local indices).
* A pure insert has `oStart === oEnd`; a pure delete has empty `content`.
*/
interface Hunk {
oStart: number;
oEnd: number;
content: number[];
}
/**
* Diff `o` against one side as a list of non-overlapping hunks (the base spans
* the side rewrote/inserted/deleted), derived from their LCS alignment.
*/
function buildHunks(o: string[], side: string[]): Hunk[] {
const pairs = lcsPairs(o, side); // [oIdx, sideIdx] kept (unchanged) blocks
const hunks: Hunk[] = [];
let prevO = -1;
let prevS = -1;
const flush = (curO: number, curS: number): void => {
const oStart = prevO + 1;
const oEnd = curO;
const content: number[] = [];
for (let s = prevS + 1; s < curS; s++) content.push(s);
if (oEnd > oStart || content.length > 0) hunks.push({ oStart, oEnd, content });
};
for (const [oIdx, sIdx] of pairs) {
flush(oIdx, sIdx);
prevO = oIdx;
prevS = sIdx;
}
flush(o.length, side.length);
return hunks;
}
/**
* Do two hunks (one per side) touch the same base region? Pure inserts only
* collide when nested strictly inside the other hunk's base span (or, for two
* inserts, at the same gap); changes sitting at a shared boundary do not.
*/
function hunksOverlap(a: Hunk, b: Hunk): boolean {
const aIns = a.oStart === a.oEnd;
const bIns = b.oStart === b.oEnd;
if (aIns && bIns) return a.oStart === b.oStart;
if (aIns) return b.oStart < a.oStart && a.oStart < b.oEnd;
if (bIns) return a.oStart < b.oStart && b.oStart < a.oEnd;
return Math.max(a.oStart, b.oStart) < Math.min(a.oEnd, b.oEnd);
}
interface LocalPick {
src: 'live' | 'target';
local: number;
}
/**
* Fine-grained three-way merge of ONE inter-anchor region. Combines the human's
* and git's NON-overlapping hunks (e.g. a human edit to one block plus a git
* insert/delete of OTHER blocks in the same region) so neither change is lost.
* Returns the merged region as region-local picks, or `null` when the two sides
* changed the SAME base block — a genuine conflict the caller resolves by the
* original all-or-nothing rule (git wins the whole region).
*/
function tryMergeRegion(
o: string[],
a: string[],
b: string[],
): LocalPick[] | null {
const aHunks = buildHunks(o, a);
const bHunks = buildHunks(o, b);
// Any overlap between a human hunk and a git hunk is a real conflict; bail so
// the caller falls back to git-wins (preserving the original behavior).
for (const ah of aHunks) {
for (const bh of bHunks) {
if (hunksOverlap(ah, bh)) return null;
}
}
// Disjoint: live index of each base block that BOTH sides kept (stable).
const aKept = matchMap(lcsPairs(o, a)); // base index -> live index
const out: LocalPick[] = [];
let pa = 0;
let pb = 0;
let oi = 0;
while (oi < o.length || pa < aHunks.length || pb < bHunks.length) {
const ah = pa < aHunks.length ? aHunks[pa] : null;
const bh = pb < bHunks.length ? bHunks[pb] : null;
const nextStart = Math.min(
ah ? ah.oStart : o.length,
bh ? bh.oStart : o.length,
);
// Emit stable base blocks (kept by both) until the next hunk, from LIVE.
while (oi < nextStart) {
out.push({ src: 'live', local: aKept.get(oi) as number });
oi++;
}
if (!ah && !bh) break;
// Apply the hunk at oi. When both sides act here they are disjoint, so the
// pure-insert (oEnd === oi) is emitted before the side that consumes base oi.
const aHere = ah !== null && ah.oStart === oi;
const bHere = bh !== null && bh.oStart === oi;
let useA: boolean;
if (aHere && bHere) {
useA = ah!.oEnd === oi; // insert side first; otherwise either order is fine
} else {
useA = aHere;
}
const h = (useA ? ah : bh) as Hunk;
const src: 'live' | 'target' = useA ? 'live' : 'target';
for (const idx of h.content) out.push({ src, local: idx });
oi = h.oEnd;
if (useA) pa++;
else pb++;
}
return out;
}
export interface Pick {
src: 'live' | 'target';
index: number;
}
/**
* The merged block order PLUS how many regions resolved as a genuine SAME-BLOCK
* conflict (both sides rewrote the same base block — `tryMergeRegion` returned
* null and git won the whole region, so the live/human version of those blocks
* is NOT in `picks`). `conflicts > 0` is the OBSERVABLE signal the caller uses to
* surface "git won a concurrent same-block edit" (log it + pin the human
* baseline to page history) instead of dropping the human side silently.
*/
export interface Diff3Result {
picks: Pick[];
conflicts: number;
}
/**
* Three-way merge of base `o`, live `a`, target `b` (arrays of block keys).
* Returns the merged block order as picks from live/target. Thin wrapper over
* `diff3PlanWithConflicts` (kept for the existing pure-array callers/tests).
*/
export function diff3Plan(o: string[], a: string[], b: string[]): Pick[] {
return diff3PlanWithConflicts(o, a, b).picks;
}
/**
* Like `diff3Plan` but also reports the SAME-BLOCK conflict count (see
* `Diff3Result`). A region where both the human and git rewrote the same base
* block cannot be merged automatically; the rule is deterministic — GIT WINS the
* whole region — but the human's version of those blocks is then absent from the
* picks, so we count it so the caller can make the loss observable/recoverable
* rather than silent (the documented conflict contract).
*/
export function diff3PlanWithConflicts(
o: string[],
a: string[],
b: string[],
): Diff3Result {
const oToA = matchMap(lcsPairs(o, a));
const oToB = matchMap(lcsPairs(o, b));
const res: Pick[] = [];
let conflicts = 0;
let oi = 0;
let ai = 0;
let bi = 0;
for (;;) {
// Next anchor: a base block present (unchanged) in BOTH live and target.
let anchor = oi;
while (anchor < o.length && !(oToA.has(anchor) && oToB.has(anchor))) {
anchor++;
}
const aEnd = anchor < o.length ? (oToA.get(anchor) as number) : a.length;
const bEnd = anchor < o.length ? (oToB.get(anchor) as number) : b.length;
// Resolve the region [oi,anchor) that one or both sides rewrote/inserted.
// Try a fine-grained three-way merge first so a human block-edit survives a
// git insert/delete of OTHER blocks in the same region; only a genuine
// same-block conflict (null) falls back to the original git-wins rule.
const merged = tryMergeRegion(
o.slice(oi, anchor),
a.slice(ai, aEnd),
b.slice(bi, bEnd),
);
if (merged) {
for (const p of merged) {
res.push(
p.src === 'live'
? { src: 'live', index: ai + p.local }
: { src: 'target', index: bi + p.local },
);
}
} else {
// SAME-BLOCK CONFLICT: count it ONLY when the human side actually had
// content in this region that git's win discards (live region non-empty).
// A region only git rewrote (live region empty) is not a human loss.
if (aEnd > ai) conflicts++;
for (let k = bi; k < bEnd; k++) res.push({ src: 'target', index: k });
}
if (anchor >= o.length) break;
// Emit the stable anchor block from LIVE, then advance past it on all sides.
res.push({ src: 'live', index: aEnd });
ai = aEnd + 1;
bi = bEnd + 1;
oi = anchor + 1;
}
return { picks: res, conflicts };
}