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:
@@ -85,14 +85,30 @@ export async function runCycle(deps: RunCycleDeps): Promise<RunCycleResult> {
|
||||
await vault.assertGitAvailable();
|
||||
await vault.ensureRepo();
|
||||
|
||||
// 2. Refuse to run on top of an unresolved merge (SPEC §9): a prior
|
||||
// conflicting pull leaves the vault mid-merge; the next checkout would fail.
|
||||
// 2. RECOVER from a vault left mid-merge by a PRIOR cycle (SPEC §9 wedge fix).
|
||||
// A leftover merge used to WEDGE THE WHOLE SPACE: this check returned
|
||||
// `skipped: "merge-in-progress"` so EVERY later cycle skipped the entire
|
||||
// space (all pages, both directions) forever, with no recovery. The pull
|
||||
// phase below no longer leaves the vault mid-merge (it commits a conflicting
|
||||
// merge with markers and isolates the one bad page), but a vault wedged by a
|
||||
// PRE-FIX build (or a manual/interrupted git op) must still self-heal.
|
||||
// So instead of skipping, ABORT the stale half-merge and continue — the
|
||||
// fresh pull re-runs and, on a real conflict, commits-with-markers rather
|
||||
// than re-wedging. A stray unmerged index that `merge --abort` can't clear
|
||||
// (no MERGE_HEAD) is force-cleared with a hard reset to HEAD.
|
||||
if (await vault.isMergeInProgress()) {
|
||||
log(
|
||||
`vault has an unresolved merge — resolve it (or 'git merge --abort') ` +
|
||||
`and re-run (SPEC §9); skipping cycle.`,
|
||||
`vault was left mid-merge by a prior cycle — aborting the stale merge and ` +
|
||||
`continuing so the space is not wedged (SPEC §9 recovery).`,
|
||||
);
|
||||
return { ran: false, skipped: "merge-in-progress" };
|
||||
await vault.abortMerge();
|
||||
if (await vault.isMergeInProgress()) {
|
||||
log(
|
||||
`vault still mid-merge after 'merge --abort' — hard-resetting to HEAD ` +
|
||||
`to recover (SPEC §9).`,
|
||||
);
|
||||
await vault.resetHardToHead();
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Pull writes happen on `docmost`; be on it BEFORE applying (see docstring).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -218,7 +218,15 @@ export function computePullActions(input: PullActionsInput): PullActions {
|
||||
*/
|
||||
export interface ApplyPullActionsDeps {
|
||||
client: Pick<GitSyncClient, "getPageJson">;
|
||||
git: Pick<VaultGit, "stageAll" | "commit" | "checkout" | "merge">;
|
||||
git: Pick<
|
||||
VaultGit,
|
||||
| "stageAll"
|
||||
| "commit"
|
||||
| "checkout"
|
||||
| "merge"
|
||||
| "listUnmergedPaths"
|
||||
| "commitMerge"
|
||||
>;
|
||||
/** Write a file by ABSOLUTE path (mkdir of the parent is done internally). */
|
||||
writeFile: (absPath: string, text: string) => Promise<void>;
|
||||
/** Recursive mkdir of an ABSOLUTE directory path. */
|
||||
@@ -240,6 +248,13 @@ export interface ApplyResult {
|
||||
failed: number;
|
||||
committed: boolean;
|
||||
merge: { ok: boolean; conflict: boolean; output: string };
|
||||
/**
|
||||
* Vault-relative paths of the page(s) that CONFLICTED in the docmost -> main
|
||||
* merge and were committed WITH conflict markers (so the rest of the space
|
||||
* keeps syncing — SPEC §9 wedge fix). Empty on a clean merge. The push side
|
||||
* isolates these (per-page failure when `autoMergeConflicts` is off).
|
||||
*/
|
||||
conflictedPaths: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -404,20 +419,47 @@ export async function applyPullActions(
|
||||
trailers: [SOURCE_TRAILER],
|
||||
});
|
||||
|
||||
// Merge docmost -> main. Conflicts are surfaced and left in git (SPEC §9);
|
||||
// we never push to Docmost. Push to a git remote is deferred (SPEC §7).
|
||||
// Merge docmost -> main. A CONFLICT must NOT wedge the whole space (the
|
||||
// reported bug: ONE same-line conflict on ONE page froze sync for EVERY page
|
||||
// in both directions because the next cycle's `isMergeInProgress` check kept
|
||||
// skipping the entire space). So instead of leaving the vault mid-merge, we
|
||||
// COMMIT the conflicted merge with markers in place (SPEC §9 wedge fix): the
|
||||
// cleanly-merged pages land, the conflicted page carries its markers on `main`
|
||||
// and is isolated by the push side (a per-page failure when `autoMergeConflicts`
|
||||
// is off — the markers never reach Docmost), and the NEXT cycle is NOT wedged.
|
||||
// Recovery: resolve the markers in git; the next push then sends the clean body.
|
||||
await git.checkout(DEFAULT_BRANCH);
|
||||
const merge = await git.merge(DOCMOST_BRANCH);
|
||||
let conflictedPaths: string[] = [];
|
||||
if (merge.conflict) {
|
||||
conflictedPaths = await git.listUnmergedPaths();
|
||||
await git.commitMerge(
|
||||
`docmost: sync with unresolved conflict in ${conflictedPaths.length} page(s)`,
|
||||
{
|
||||
authorName: BOT_AUTHOR_NAME,
|
||||
authorEmail: BOT_AUTHOR_EMAIL,
|
||||
trailers: [SOURCE_TRAILER],
|
||||
},
|
||||
);
|
||||
log(
|
||||
"pull: merge of docmost -> main CONFLICTED. Conflict markers were left " +
|
||||
"in the vault for manual resolution (SPEC §9). Nothing is pushed to " +
|
||||
"Docmost (read-only). Resolve locally, then re-run.",
|
||||
`pull: merge of docmost -> main CONFLICTED on ${conflictedPaths.length} ` +
|
||||
`page(s): ${conflictedPaths.join(", ")}. Committed the merge WITH ` +
|
||||
`conflict markers so the rest of the space keeps syncing (SPEC §9). The ` +
|
||||
`conflicted page(s) are isolated on push (markers never reach Docmost); ` +
|
||||
`resolve the markers in git to recover.`,
|
||||
);
|
||||
} else if (!merge.ok) {
|
||||
log(`pull: merge of docmost -> main failed: ${merge.output}`);
|
||||
}
|
||||
log("pull: git push to remote is DEFERRED in this increment (SPEC §7).");
|
||||
|
||||
return { written, movedApplied, deleted, failed, committed, merge };
|
||||
return {
|
||||
written,
|
||||
movedApplied,
|
||||
deleted,
|
||||
failed,
|
||||
committed,
|
||||
merge,
|
||||
conflictedPaths,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -49,8 +49,18 @@ function getStyleProperty(element: HTMLElement, propertyName: string): string |
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Allowed Docmost callout types; anything else falls back to "info". */
|
||||
const CALLOUT_TYPES = ["info", "warning", "danger", "success"];
|
||||
/**
|
||||
* Allowed Docmost callout types; anything else falls back to "info".
|
||||
*
|
||||
* This MUST stay in lockstep with the editor's canonical set
|
||||
* (`getValidCalloutType` in `@docmost/editor-ext` callout/utils.ts:
|
||||
* default | info | note | success | warning | danger). A type missing here is
|
||||
* silently flattened to "info" on the markdown -> ProseMirror round-trip, so a
|
||||
* `[!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.
|
||||
*/
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user