fix(git-sync): kill spurious marker-leaking conflict, concurrent-edit loss, flapping HEAD
Three more git-sync QA defects from the 2nd live pass on PR #119, plus a callout-fidelity nit: 1. SPURIOUS conflict leaked raw markers into canonical main (root cause). On an ordinary round-trip the only difference between the docmost mirror (normalize- on-write) and a user's raw push is trailing/empty-line normalization, which made git's line-based docmost->main merge CONFLICT, and the wedge fix then committed the file WITH literal <<<<<<< / ======= / >>>>>>> markers onto main (git and the DB silently diverged for cycles). Fix: on a conflict, normalize trailing/empty lines on BOTH sides (showStage :2:/:3:) before comparing — a trailing-only diff is recognized as spurious and resolved to the clean normalized form. A GENUINE same-block conflict is auto-resolved to OURS (git wins, mirroring the live-doc 3-way rule); the docmost side stays on the `docmost` branch + page history. Raw markers NEVER reach main again. 2. Concurrent UI<->git edit silently lost the UI side. The git->Docmost 3-way merge ran against a live Y.Doc that hadn't yet received the user's debounced in-flight edit, so git clean-applied (no conflict detected) and the edit vanished even on a different block. Fix: flush the pending debounced store before the merge so the in-flight edit is drained into the live doc first — a different-block edit is merged, a same-block one is detected and pinned to history (recoverable). 3. Smart-HTTP HEAD flapped to the read-only `docmost` mirror (~1/4 of clones). The engine transiently checks out `docmost` mid-pull and the host advertises whatever HEAD resolves to. Fix: VaultGit.pinHeadToMain(); the cycle restores HEAD->main in a finally; and the upload-pack ref advertisement is served HEAD-pinned under the per-space lock so it can never observe a mid-cycle HEAD. 4. (callout) clampCalloutType now mirrors the editor's GITHUB_ALERT_TYPE_MAP for non-schema aliases (tip->success, caution->danger, important->info) instead of flatly collapsing to info. The editor schema genuinely supports only the six banner types, so unknown types still fall back to info (by design). Tests: deterministic real-git trailing-blank round-trip (no conflict, no markers, in sync over 2 cycles) + genuine-conflict no-marker-leak; HEAD advertisement stability; pre/post-flush concurrent-edit survival; serveReadAdvertisement lock pin; widened callout-alias coverage. Engine vitest + server tsc + collaboration / git-http / orchestrator specs all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -158,6 +158,24 @@ export class CollaborationHandler {
|
||||
)
|
||||
: null;
|
||||
|
||||
// CONCURRENT-EDIT FLUSH (QA #119, finding #2). The 3-way merge below runs
|
||||
// against the LIVE Y.Doc, so a concurrent UI edit is only preserved if it
|
||||
// is already part of that doc. A user's edit is debounced before it lands
|
||||
// (the editor batches; the collab store is debounced up to 10s), so the
|
||||
// merge could otherwise run against a PRE-EDIT doc: git would then
|
||||
// clean-apply (no same-block conflict detected) and the in-flight UI edit
|
||||
// — even on a DIFFERENT block — would be silently dropped.
|
||||
//
|
||||
// Flushing the pending debounced store here (a) drains the event loop so a
|
||||
// just-arrived client Yjs update is applied to the live doc BEFORE we
|
||||
// merge, and (b) persists the live doc so the merge baseline is current
|
||||
// even on the doc-reload-from-DB path. After the flush the merge sees the
|
||||
// latest state, so an edit on a different block is MERGED (not overwritten)
|
||||
// and a genuine same-block edit is detected as a conflict -> the
|
||||
// boundary-snapshot in PersistenceExtension pins it to page history
|
||||
// (recoverable) instead of vanishing silently.
|
||||
await this.flushPendingStore(hocuspocus, documentName);
|
||||
|
||||
// actor:'git-sync' + the service user flow into PersistenceExtension
|
||||
// (lastUpdatedSource='git-sync', lastUpdatedById=userId).
|
||||
await this.withYdocConnection(
|
||||
@@ -195,6 +213,33 @@ export class CollaborationHandler {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush any pending DEBOUNCED store for `documentName` so the live Y.Doc and the
|
||||
* DB are current BEFORE a git-sync merge reads them (QA #119, finding #2 —
|
||||
* concurrent UI edit silently lost). Mirrors the PersistenceExtension.onDisconnect
|
||||
* flush: only acts when a store is actually pending (`isDebounced`), runs the
|
||||
* SAME scheduled payload (`executeNow`, preserving the edit's context/actor), and
|
||||
* never throws — a flush failure must not abort the git-sync write. Awaiting it
|
||||
* also drains the event loop, so a client Yjs update sitting in the socket buffer
|
||||
* is applied to the live doc before the merge transaction runs.
|
||||
*/
|
||||
private async flushPendingStore(
|
||||
hocuspocus: Hocuspocus,
|
||||
documentName: string,
|
||||
): Promise<void> {
|
||||
const debounceId = `onStoreDocument-${documentName}`;
|
||||
try {
|
||||
const debouncer = (hocuspocus as any)?.debouncer;
|
||||
if (!debouncer?.isDebounced?.(debounceId)) return;
|
||||
await debouncer.executeNow(debounceId);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`git-sync pre-merge flush failed for ${documentName}: ` +
|
||||
(err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async withYdocConnection(
|
||||
hocuspocus: Hocuspocus,
|
||||
documentName: string,
|
||||
|
||||
Reference in New Issue
Block a user