fix(git-sync): unwedge per-page conflicts, preserve callout types, flush collab on disconnect
Addresses QA findings on PR #119 (issues #235/#236). SYNC-WEDGE (HIGH): one same-line conflict on one page froze sync for the WHOLE space in both directions forever. The pull's docmost->main merge left the vault mid-merge, so every later cycle's isMergeInProgress() check returned skipped:"merge-in-progress" and skipped the entire space with no recovery. - pull.ts now COMMITS a conflicting merge with markers in place (commitMerge): cleanly-merged pages land, the conflicted page carries its markers on main and is isolated by the existing push-side conflict-marker skip (markers never reach Docmost), and the next cycle is no longer wedged. conflictedPaths is surfaced. - cycle.ts now RECOVERS a vault left mid-merge by a prior/pre-fix cycle: it aborts the stale merge (merge --abort, hard-reset fallback) and continues, instead of skipping the space forever. - git.ts: listUnmergedPaths / commitMerge / abortMerge / resetHardToHead. CALLOUT TYPE FIDELITY: git-sync's CALLOUT_TYPES was missing "note" and "default" (editor-canonical types), so [!note]/[!default] callouts flattened to [!info] on every round-trip. Aligned the list with @docmost/editor-ext getValidCalloutType. LOSS-ON-FAST-CLOSE: editing a page then closing the tab inside the collab debounce window (~3-18s) lost the edit, because with unloadImmediately:false Hocuspocus does not flush the debounced onStoreDocument on the last-client disconnect. PersistenceExtension.onDisconnect now flushes the pending store (debouncer.executeNow) on the last disconnect only, with no redundant write. DUPLICATION re-verify (#1): the schema-default merge-key normalization is intact; faithful toYdoc-based reproduction shows callout + rich content resync with 0 ops and no growth/strip across cycles -> the re-report was leftover vault data, not a live regression. Locked with a callout regression spec. Tests: git-sync 688 pass (incl. real-VaultGit wedge-recovery integration); server git-sync+collaboration 285 pass; new callout merge/fidelity + onDisconnect-flush specs. tsc --noEmit clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -414,6 +414,65 @@ export class VaultGit {
|
||||
return r.code === 0 && r.stdout.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The vault-relative (forward-slash) paths with UNMERGED (conflicted) index
|
||||
* entries after a conflicting merge. NUL-delimited + `core.quotepath=false`
|
||||
* (the `runRaw` baseline) so Cyrillic/space paths come back verbatim. Used by
|
||||
* the pull cycle to LOG and ISOLATE the conflicted page(s) when it commits a
|
||||
* conflicted merge instead of leaving the whole vault wedged (SPEC §9 wedge
|
||||
* fix). Returns `[]` on any error (best-effort diagnostics).
|
||||
*/
|
||||
async listUnmergedPaths(): Promise<string[]> {
|
||||
const r = await this.runRaw([
|
||||
"diff",
|
||||
"--name-only",
|
||||
"--diff-filter=U",
|
||||
"-z",
|
||||
]);
|
||||
if (r.code !== 0) return [];
|
||||
return r.stdout.split("\0").filter((p) => p.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit an IN-PROGRESS (conflicted) merge AS-IS so the vault is NOT left
|
||||
* wedged mid-merge (SPEC §9 wedge fix). A `git merge` that conflicts leaves
|
||||
* `MERGE_HEAD` + unmerged index entries; the next cycle's `isMergeInProgress`
|
||||
* check would then skip the ENTIRE space forever (the reported wedge). Instead
|
||||
* we stage everything — including the conflicted file(s), whose conflict
|
||||
* markers are PRESERVED in the committed tree — and record the two-parent merge
|
||||
* commit. The cleanly-merged pages land normally; the conflicted page carries
|
||||
* its markers on `main`, where the push side isolates it (a per-page push
|
||||
* failure when `autoMergeConflicts` is off; the markers never reach Docmost)
|
||||
* while every other page keeps syncing. Recovery: resolve the markers in git
|
||||
* and the next push sends the clean body.
|
||||
*
|
||||
* `--allow-empty` guards the degenerate case where the staged conflict
|
||||
* resolution nets to no tree change; while `MERGE_HEAD` exists `git commit`
|
||||
* still records the merge commit so the half-merge is cleared.
|
||||
*/
|
||||
async commitMerge(message: string, opts: CommitOptions): Promise<void> {
|
||||
await this.run(["add", "-A"]);
|
||||
await this.commitRaw(message, { ...opts, allowEmpty: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort an in-progress merge (`git merge --abort`), restoring the pre-merge
|
||||
* working tree + index. Best-effort: a non-zero exit (e.g. no MERGE_HEAD) is
|
||||
* swallowed. Used by the cycle's RECOVERY path to unwedge a vault that a
|
||||
* PRIOR (pre-fix) cycle left mid-merge, so the fresh pull can re-run instead of
|
||||
* skipping the space forever (SPEC §9 wedge recovery).
|
||||
*/
|
||||
async abortMerge(): Promise<void> {
|
||||
await this.runRaw(["merge", "--abort"]);
|
||||
}
|
||||
|
||||
/** Hard-reset the working tree + index to HEAD (drops a stray half-merge that
|
||||
* `merge --abort` could not clear — no MERGE_HEAD but lingering unmerged
|
||||
* entries). Best-effort recovery primitive (SPEC §9). */
|
||||
async resetHardToHead(): Promise<void> {
|
||||
await this.runRaw(["reset", "--hard", "HEAD"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* List tracked files on the current branch (paths relative to the vault
|
||||
* root, forward-slash separated). An optional glob (a git pathspec) narrows
|
||||
|
||||
Reference in New Issue
Block a user