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:
@@ -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' });
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
97
packages/git-sync/test/head-advertise.test.ts
Normal file
97
packages/git-sync/test/head-advertise.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
199
packages/git-sync/test/pull-conflict-normalize.test.ts
Normal file
199
packages/git-sync/test/pull-conflict-normalize.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user