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:
claude code agent 227
2026-06-28 22:05:32 +03:00
parent b7e5cb6970
commit b47751349f
16 changed files with 948 additions and 106 deletions

View File

@@ -59,12 +59,43 @@ function getStyleProperty(element: HTMLElement, propertyName: string): string |
* `[!note]` / `[!default]` callout authored in the editor would come back as
* `[!info]` after a git sync (the QA "callout type -> [!info]" fidelity loss).
* `note` and `default` were previously absent and so were being flattened.
*
* The editor SCHEMA genuinely only supports these six banner types — there is no
* `tip`/`caution`/`important`/`question` callout node. So those are NOT first-
* class types we can round-trip literally; they are INPUT ALIASES (GitHub/Obsidian
* alert syntax). The editor's own paste/import path maps them onto the supported
* set (see `GITHUB_ALERT_TYPE_MAP` in
* `@docmost/editor-ext` markdown/utils/github-callout.marked.ts:
* tip -> success, caution -> danger, important -> info). We mirror that aliasing
* here so an ingested `> [!tip]` / `> [!caution]` lands on the closest real banner
* (success / danger) instead of flatly collapsing to `info` — matching exactly how
* the editor itself would interpret the same alias. A schema type always maps to
* itself first (idempotent round-trip); the alias map only rewrites NON-schema
* names; anything still unknown falls back to `info`.
*/
const CALLOUT_TYPES = ["default", "info", "note", "success", "warning", "danger"];
export const clampCalloutType = (value: string | null | undefined): string =>
value && CALLOUT_TYPES.includes(value.toLowerCase())
? value.toLowerCase()
: "info";
/**
* NON-schema callout aliases -> their closest supported banner. Mirrors the
* editor's `GITHUB_ALERT_TYPE_MAP` for the names that are NOT already schema
* types (a schema type is preserved as-is and never consulted here). Keeping
* these in lockstep means git-sync ingest and an editor paste interpret the same
* `> [!alias]` identically.
*/
const CALLOUT_TYPE_ALIASES: Record<string, string> = {
tip: "success",
caution: "danger",
important: "info",
};
export const clampCalloutType = (value: string | null | undefined): string => {
if (!value) return "info";
const lower = value.toLowerCase();
// A real schema type round-trips to itself (idempotent).
if (CALLOUT_TYPES.includes(lower)) return lower;
// A known GitHub/Obsidian alias maps to the editor's closest banner.
if (CALLOUT_TYPE_ALIASES[lower]) return CALLOUT_TYPE_ALIASES[lower];
// Anything else is collapsed to the safe default (matches the editor).
return "info";
};
/**
* Allowlist guard for CSS color values imported from HTML.