test(git-sync): cover null-edge conflict resolution in applyPullActions (F1)

The genuine-conflict branch in applyPullActions resolves to `ours ?? theirs`,
but the two stages where a side is ABSENT had NO test — the existing conflict
tests only fed stages where both ours and theirs are non-null. This is the
data-preservation core on the published `main`: a regression (dropping the
`?? theirs`, or wrongly writing on both-null) would silently lose a surviving
Docmost edit or resurrect a both-deleted page.

Adds four tests:
- apply-pull-actions.test.ts (fake-git, controlled stages): modify/delete
  (ours=null, theirs!=null -> keep THEIRS) and delete/delete (both null ->
  write nothing, deletion staged by commitMerge's `git add -A`).
- pull-conflict-normalize.test.ts (real-git 3-way): modify/delete built by
  deleting on main + modifying on docmost (stage 2 absent -> theirs kept,
  committed clean, no markers); delete/delete built via a rename/rename(1to2)
  on the shared base file, which records the original path as both-deleted
  (stages 2 AND 3 absent -> nothing written, deletion committed off main).

Production logic at pull.ts:487-497 held — pure test-coverage fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-29 00:23:55 +03:00
parent b47751349f
commit 32cb9eb1e3
2 changed files with 177 additions and 0 deletions

View File

@@ -496,6 +496,77 @@ describe('applyPullActions — merge result is surfaced, not swallowed', () => {
expect(g.git.commitMerge).toHaveBeenCalledTimes(1);
});
// NULL-EDGE coverage (round-? review F1): the genuine-conflict branch resolves
// to `ours ?? theirs`. The two cases where a stage is ABSENT are the
// data-preservation core on the published `main` and were previously untested.
it('NULL-EDGE modify/delete (ours absent): keeps THEIRS so the surviving edit is not dropped', async () => {
// modify/delete conflict: OUR side (main) deleted the page (stage 2 absent),
// but THEIR side (docmost) still has a modified body. Losing the `?? theirs`
// fallback here would silently drop a surviving Docmost edit. The resolution
// must keep theirs — marker-free — on `main`.
const { client } = makeClient();
const g = makeGit(
{ ok: false, conflict: true, output: 'CONFLICT (modify/delete)' },
{
unmerged: ['Gone.md'],
stages: {
'Gone.md': { ours: null, theirs: 'surviving docmost body\n' },
},
},
);
const fs = makeFs();
const res = await applyPullActions(
deps(client, g.git, fs),
actions({ toWrite: [] }),
VAULT,
);
expect(res.merge.conflict).toBe(true);
expect(res.merge.ok).toBe(true);
expect(res.conflictedPaths).toEqual(['Gone.md']);
// `resolved = ours ?? theirs` fell through to THEIRS (content preserved).
const w = fs.writes.find((x) => x.abs === '/vault/Gone.md');
expect(w?.text).toBe('surviving docmost body\n');
expect(w?.text).not.toContain('<<<<<<<');
expect(w?.text).not.toContain('>>>>>>>');
// The merge was committed clean (no wedge).
expect(g.git.commitMerge).toHaveBeenCalledTimes(1);
});
it('NULL-EDGE delete/delete (both absent): writes NOTHING; commitMerge stages the deletion', async () => {
// delete/delete conflict: BOTH sides removed the path (stage 2 AND 3 absent),
// so `resolved = ours ?? theirs` is null. The file must NOT be re-created;
// commitMerge's `git add -A` stages the deletion. A regression that wrongly
// wrote on the both-null path would resurrect a page both sides deleted.
const { client } = makeClient();
const g = makeGit(
{ ok: false, conflict: true, output: 'CONFLICT' },
{
unmerged: ['Both.md'],
stages: {
'Both.md': { ours: null, theirs: null },
},
},
);
const fs = makeFs();
const res = await applyPullActions(
deps(client, g.git, fs),
actions({ toWrite: [] }),
VAULT,
);
// The path is surfaced as a resolved conflict, the merge committed clean...
expect(res.merge.conflict).toBe(true);
expect(res.merge.ok).toBe(true);
expect(res.conflictedPaths).toEqual(['Both.md']);
// ...but NOTHING was written for it (resolved === null): no re-creation.
expect(fs.writes.find((x) => x.abs === '/vault/Both.md')).toBeUndefined();
expect(fs.writes).toEqual([]);
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

@@ -196,4 +196,110 @@ describe('pull merge — spurious vs genuine conflict (real git)', () => {
const onDocmost = await git.showFileAtRef('docmost', file);
expect(onDocmost).toContain('Edited by DOCMOST');
});
// ===========================================================================
// NULL-EDGE coverage (round-? review F1): the genuine-conflict resolution is
// `resolved = ours ?? theirs`. The two cases where a merge stage is ABSENT are
// the data-preservation core on the published `main` and were untested — the
// existing cases above only feed conflicts where BOTH sides are non-null.
// (i) modify/delete (stage 2 absent) -> keep THEIRS (don't drop content)
// (ii) delete/delete (stages 2 AND 3 absent) -> write nothing; stage the delete
// These build REAL 3-way index stages (not fakes) so the production code path
// is exercised end-to-end against git itself.
it('NULL-EDGE modify/delete (real git): our side DELETED, their side MODIFIED -> keeps THEIRS, clean on main', async () => {
if (!available) return;
dir = await mkdtemp(join(tmpdir(), 'docmost-conflict-'));
const git = new VaultGit(dir);
await git.ensureRepo();
await git.ensureBranch('docmost', 'main');
const file = 'Doc.md';
// Shared base on main, then re-fork docmost (merge-base = base).
await writeFile(join(dir, file), PAGE('Base body'), 'utf8');
await commitOn(git, 'base');
await execFileAsync('git', ['branch', '-f', 'docmost', 'main'], { cwd: dir });
// docmost MODIFIES the page (the surviving edit).
await git.checkout('docmost');
await writeFile(join(dir, file), PAGE('Modified on DOCMOST'), 'utf8');
await commitOn(git, 'docmost: modify');
// main DELETES the page -> a real modify/delete 3-way: stage 2 (ours) absent.
await git.checkout('main');
await rm(join(dir, file), { force: true });
await commitOn(git, 'local: delete');
// The cycle runs on `docmost`.
await git.checkout('docmost');
const res = await applyPullActions(realDeps(git), actions(), dir);
// modify/delete is a GENUINE conflict, auto-resolved + committed clean.
expect(res.merge.conflict).toBe(true);
expect(res.merge.ok).toBe(true);
expect(res.conflictedPaths).toEqual([file]);
expect(await git.isMergeInProgress()).toBe(false);
// CONTENT PRESERVED: `ours` is null (we deleted) so `ours ?? theirs` keeps the
// surviving Docmost body on `main` instead of dropping it. No markers.
const onMain = await readFile(join(dir, file), 'utf8');
expect(onMain).toContain('Modified on DOCMOST');
expect(onMain).not.toContain('<<<<<<<');
expect(onMain).not.toContain('=======');
expect(onMain).not.toContain('>>>>>>>');
// It is actually committed on `main` (recoverable from the ref, not just disk).
expect(await git.showFileAtRef('main', file)).toContain('Modified on DOCMOST');
});
it('NULL-EDGE delete/delete (real git): both sides removed the base path -> nothing written, deletion committed', async () => {
if (!available) return;
dir = await mkdtemp(join(tmpdir(), 'docmost-conflict-'));
const git = new VaultGit(dir);
await git.ensureRepo();
await git.ensureBranch('docmost', 'main');
// Shared base: a single page `orig.md`.
await writeFile(join(dir, 'orig.md'), PAGE('Base body'), 'utf8');
await commitOn(git, 'base');
await execFileAsync('git', ['branch', '-f', 'docmost', 'main'], { cwd: dir });
// A rename/rename(1to2) of the SAME base file makes git record the ORIGINAL
// path `orig.md` as BOTH-DELETED (DD): stage 1 only, stages 2 AND 3 absent ->
// the `ours === null && theirs === null` edge. (The two rename targets A/B
// are themselves modify/delete halves that exercise `ours ?? theirs` too.)
await git.checkout('docmost');
await rm(join(dir, 'orig.md'), { force: true });
await writeFile(join(dir, 'B.md'), PAGE('Base body'), 'utf8');
await commitOn(git, 'docmost: rename orig -> B');
await git.checkout('main');
await rm(join(dir, 'orig.md'), { force: true });
await writeFile(join(dir, 'A.md'), PAGE('Base body'), 'utf8');
await commitOn(git, 'local: rename orig -> A');
// The cycle runs on `docmost`.
await git.checkout('docmost');
const res = await applyPullActions(realDeps(git), actions(), dir);
// Conflicted -> auto-resolved + COMMITTED clean (no wedge).
expect(res.merge.ok).toBe(true);
expect(await git.isMergeInProgress()).toBe(false);
// The both-deleted base path is surfaced among the resolved conflicts...
expect(res.conflictedPaths).toContain('orig.md');
// ...and on the both-null edge NOTHING is written for it: it stays DELETED on
// main (no stray re-creation), and commitMerge's `git add -A` staged the
// deletion so it is gone from the committed `main` tree too.
await expect(readFile(join(dir, 'orig.md'), 'utf8')).rejects.toThrow();
expect(await git.showFileAtRef('main', 'orig.md')).toBeNull();
// The two rename targets are each a modify/delete null-edge: `ours ?? theirs`
// preserved the surviving side for both, marker-free.
for (const t of ['A.md', 'B.md']) {
const body = await readFile(join(dir, t), 'utf8');
expect(body).toContain('Base body');
expect(body).not.toContain('<<<<<<<');
expect(body).not.toContain('>>>>>>>');
}
});
});