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:
a
2026-06-27 19:50:28 +03:00
committed by claude code agent 227
parent eefe17600c
commit fe4adf23a0
11 changed files with 517 additions and 28 deletions

View File

@@ -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).

View File

@@ -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

View File

@@ -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,
};
}

View File

@@ -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()

View File

@@ -66,6 +66,10 @@ function makeGit(merge: { ok: boolean; conflict: boolean; output?: string } = {
order.push('merge');
return { ok: merge.ok, conflict: merge.conflict, output: merge.output ?? '' };
}),
listUnmergedPaths: vi.fn(async () => ['Conflicted.md']),
commitMerge: vi.fn(async (subject: string) => {
order.push(`commitMerge:${subject}`);
}),
};
return {
git,
@@ -403,7 +407,11 @@ describe('applyPullActions — commit subject reflects ACTUAL counts', () => {
});
describe('applyPullActions — merge result is surfaced, not swallowed', () => {
it('returns conflict:true on a conflicting merge (no auto-resolve)', async () => {
it('COMMITS a conflicting merge with markers (no wedge) and surfaces conflictedPaths', async () => {
// Regression for the WEDGE bug (QA #119): a conflicting docmost -> main merge
// must NOT be left mid-merge (which wedged the whole space). It is committed
// WITH markers so the rest of the space keeps syncing; the conflicted page is
// surfaced in `conflictedPaths` and isolated by the push side.
const { client } = makeClient();
const g = makeGit({ ok: false, conflict: true, output: 'CONFLICT' });
const fs = makeFs();
@@ -415,6 +423,10 @@ describe('applyPullActions — merge result is surfaced, not swallowed', () => {
);
expect(res.merge.conflict).toBe(true);
expect(res.merge.ok).toBe(false);
// The merge was COMMITTED (vault no longer mid-merge) and the bad page named.
expect(g.git.commitMerge).toHaveBeenCalledTimes(1);
expect(res.conflictedPaths).toEqual(['Conflicted.md']);
expect(g.order.some((o) => o.startsWith('commitMerge:'))).toBe(true);
});
it('returns ok:false conflict:false on a non-conflict merge failure', async () => {

View File

@@ -129,7 +129,7 @@ describe("runCycle against a REAL VaultGit (integration)", () => {
expect(onDisk).toContain("new-id");
});
it("an unresolved merge short-circuits before any client call", async () => {
it("RECOVERS a vault left mid-merge instead of wedging the whole space", async () => {
if (!available) return;
dir = await mkdtemp(join(tmpdir(), "docmost-cycle-merge-"));
@@ -149,8 +149,9 @@ describe("runCycle against a REAL VaultGit (integration)", () => {
await writeFile(join(dir, "C.md"), "main-side\n", "utf8");
await git.stageAll();
await git.commit("main edit", { authorName: "h", authorEmail: "h@l" });
// Start a conflicting merge and leave it unresolved.
// Start a conflicting merge and leave it unresolved (the wedged state).
await execFileAsync("git", ["-C", dir, "merge", "docmost"]).catch(() => {});
expect(await git.isMergeInProgress()).toBe(true);
const client = makeEmptyClientFake();
const res = await runCycle({
@@ -162,8 +163,25 @@ describe("runCycle against a REAL VaultGit (integration)", () => {
log: () => undefined,
});
expect(res).toEqual({ ran: false, skipped: "merge-in-progress" });
expect(client.listSpaceTree).not.toHaveBeenCalled();
expect(client.createPage).not.toHaveBeenCalled();
// WEDGE FIX: the cycle does NOT skip forever — it aborts the stale merge and
// RUNS the full pull/push. The space is no longer frozen.
expect(res.ran).toBe(true);
expect(client.listSpaceTree).toHaveBeenCalled();
// And crucially, the vault is NOT left mid-merge afterward (the re-merge of a
// genuinely conflicting page is committed-with-markers, not wedged), so the
// next cycle can run too.
expect(await git.isMergeInProgress()).toBe(false);
// A SECOND cycle also runs cleanly (proves the wedge is gone for good).
const res2 = await runCycle({
spaceId: "space-1",
client: client as any,
vault: git,
settings: makeSettings(dir),
fs: nodeFs,
log: () => undefined,
});
expect(res2.ran).toBe(true);
expect(await git.isMergeInProgress()).toBe(false);
});
});

View File

@@ -24,6 +24,10 @@ function fakeVault(overrides: Record<string, any> = {}) {
stageAll: rec("stageAll"),
commit: rec("commit", { committed: false }),
merge: rec("merge", { ok: true, conflict: false, output: "" }),
listUnmergedPaths: vi.fn(async () => [] as string[]),
commitMerge: rec("commitMerge"),
abortMerge: rec("abortMerge"),
resetHardToHead: rec("resetHardToHead"),
readRef: vi.fn(async () => null),
revParse: vi.fn(async () => "0000000000000000000000000000000000000000"),
diffNameStatus: vi.fn(async () => [] as any[]),
@@ -64,16 +68,47 @@ function baseDeps(vault: any, over: Partial<RunCycleDeps> = {}): RunCycleDeps {
}
describe("runCycle (composition)", () => {
it("short-circuits with skipped:'merge-in-progress' and runs no pull/push", async () => {
const vault = fakeVault({ isMergeInProgress: vi.fn(async () => true) });
it("RECOVERS from a vault left mid-merge: aborts the stale merge and continues (no wedge)", async () => {
// Regression for the WEDGE bug (QA #119): a vault left mid-merge by a prior
// cycle used to skip the WHOLE space forever. Now the cycle aborts the stale
// merge and proceeds so the space self-heals.
let midMerge = true;
const vault = fakeVault({
// mid-merge until `abortMerge` clears it (then the cycle continues).
isMergeInProgress: vi.fn(async () => midMerge),
abortMerge: vi.fn(async () => {
midMerge = false;
}),
});
const deps = baseDeps(vault);
const res = await runCycle(deps);
expect(res).toEqual({ ran: false, skipped: "merge-in-progress" });
// Never advanced to the pull (listSpaceTree) or push.
expect(deps.client.listSpaceTree).not.toHaveBeenCalled();
expect(vault.order).not.toContain("checkout:docmost");
// The stale merge was aborted and the cycle RAN (no permanent wedge).
expect(vault.abortMerge).toHaveBeenCalledTimes(1);
expect(res.ran).toBe(true);
expect(deps.client.listSpaceTree).toHaveBeenCalledTimes(1);
expect(vault.order).toContain("checkout:docmost");
});
it("hard-resets when 'merge --abort' cannot clear a stray unmerged index", async () => {
// abortMerge does NOT clear it (no MERGE_HEAD but stray unmerged entries);
// the cycle falls back to a hard reset, then proceeds.
let midMerge = true;
const vault = fakeVault({
isMergeInProgress: vi.fn(async () => midMerge),
abortMerge: vi.fn(async () => undefined), // leaves it mid-merge
resetHardToHead: vi.fn(async () => {
midMerge = false;
}),
});
const deps = baseDeps(vault);
const res = await runCycle(deps);
expect(vault.abortMerge).toHaveBeenCalledTimes(1);
expect(vault.resetHardToHead).toHaveBeenCalledTimes(1);
expect(res.ran).toBe(true);
});
it("stages ensureRepo -> ensureBranch(docmost,main) -> checkout(docmost) BEFORE pulling", async () => {

View File

@@ -65,9 +65,21 @@ describe('clampCalloutType', () => {
expect(clampCalloutType('success')).toBe('success');
});
it('falls back to "info" for unknown types', () => {
expect(clampCalloutType('note')).toBe('info');
it('PRESERVES every editor-canonical type (note/default no longer flattened)', () => {
// Regression for the QA "callout type -> [!info]" fidelity loss: `note` and
// `default` are valid editor callout types and must survive the git
// round-trip, not collapse to `info`.
expect(clampCalloutType('note')).toBe('note');
expect(clampCalloutType('default')).toBe('default');
expect(clampCalloutType('info')).toBe('info');
expect(clampCalloutType('warning')).toBe('warning');
expect(clampCalloutType('danger')).toBe('danger');
expect(clampCalloutType('success')).toBe('success');
});
it('falls back to "info" for genuinely unknown types', () => {
expect(clampCalloutType('tip')).toBe('info');
expect(clampCalloutType('banana')).toBe('info');
});
it('falls back to "info" for empty string and null', () => {