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

@@ -44,12 +44,24 @@ function makeClient(opts?: { failFor?: Set<string> }) {
}
/** A git fake recording the order of ops; merge result is configurable. */
function makeGit(merge: { ok: boolean; conflict: boolean; output?: string } = {
ok: true,
conflict: false,
}) {
function makeGit(
merge: { ok: boolean; conflict: boolean; output?: string } = {
ok: true,
conflict: false,
},
conflictStages?: {
unmerged?: string[];
/** path -> { ours, theirs } blob content for showStage(2|3, path). */
stages?: Record<string, { ours: string | null; theirs: string | null }>;
},
) {
const order: string[] = [];
let committedSubject: string | undefined;
const unmerged = conflictStages?.unmerged ?? ['Conflicted.md'];
// Default stages: genuinely-different ours/theirs (a real same-block conflict).
const stages = conflictStages?.stages ?? {
'Conflicted.md': { ours: 'git side\n', theirs: 'docmost side\n' },
};
const git = {
stageAll: vi.fn(async () => {
order.push('stageAll');
@@ -66,7 +78,12 @@ 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']),
listUnmergedPaths: vi.fn(async () => unmerged),
showStage: vi.fn(async (stage: 1 | 2 | 3, path: string) => {
const s = stages[path];
if (!s) return null;
return stage === 2 ? s.ours : stage === 3 ? s.theirs : null;
}),
commitMerge: vi.fn(async (subject: string) => {
order.push(`commitMerge:${subject}`);
}),
@@ -407,13 +424,22 @@ describe('applyPullActions — commit subject reflects ACTUAL counts', () => {
});
describe('applyPullActions — merge result is surfaced, not swallowed', () => {
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.
it('GENUINE conflict: auto-resolves to OURS (git wins), no markers, surfaces conflictedPaths', async () => {
// QA #119 round-2: a genuine same-block docmost -> main conflict must NOT be
// committed with raw markers onto `main` (external clones would see them and
// the body re-conflicts forever). It is auto-resolved to the git/main side
// (git wins, SPEC §9), the conflicted page is surfaced in `conflictedPaths`,
// and the merge is committed CLEAN (no wedge).
const { client } = makeClient();
const g = makeGit({ ok: false, conflict: true, output: 'CONFLICT' });
const g = makeGit(
{ ok: false, conflict: true, output: 'CONFLICT' },
{
unmerged: ['Conflicted.md'],
stages: {
'Conflicted.md': { ours: 'git wins body\n', theirs: 'docmost body\n' },
},
},
);
const fs = makeFs();
const res = await applyPullActions(
@@ -421,14 +447,55 @@ describe('applyPullActions — merge result is surfaced, not swallowed', () => {
actions({ toWrite: [{ pageId: 'p1', relPath: 'A.md' }] }),
VAULT,
);
// A genuine conflict was detected and auto-resolved (git won): reported as a
// (now-clean) committed merge with the conflicting page surfaced.
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.merge.ok).toBe(true);
expect(res.conflictedPaths).toEqual(['Conflicted.md']);
// The conflicted file was rewritten with OURS (git side) — NO markers.
const resolved = fs.writes.find((w) => w.abs === '/vault/Conflicted.md');
expect(resolved?.text).toBe('git wins body\n');
expect(resolved?.text).not.toContain('<<<<<<<');
expect(resolved?.text).not.toContain('>>>>>>>');
// The merge was COMMITTED (vault no longer mid-merge).
expect(g.git.commitMerge).toHaveBeenCalledTimes(1);
expect(g.order.some((o) => o.startsWith('commitMerge:'))).toBe(true);
});
it('SPURIOUS conflict (trailing-blank only): normalizes clean, NOT reported as a conflict', async () => {
// Root-cause fix: when the two sides differ ONLY in trailing/empty lines (the
// normalize-on-write form vs a user's blank-line append), the conflict is
// spurious — both normalize to the same text. It is resolved to the normalized
// form (no markers) and NOT counted as a conflict (so /status does not cry wolf).
const { client } = makeClient();
const g = makeGit(
{ ok: false, conflict: true, output: 'CONFLICT' },
{
unmerged: ['Trailing.md'],
stages: {
// Same content; OURS has a double-blank-line append, THEIRS is normalized.
'Trailing.md': { ours: 'Hello world\n\n\n', theirs: 'Hello world\n' },
},
},
);
const fs = makeFs();
const res = await applyPullActions(
deps(client, g.git, fs),
actions({ toWrite: [{ pageId: 'p1', relPath: 'A.md' }] }),
VAULT,
);
// No GENUINE conflict — reported clean.
expect(res.merge.conflict).toBe(false);
expect(res.merge.ok).toBe(true);
expect(res.conflictedPaths).toEqual([]);
// The file was rewritten to the canonical normalized form (single trailing \n).
const resolved = fs.writes.find((w) => w.abs === '/vault/Trailing.md');
expect(resolved?.text).toBe('Hello world\n');
// Still committed (clears the merge), but as a clean merge.
expect(g.git.commitMerge).toHaveBeenCalledTimes(1);
});
it('returns ok:false conflict:false on a non-conflict merge failure', async () => {
const { client } = makeClient();
const g = makeGit({ ok: false, conflict: false, output: 'some error' });

View File

@@ -77,8 +77,20 @@ describe('clampCalloutType', () => {
expect(clampCalloutType('success')).toBe('success');
});
it('maps GitHub/Obsidian alert ALIASES to the editor banner (not flatly info)', () => {
// The editor schema has no tip/caution/important callout node — they are input
// aliases the editor's own paste path maps onto the supported set
// (GITHUB_ALERT_TYPE_MAP in editor-ext). git-sync mirrors that aliasing so an
// ingested `> [!tip]` / `> [!caution]` lands on the closest real banner instead
// of collapsing everything to `info`.
expect(clampCalloutType('tip')).toBe('success');
expect(clampCalloutType('TIP')).toBe('success');
expect(clampCalloutType('caution')).toBe('danger');
expect(clampCalloutType('important')).toBe('info');
});
it('falls back to "info" for genuinely unknown types', () => {
expect(clampCalloutType('tip')).toBe('info');
expect(clampCalloutType('question')).toBe('info');
expect(clampCalloutType('banana')).toBe('info');
});

View File

@@ -0,0 +1,97 @@
import { execFile } from 'node:child_process';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { promisify } from 'node:util';
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
import {
VaultGit,
BOT_AUTHOR_NAME,
BOT_AUTHOR_EMAIL,
} from '../src/engine/git';
/**
* QA #119 bug #3 — the smart-HTTP host advertises whatever `HEAD` resolves to as
* a clone's default branch. The engine transiently checks out the read-only
* `docmost` mirror during a cycle, so a clone racing a cycle could default to
* `docmost`. `VaultGit.pinHeadToMain()` pins the symref back to `main` so the
* advertised HEAD is deterministic. Verified against a REAL temp git repo,
* including the actual `git upload-pack --advertise-refs` HEAD symref capability
* a clone reads. Skips gracefully if git is unavailable.
*/
const execFileAsync = promisify(execFile);
async function gitAvailable(): Promise<boolean> {
try {
await execFileAsync('git', ['--version']);
return true;
} catch {
return false;
}
}
describe('VaultGit.pinHeadToMain — advertised HEAD is stably main (real git)', () => {
let available = false;
let dir: string;
beforeAll(async () => {
available = await gitAvailable();
});
afterEach(async () => {
if (dir) await rm(dir, { recursive: true, force: true });
});
async function headSymref(vault: string): Promise<string> {
const { stdout } = await execFileAsync(
'git',
['symbolic-ref', '--short', 'HEAD'],
{ cwd: vault },
);
return stdout.trim();
}
/** The HEAD symref a clone would read from `git upload-pack --advertise-refs`. */
async function advertisedHead(vault: string): Promise<string | null> {
const { stdout } = await execFileAsync(
'git',
['upload-pack', '--advertise-refs', vault],
{ cwd: vault },
);
// protocol v0/v2 advertise `symref=HEAD:refs/heads/<branch>` in the caps.
const m = stdout.match(/symref=HEAD:refs\/heads\/([^\s\0]+)/);
return m ? m[1] : null;
}
it('pins HEAD back to main after the engine checked out docmost', async () => {
if (!available) return;
dir = await mkdtemp(join(tmpdir(), 'docmost-head-'));
const git = new VaultGit(dir);
await git.ensureRepo();
await git.ensureBranch('docmost', 'main');
await writeFile(join(dir, 'A.md'), 'hello\n', 'utf8');
await git.stageAll();
await git.commit('seed', {
authorName: BOT_AUTHOR_NAME,
authorEmail: BOT_AUTHOR_EMAIL,
});
// Keep docmost reachable as a real branch ref.
await execFileAsync('git', ['branch', '-f', 'docmost', 'main'], { cwd: dir });
// Simulate a cycle mid-pull: the engine checks out the read-only mirror.
await git.checkout('docmost');
expect(await headSymref(dir)).toBe('docmost');
expect(await advertisedHead(dir)).toBe('docmost'); // the bug, pre-pin
// Pin: the advertised default branch must be `main` again.
await git.pinHeadToMain();
expect(await headSymref(dir)).toBe('main');
expect(await advertisedHead(dir)).toBe('main');
// Idempotent: pinning when already on main is a clean no-op.
await git.pinHeadToMain();
expect(await headSymref(dir)).toBe('main');
expect(await advertisedHead(dir)).toBe('main');
});
});

View File

@@ -307,23 +307,33 @@ describe('import: highlight/textStyle color sanitization (parseHTML)', () => {
});
// ---------------------------------------------------------------------------
// Spec 2. Importing an unsupported callout fence clamps the type to 'info'.
// Spec 2. Importing a non-schema callout fence resolves the type via the editor's
// alias map (known GitHub/Obsidian aliases) or clamps to 'info' (unknown).
//
// preprocessCallouts emits div[data-type=callout][data-callout-type=tip]; the
// schema's Callout.type parseHTML pipes 'tip' through clampCalloutType, which
// maps the unknown type to the 'info' default. End-to-end import-side clamp.
// preprocessCallouts emits div[data-type=callout][data-callout-type=<type>]; the
// schema's Callout.type parseHTML pipes it through clampCalloutType. A known alias
// (`tip`) maps to the editor's banner (`success`); a genuinely unknown type
// (`banana`) clamps to the 'info' default. End-to-end import-side resolution.
// ---------------------------------------------------------------------------
describe('import: unsupported callout fence clamps type to info', () => {
it("imports ':::tip' as a callout whose attrs.type === 'info'", async () => {
describe('import: non-schema callout fence resolves via alias map / clamps to info', () => {
it("imports ':::tip' as a callout whose attrs.type === 'success' (alias)", async () => {
const doc = await markdownToProseMirror(':::tip\nhello\n:::');
const callouts = findAll(doc, 'callout');
expect(callouts).toHaveLength(1);
expect(callouts[0].attrs.type).toBe('info');
expect(callouts[0].attrs.type).toBe('success');
// The body paragraph survived inside the callout.
expect(allText(callouts[0])).toContain('hello');
const paras = findAll(callouts[0], 'paragraph');
expect(paras.length).toBeGreaterThanOrEqual(1);
});
it("imports ':::banana' (unknown) as a callout whose attrs.type === 'info'", async () => {
const doc = await markdownToProseMirror(':::banana\nhello\n:::');
const callouts = findAll(doc, 'callout');
expect(callouts).toHaveLength(1);
expect(callouts[0].attrs.type).toBe('info');
expect(allText(callouts[0])).toContain('hello');
});
});
// ---------------------------------------------------------------------------

View File

@@ -0,0 +1,199 @@
import { execFile } from 'node:child_process';
import { mkdtemp, readFile, rm, writeFile, mkdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { promisify } from 'node:util';
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
import {
VaultGit,
BOT_AUTHOR_NAME,
BOT_AUTHOR_EMAIL,
} from '../src/engine/git';
import { applyPullActions, type PullActions } from '../src/engine/pull';
/**
* QA #119 round-2 — the docmost -> main merge must NEVER commit raw conflict
* markers onto the published `main` (external clones would see them and the body
* re-conflicts every cycle while git and the DB silently diverge). These run
* against a REAL temp git repo:
*
* 1. SPURIOUS conflict (the root cause): two sides that differ ONLY in
* trailing/empty lines (normalize-on-write vs a user's blank-line append)
* must NOT conflict — they auto-normalize, no markers, and stay in sync over
* repeated cycles.
* 2. GENUINE same-block conflict: still must not leak raw markers into `main`
* (auto-resolved to the git/main side; the docmost side stays recoverable on
* the `docmost` branch).
*
* Skips gracefully if git is unavailable.
*/
const execFileAsync = promisify(execFile);
async function gitAvailable(): Promise<boolean> {
try {
await execFileAsync('git', ['--version']);
return true;
} catch {
return false;
}
}
/** PullActions with everything empty except the given overrides. */
function actions(partial: Partial<PullActions> = {}): PullActions {
return {
toWrite: [],
moved: [],
toDelete: [],
deletionDecision: { apply: true },
existingCount: 0,
plannedDeleteCount: 0,
...partial,
};
}
/** Real-fs/real-git deps for applyPullActions (no client calls when toWrite empty). */
function realDeps(git: VaultGit) {
return {
client: {
getPageJson: async () => {
throw new Error('getPageJson should not be called in these tests');
},
},
git,
writeFile: async (abs: string, text: string) => {
await writeFile(abs, text, 'utf8');
},
mkdir: async (abs: string) => {
await mkdir(abs, { recursive: true });
},
rm: async (abs: string) => {
await rm(abs, { force: true });
},
log: () => {},
};
}
const PAGE = (body: string) => `---\ngitmost_id: p1\n---\n\n${body}`;
describe('pull merge — spurious vs genuine conflict (real git)', () => {
let available = false;
let dir: string;
beforeAll(async () => {
available = await gitAvailable();
});
afterEach(async () => {
if (dir) await rm(dir, { recursive: true, force: true });
});
async function commitOn(git: VaultGit, subject: string): Promise<void> {
await git.stageAll();
await git.commit(subject, {
authorName: BOT_AUTHOR_NAME,
authorEmail: BOT_AUTHOR_EMAIL,
});
}
/**
* Build a repo where `main` and `docmost` have DIVERGED from a shared base on
* the SAME file, so `applyPullActions`'s docmost -> main merge does a real
* 3-way merge. `ours`/`theirs`/`base` are the file BODIES for main/docmost/base.
*/
async function divergedRepo(opts: {
base: string;
ours: string;
theirs: string;
}): Promise<{ vault: string; git: VaultGit; file: string }> {
dir = await mkdtemp(join(tmpdir(), 'docmost-conflict-'));
const git = new VaultGit(dir);
await git.ensureRepo();
await git.ensureBranch('docmost', 'main');
const file = 'Doc.md';
// base commit on main, then re-fork docmost from it (merge-base = base).
await writeFile(join(dir, file), PAGE(opts.base), 'utf8');
await commitOn(git, 'base');
await execFileAsync('git', ['branch', '-f', 'docmost', 'main'], { cwd: dir });
// docmost side.
await git.checkout('docmost');
await writeFile(join(dir, file), PAGE(opts.theirs), 'utf8');
await commitOn(git, 'docmost: change');
// main side (diverges from base too -> a real 3-way merge, not a ff).
await git.checkout('main');
await writeFile(join(dir, file), PAGE(opts.ours), 'utf8');
await commitOn(git, 'local: change');
// The cycle calls applyPullActions while on `docmost`.
await git.checkout('docmost');
return { vault: dir, git, file };
}
it('SPURIOUS: a trailing-blank-only diff does NOT conflict, no markers, stays in sync', async () => {
if (!available) return;
// base ends "World\n\n", main appends another blank, docmost normalizes to one.
const { vault, git, file } = await divergedRepo({
base: 'World\n\n',
ours: 'World\n\n\n',
theirs: 'World\n',
});
const res = await applyPullActions(realDeps(git), actions(), vault);
// No GENUINE conflict reported.
expect(res.merge.conflict).toBe(false);
expect(res.merge.ok).toBe(true);
expect(res.conflictedPaths).toEqual([]);
// The vault is not wedged mid-merge.
expect(await git.isMergeInProgress()).toBe(false);
// `main` carries the clean normalized body — NO conflict markers.
const onMain = await readFile(join(vault, file), 'utf8');
expect(onMain).not.toContain('<<<<<<<');
expect(onMain).not.toContain('=======');
expect(onMain).not.toContain('>>>>>>>');
expect(onMain).toContain('World');
// A SECOND identical pull cycle is a clean no-op (git and content stay in
// sync — no re-conflict, no churn). docmost is now an ancestor of main.
await git.checkout('docmost');
const res2 = await applyPullActions(realDeps(git), actions(), vault);
expect(res2.merge.conflict).toBe(false);
expect(res2.conflictedPaths).toEqual([]);
const onMain2 = await readFile(join(vault, file), 'utf8');
expect(onMain2).not.toContain('<<<<<<<');
});
it('GENUINE: a same-block content conflict does NOT leak raw markers into main', async () => {
if (!available) return;
const { vault, git, file } = await divergedRepo({
base: 'Original line\n',
ours: 'Edited by GIT\n',
theirs: 'Edited by DOCMOST\n',
});
const res = await applyPullActions(realDeps(git), actions(), vault);
// A genuine conflict is detected + auto-resolved (git wins) — reported, clean.
expect(res.merge.conflict).toBe(true);
expect(res.merge.ok).toBe(true);
expect(res.conflictedPaths).toEqual([file]);
expect(await git.isMergeInProgress()).toBe(false);
const onMain = await readFile(join(vault, file), 'utf8');
// CARDINAL invariant: no raw conflict markers ever on the published main.
expect(onMain).not.toContain('<<<<<<<');
expect(onMain).not.toContain('=======');
expect(onMain).not.toContain('>>>>>>>');
// Git/main side won the published branch.
expect(onMain).toContain('Edited by GIT');
expect(onMain).not.toContain('Edited by DOCMOST');
// The docmost side stays recoverable on the `docmost` branch.
const onDocmost = await git.showFileAtRef('docmost', file);
expect(onDocmost).toContain('Edited by DOCMOST');
});
});