feat(git-sync): vendor IO engine (pull/push/git/settings) with GitSyncClient seam (Phase A.3)
Vendor the IO engine from docmost-sync into packages/git-sync/src/engine: - git.ts (VaultGit, execFile shell-out — verbatim) - pull.ts (readExisting, computePullActions, applyPullActions) - push.ts (classifyRenameMoves, computePushActions, applyPushActions, runPush) - settings.ts adapted (pure parseSettings + Settings type; no process.env binding — the server builds Settings from EnvironmentService later), config-errors.ts. CLI main()/import.meta entrypoints dropped (server drives in-process). Client seam: new engine/client.types.ts defines GitSyncClient; pull.ts/push.ts now use Pick<GitSyncClient, ...> instead of the non-vendored DocmostClient. Engine logic byte-identical except a zod4-compat fix in config-errors (zod4 dropped the issue.received==='undefined' signal; match /received undefined/ on the message). Ported the engine unit tests (compute/apply pull+push actions, classify-rename- moves, run-push, settings, config-errors) incl. real-git temp-repo tests: 431 pass / 3 expected-fail (was 314/3). REST/CLI-coupled upstream tests skipped (noted). CJS build clean. No apps/server wiring yet (next step). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
417
packages/git-sync/test/apply-pull-actions.test.ts
Normal file
417
packages/git-sync/test/apply-pull-actions.test.ts
Normal file
@@ -0,0 +1,417 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { applyPullActions } from '../src/engine/pull';
|
||||
import type {
|
||||
PullActions,
|
||||
ApplyPullActionsDeps,
|
||||
} from '../src/engine/pull';
|
||||
import type { DeletionDecision } from '../src/engine/reconcile';
|
||||
|
||||
// R-Pull-2 (test-strategy report §5): `applyPullActions` is the THIN IO half of
|
||||
// the pull cycle. These tests drive it with FAKES that record every call — no
|
||||
// real git, fs, or network — so the ordering and the ⭐ move-on-success
|
||||
// data-loss guard are verifiable. SPEC §8 (delete suppression) + SPEC §5 (commit
|
||||
// subject reflects ACTUAL counts) are asserted here.
|
||||
|
||||
const VAULT = '/vault';
|
||||
|
||||
/** A getPageJson fake: returns a minimal page whose content stabilizes cheaply. */
|
||||
function makeClient(opts?: { failFor?: Set<string> }) {
|
||||
const calls: string[] = [];
|
||||
const client = {
|
||||
getPageJson: vi.fn(async (pageId: string) => {
|
||||
calls.push(pageId);
|
||||
if (opts?.failFor?.has(pageId)) {
|
||||
throw new Error(`fetch failed for ${pageId}`);
|
||||
}
|
||||
return {
|
||||
id: pageId,
|
||||
slugId: `slug-${pageId}`,
|
||||
title: `Title ${pageId}`,
|
||||
spaceId: 'space',
|
||||
parentPageId: null,
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
// A trivial doc so stabilizePageFile (the real one) runs fast.
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: pageId }] },
|
||||
],
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
return { client, calls };
|
||||
}
|
||||
|
||||
/** 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,
|
||||
}) {
|
||||
const order: string[] = [];
|
||||
let committedSubject: string | undefined;
|
||||
const git = {
|
||||
stageAll: vi.fn(async () => {
|
||||
order.push('stageAll');
|
||||
}),
|
||||
commit: vi.fn(async (subject: string) => {
|
||||
order.push(`commit:${subject}`);
|
||||
committedSubject = subject;
|
||||
return true;
|
||||
}),
|
||||
checkout: vi.fn(async (branch: string) => {
|
||||
order.push(`checkout:${branch}`);
|
||||
}),
|
||||
merge: vi.fn(async () => {
|
||||
order.push('merge');
|
||||
return { ok: merge.ok, conflict: merge.conflict, output: merge.output ?? '' };
|
||||
}),
|
||||
};
|
||||
return {
|
||||
git,
|
||||
order,
|
||||
get committedSubject() {
|
||||
return committedSubject;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A recording fs fake: writes/mkdirs/rms tracked in arrays. */
|
||||
function makeFs(opts?: { failWriteFor?: Set<string> }) {
|
||||
const writes: { abs: string; text: string }[] = [];
|
||||
const mkdirs: string[] = [];
|
||||
const rms: string[] = [];
|
||||
const fs = {
|
||||
writeFile: vi.fn(async (abs: string, text: string) => {
|
||||
// Fail a specific destination path if asked (to simulate a write failure).
|
||||
if (opts?.failWriteFor?.has(abs)) {
|
||||
throw new Error(`write failed for ${abs}`);
|
||||
}
|
||||
writes.push({ abs, text });
|
||||
}),
|
||||
mkdir: vi.fn(async (abs: string) => {
|
||||
mkdirs.push(abs);
|
||||
}),
|
||||
rm: vi.fn(async (abs: string) => {
|
||||
rms.push(abs);
|
||||
}),
|
||||
};
|
||||
return { fs, writes, mkdirs, rms };
|
||||
}
|
||||
|
||||
function deps(
|
||||
client: any,
|
||||
git: any,
|
||||
fs: ReturnType<typeof makeFs>,
|
||||
): ApplyPullActionsDeps {
|
||||
return {
|
||||
client,
|
||||
git,
|
||||
writeFile: fs.fs.writeFile,
|
||||
mkdir: fs.fs.mkdir,
|
||||
rm: fs.fs.rm,
|
||||
};
|
||||
}
|
||||
|
||||
const APPLY: DeletionDecision = { apply: true };
|
||||
|
||||
function actions(partial: Partial<PullActions>): PullActions {
|
||||
return {
|
||||
toWrite: [],
|
||||
moved: [],
|
||||
toDelete: [],
|
||||
deletionDecision: APPLY,
|
||||
existingCount: 0,
|
||||
plannedDeleteCount: 0,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('applyPullActions — happy path (write + commit + merge)', () => {
|
||||
it('fetches, writes each page, stages, commits, checks out main, merges', async () => {
|
||||
const { client } = makeClient();
|
||||
const g = makeGit();
|
||||
const fs = makeFs();
|
||||
|
||||
const res = await applyPullActions(
|
||||
deps(client, g.git, fs),
|
||||
actions({
|
||||
toWrite: [
|
||||
{ pageId: 'p1', relPath: 'A.md' },
|
||||
{ pageId: 'p2', relPath: 'Sub/B.md' },
|
||||
],
|
||||
}),
|
||||
VAULT,
|
||||
);
|
||||
|
||||
expect(res.written).toBe(2);
|
||||
expect(res.failed).toBe(0);
|
||||
expect(res.committed).toBe(true);
|
||||
expect(res.merge).toEqual({ ok: true, conflict: false, output: '' });
|
||||
|
||||
// Both pages were fetched and written at their absolute paths.
|
||||
expect(client.getPageJson).toHaveBeenCalledTimes(2);
|
||||
const writtenPaths = fs.writes.map((w) => w.abs).sort();
|
||||
expect(writtenPaths).toEqual(['/vault/A.md', '/vault/Sub/B.md']);
|
||||
|
||||
// The git op order is: stageAll -> commit -> checkout main -> merge.
|
||||
expect(g.order).toEqual([
|
||||
'stageAll',
|
||||
`commit:docmost: sync 2 page(s)`,
|
||||
'checkout:main',
|
||||
'merge',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPullActions — ordering (write before move/delete before commit)', () => {
|
||||
it('does writes, then move-old-path removals, then deletes, then commit/merge', async () => {
|
||||
const { client } = makeClient();
|
||||
const g = makeGit();
|
||||
const fs = makeFs();
|
||||
|
||||
await applyPullActions(
|
||||
deps(client, g.git, fs),
|
||||
actions({
|
||||
toWrite: [{ pageId: 'm', relPath: 'New/M.md' }],
|
||||
moved: [
|
||||
{
|
||||
pageId: 'm',
|
||||
fromRelPath: 'Old/M.md',
|
||||
toRelPath: 'New/M.md',
|
||||
removeOldPath: true,
|
||||
},
|
||||
],
|
||||
toDelete: ['Dead.md'],
|
||||
plannedDeleteCount: 1,
|
||||
existingCount: 3,
|
||||
}),
|
||||
VAULT,
|
||||
);
|
||||
|
||||
// The write to the new path happened (the page was fetched first).
|
||||
expect(fs.writes.map((w) => w.abs)).toEqual(['/vault/New/M.md']);
|
||||
// The move old-path removal AND the absence delete both ran, old path first.
|
||||
expect(fs.rms).toEqual(['/vault/Old/M.md', '/vault/Dead.md']);
|
||||
// git ops happen AFTER all fs work.
|
||||
expect(g.order).toEqual([
|
||||
'stageAll',
|
||||
'commit:docmost: sync 1 page(s), 1 deleted',
|
||||
'checkout:main',
|
||||
'merge',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPullActions — ⭐ data-loss guard (move-on-success)', () => {
|
||||
it('does NOT remove the OLD path when the new-path write FAILS', async () => {
|
||||
// The page "m" is being moved Old/M.md -> New/M.md, but its new-path write
|
||||
// FAILS. Removing the old path now would erase the only copy of the page.
|
||||
// The guard must KEEP the old path.
|
||||
const { client } = makeClient();
|
||||
const g = makeGit();
|
||||
const fs = makeFs({ failWriteFor: new Set(['/vault/New/M.md']) });
|
||||
|
||||
const res = await applyPullActions(
|
||||
deps(client, g.git, fs),
|
||||
actions({
|
||||
toWrite: [{ pageId: 'm', relPath: 'New/M.md' }],
|
||||
moved: [
|
||||
{
|
||||
pageId: 'm',
|
||||
fromRelPath: 'Old/M.md',
|
||||
toRelPath: 'New/M.md',
|
||||
removeOldPath: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
VAULT,
|
||||
);
|
||||
|
||||
// The write failed -> recorded as a failure, nothing written.
|
||||
expect(res.failed).toBe(1);
|
||||
expect(res.written).toBe(0);
|
||||
expect(fs.writes).toEqual([]);
|
||||
// ⭐ The OLD path was NOT removed: the data-loss guard kept it.
|
||||
expect(fs.rms).not.toContain('/vault/Old/M.md');
|
||||
expect(fs.rms).toEqual([]);
|
||||
expect(res.movedApplied).toBe(0);
|
||||
|
||||
// The commit subject reflects ACTUAL counts: 0 written, 0 deleted.
|
||||
expect(g.committedSubject).toBe('docmost: sync 0 page(s)');
|
||||
});
|
||||
|
||||
it('DOES remove the old path when the new-path write SUCCEEDS', async () => {
|
||||
// Same move, but the write succeeds -> the old path is safely removed. This
|
||||
// is the positive control proving the guard is keyed on write success.
|
||||
const { client } = makeClient();
|
||||
const g = makeGit();
|
||||
const fs = makeFs(); // no write failures
|
||||
|
||||
const res = await applyPullActions(
|
||||
deps(client, g.git, fs),
|
||||
actions({
|
||||
toWrite: [{ pageId: 'm', relPath: 'New/M.md' }],
|
||||
moved: [
|
||||
{
|
||||
pageId: 'm',
|
||||
fromRelPath: 'Old/M.md',
|
||||
toRelPath: 'New/M.md',
|
||||
removeOldPath: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
VAULT,
|
||||
);
|
||||
|
||||
expect(res.written).toBe(1);
|
||||
expect(res.movedApplied).toBe(1);
|
||||
expect(fs.rms).toContain('/vault/Old/M.md');
|
||||
expect(g.committedSubject).toBe('docmost: sync 1 page(s)');
|
||||
});
|
||||
|
||||
it('honours removeOldPath:false (path reused by another live page is kept)', async () => {
|
||||
const { client } = makeClient();
|
||||
const g = makeGit();
|
||||
const fs = makeFs();
|
||||
|
||||
await applyPullActions(
|
||||
deps(client, g.git, fs),
|
||||
actions({
|
||||
toWrite: [{ pageId: 'm', relPath: 'New/M.md' }],
|
||||
moved: [
|
||||
{
|
||||
pageId: 'm',
|
||||
fromRelPath: 'X.md',
|
||||
toRelPath: 'New/M.md',
|
||||
removeOldPath: false, // X.md is a live target of another page
|
||||
},
|
||||
],
|
||||
}),
|
||||
VAULT,
|
||||
);
|
||||
|
||||
// The reused old path is never removed.
|
||||
expect(fs.rms).not.toContain('/vault/X.md');
|
||||
expect(fs.rms).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPullActions — deletion suppression (SPEC §8)', () => {
|
||||
it('skips deletions when the decision SUPPRESSES them (toDelete already empty)', async () => {
|
||||
// computePullActions empties toDelete when suppressed, but assert the applier
|
||||
// ALSO does no removals and the subject omits the deleted count.
|
||||
const { client } = makeClient();
|
||||
const g = makeGit();
|
||||
const fs = makeFs();
|
||||
|
||||
const res = await applyPullActions(
|
||||
deps(client, g.git, fs),
|
||||
actions({
|
||||
toWrite: [{ pageId: 'p1', relPath: 'A.md' }],
|
||||
// Suppressed: toDelete is empty even though 5 were planned.
|
||||
toDelete: [],
|
||||
deletionDecision: { apply: false, reason: 'incomplete-fetch' },
|
||||
plannedDeleteCount: 5,
|
||||
existingCount: 6,
|
||||
}),
|
||||
VAULT,
|
||||
);
|
||||
|
||||
expect(res.deleted).toBe(0);
|
||||
expect(fs.rms).toEqual([]);
|
||||
// Subject reflects 0 deleted (no ", N deleted" suffix).
|
||||
expect(g.committedSubject).toBe('docmost: sync 1 page(s)');
|
||||
// The suppression warning was emitted.
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/tree fetch incomplete/),
|
||||
);
|
||||
});
|
||||
|
||||
it('applies deletions present in toDelete when the decision allows them', async () => {
|
||||
const { client } = makeClient();
|
||||
const g = makeGit();
|
||||
const fs = makeFs();
|
||||
|
||||
const res = await applyPullActions(
|
||||
deps(client, g.git, fs),
|
||||
actions({
|
||||
toWrite: [{ pageId: 'p1', relPath: 'A.md' }],
|
||||
toDelete: ['Dead1.md', 'Dead2.md'],
|
||||
deletionDecision: APPLY,
|
||||
plannedDeleteCount: 2,
|
||||
existingCount: 5,
|
||||
}),
|
||||
VAULT,
|
||||
);
|
||||
|
||||
expect(res.deleted).toBe(2);
|
||||
expect(fs.rms).toEqual(['/vault/Dead1.md', '/vault/Dead2.md']);
|
||||
// Subject reflects ACTUAL written + deleted counts.
|
||||
expect(g.committedSubject).toBe('docmost: sync 1 page(s), 2 deleted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPullActions — commit subject reflects ACTUAL counts', () => {
|
||||
it('counts only SUCCESSFUL writes when some page fetches fail', async () => {
|
||||
// p2 fetch fails; the subject must say 1 page (only p1 was written), not 2.
|
||||
const { client } = makeClient({ failFor: new Set(['p2']) });
|
||||
const g = makeGit();
|
||||
const fs = makeFs();
|
||||
|
||||
const res = await applyPullActions(
|
||||
deps(client, g.git, fs),
|
||||
actions({
|
||||
toWrite: [
|
||||
{ pageId: 'p1', relPath: 'A.md' },
|
||||
{ pageId: 'p2', relPath: 'B.md' },
|
||||
],
|
||||
}),
|
||||
VAULT,
|
||||
);
|
||||
|
||||
expect(res.written).toBe(1);
|
||||
expect(res.failed).toBe(1);
|
||||
expect(g.committedSubject).toBe('docmost: sync 1 page(s)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPullActions — merge result is surfaced, not swallowed', () => {
|
||||
it('returns conflict:true on a conflicting merge (no auto-resolve)', async () => {
|
||||
const { client } = makeClient();
|
||||
const g = makeGit({ ok: false, conflict: true, output: 'CONFLICT' });
|
||||
const fs = makeFs();
|
||||
|
||||
const res = await applyPullActions(
|
||||
deps(client, g.git, fs),
|
||||
actions({ toWrite: [{ pageId: 'p1', relPath: 'A.md' }] }),
|
||||
VAULT,
|
||||
);
|
||||
expect(res.merge.conflict).toBe(true);
|
||||
expect(res.merge.ok).toBe(false);
|
||||
});
|
||||
|
||||
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' });
|
||||
const fs = makeFs();
|
||||
|
||||
const res = await applyPullActions(
|
||||
deps(client, g.git, fs),
|
||||
actions({ toWrite: [{ pageId: 'p1', relPath: 'A.md' }] }),
|
||||
VAULT,
|
||||
);
|
||||
expect(res.merge.ok).toBe(false);
|
||||
expect(res.merge.conflict).toBe(false);
|
||||
});
|
||||
});
|
||||
655
packages/git-sync/test/apply-push-actions.test.ts
Normal file
655
packages/git-sync/test/apply-push-actions.test.ts
Normal file
@@ -0,0 +1,655 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { applyPushActions, LAST_PUSHED_REF } from '../src/engine/push';
|
||||
import { bodyHash } from '../src/engine/loop-guard';
|
||||
import type { ApplyPushDeps, PushActions } from '../src/engine/push';
|
||||
import {
|
||||
parseDocmostMarkdown,
|
||||
serializeDocmostMarkdownBody,
|
||||
} from '../src/lib/index';
|
||||
|
||||
// FS→Docmost push, FIRST increment (SPEC §6). `applyPushActions` is the THIN IO
|
||||
// half: create/update/delete via FAKES that record every call — no real network,
|
||||
// git, or fs. Asserts: update uses importPageMarkdown (collab path, SPEC
|
||||
// §2/§15.6); create writes the assigned pageId BACK into the file meta; delete
|
||||
// soft-deletes; rename/move is returned as `deferred` with NO client call; the
|
||||
// last-pushed ref is advanced.
|
||||
|
||||
/** A recording client fake; createPage returns a configurable assigned id. */
|
||||
function makeClient(opts?: { createId?: string }) {
|
||||
const client = {
|
||||
importPageMarkdown: vi.fn(async (_pageId: string, _md: string) => ({
|
||||
success: true,
|
||||
})),
|
||||
createPage: vi.fn(
|
||||
async (
|
||||
title: string,
|
||||
_content: string,
|
||||
_spaceId: string,
|
||||
_parentPageId?: string,
|
||||
) => ({
|
||||
// Mirrors the real `createPage` shape: `{ data: { id, ... }, success }`.
|
||||
data: { id: opts?.createId ?? 'assigned-id', title },
|
||||
success: true,
|
||||
}),
|
||||
),
|
||||
deletePage: vi.fn(async (_pageId: string) => ({ success: true })),
|
||||
movePage: vi.fn(
|
||||
async (
|
||||
_pageId: string,
|
||||
_parentPageId: string | null,
|
||||
_position?: string,
|
||||
) => ({ success: true }),
|
||||
),
|
||||
renamePage: vi.fn(async (pageId: string, title: string) => ({
|
||||
success: true,
|
||||
pageId,
|
||||
title,
|
||||
})),
|
||||
};
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* A recording git fake: `updateRef` (advance last-pushed) and `fastForwardBranch`
|
||||
* (advance the `docmost` mirror, the loop-close). `ffResult` configures what the
|
||||
* ff returns (default a successful advance).
|
||||
*/
|
||||
function makeGit(opts?: {
|
||||
ffResult?: { ok: boolean; reason?: string };
|
||||
/** Pre-image tree at `refs/docmost/last-pushed` (path -> text). */
|
||||
prevTree?: Record<string, string>;
|
||||
}) {
|
||||
const updateRefCalls: { ref: string; target: string }[] = [];
|
||||
const ffCalls: { branch: string; toCommit: string }[] = [];
|
||||
const prevTree = opts?.prevTree ?? {};
|
||||
const git = {
|
||||
updateRef: vi.fn(async (ref: string, target: string) => {
|
||||
updateRefCalls.push({ ref, target });
|
||||
}),
|
||||
fastForwardBranch: vi.fn(async (branch: string, toCommit: string) => {
|
||||
ffCalls.push({ branch, toCommit });
|
||||
return opts?.ffResult ?? { ok: true };
|
||||
}),
|
||||
// The move/rename classifier reads the PREVIOUS parent folder's `.md` at
|
||||
// refs/docmost/last-pushed via this; `null` when absent there (SPEC §5).
|
||||
showFileAtRef: vi.fn(async (_ref: string, path: string) =>
|
||||
path in prevTree ? prevTree[path] : null,
|
||||
),
|
||||
};
|
||||
return { git, updateRefCalls, ffCalls };
|
||||
}
|
||||
|
||||
/** A recording fs fake over a path->text store. */
|
||||
function makeFs(initial: Record<string, string> = {}) {
|
||||
const store: Record<string, string> = { ...initial };
|
||||
const writes: { path: string; text: string }[] = [];
|
||||
const reads: string[] = [];
|
||||
const fs = {
|
||||
readFile: vi.fn(async (path: string) => {
|
||||
reads.push(path);
|
||||
if (!(path in store)) throw new Error(`no such file: ${path}`);
|
||||
return store[path];
|
||||
}),
|
||||
writeFile: vi.fn(async (path: string, text: string) => {
|
||||
store[path] = text;
|
||||
writes.push({ path, text });
|
||||
}),
|
||||
};
|
||||
return { fs, store, writes, reads };
|
||||
}
|
||||
|
||||
function deps(client: any, git: any, fs: ReturnType<typeof makeFs>): ApplyPushDeps {
|
||||
return {
|
||||
client,
|
||||
git,
|
||||
readFile: fs.fs.readFile,
|
||||
writeFile: fs.fs.writeFile,
|
||||
};
|
||||
}
|
||||
|
||||
function actions(partial: Partial<PushActions>): PushActions {
|
||||
return {
|
||||
creates: [],
|
||||
updates: [],
|
||||
deletes: [],
|
||||
renamesMoves: [],
|
||||
skipped: [],
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('applyPushActions — update (collab path, SPEC §2/§15.6)', () => {
|
||||
it('reads the file body and calls importPageMarkdown with it', async () => {
|
||||
const fileBody =
|
||||
'<!-- docmost:meta\n{"version":1,"pageId":"p-1"}\n-->\n\nupdated body\n';
|
||||
const client = makeClient();
|
||||
const { git } = makeGit();
|
||||
const fs = makeFs({ 'Doc.md': fileBody });
|
||||
|
||||
const res = await applyPushActions(
|
||||
deps(client, git, fs),
|
||||
actions({ updates: [{ pageId: 'p-1', path: 'Doc.md' }] }),
|
||||
);
|
||||
|
||||
expect(res.updated).toBe(1);
|
||||
// The collab/Yjs write path is used — NOT a raw jsonb overwrite.
|
||||
expect(client.importPageMarkdown).toHaveBeenCalledTimes(1);
|
||||
expect(client.importPageMarkdown).toHaveBeenCalledWith('p-1', fileBody);
|
||||
// No raw-overwrite path exists on the injected client surface at all.
|
||||
expect((client as any).updatePageJson).toBeUndefined();
|
||||
expect(client.createPage).not.toHaveBeenCalled();
|
||||
expect(client.deletePage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPushActions — create (assigned pageId written back to meta)', () => {
|
||||
it('createPage is called and the new pageId is serialized back into the file', async () => {
|
||||
// A brand-new local file: meta has title/spaceId but NO pageId yet.
|
||||
const original = serializeDocmostMarkdownBody(
|
||||
{ version: 1, title: 'My New Page', spaceId: 'sp-7', parentPageId: 'parent-9' },
|
||||
'# My New Page\n\nbody text',
|
||||
);
|
||||
const client = makeClient({ createId: 'page-new-42' });
|
||||
const { git } = makeGit();
|
||||
const fs = makeFs({ 'New.md': original });
|
||||
|
||||
const res = await applyPushActions(
|
||||
deps(client, git, fs),
|
||||
actions({ creates: [{ path: 'New.md' }] }),
|
||||
);
|
||||
|
||||
expect(res.created).toBe(1);
|
||||
// createPage was called with title/body/spaceId/parentPageId from meta.
|
||||
expect(client.createPage).toHaveBeenCalledTimes(1);
|
||||
const [title, content, spaceId, parentPageId] =
|
||||
client.createPage.mock.calls[0];
|
||||
expect(title).toBe('My New Page');
|
||||
expect(spaceId).toBe('sp-7');
|
||||
expect(parentPageId).toBe('parent-9');
|
||||
expect(content).toContain('body text');
|
||||
|
||||
// The file was rewritten with the assigned pageId in meta...
|
||||
expect(fs.writes.map((w) => w.path)).toEqual(['New.md']);
|
||||
const rewritten = fs.store['New.md'];
|
||||
const parsed = parseDocmostMarkdown(rewritten);
|
||||
expect(parsed.meta?.pageId).toBe('page-new-42');
|
||||
// ...preserving the rest of the meta and the body.
|
||||
expect(parsed.meta?.title).toBe('My New Page');
|
||||
expect(parsed.meta?.spaceId).toBe('sp-7');
|
||||
expect(parsed.body).toContain('body text');
|
||||
|
||||
// The write-back is recorded so a follow-up commit can be made (NEXT inc).
|
||||
expect(res.writtenBack).toEqual([{ path: 'New.md', pageId: 'page-new-42' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPushActions — delete (soft-delete to Trash, SPEC §8)', () => {
|
||||
it('calls deletePage(pageId)', async () => {
|
||||
const client = makeClient();
|
||||
const { git } = makeGit();
|
||||
const fs = makeFs();
|
||||
|
||||
const res = await applyPushActions(
|
||||
deps(client, git, fs),
|
||||
actions({ deletes: [{ pageId: 'p-del' }] }),
|
||||
);
|
||||
|
||||
expect(res.deleted).toBe(1);
|
||||
expect(client.deletePage).toHaveBeenCalledTimes(1);
|
||||
expect(client.deletePage).toHaveBeenCalledWith('p-del');
|
||||
// No body read needed for a delete.
|
||||
expect(fs.reads).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// FS→Docmost push #3 (SPEC §5/§6/§16): the move/rename APPLY. The classifier
|
||||
// resolves the parent from the FILE PATH (the enclosing folder's `.md`), not
|
||||
// stale `meta.parentPageId`, then `applyPushActions` calls move_page / rename_page
|
||||
// (both for a reparent+retitle) or records a path-only NO-OP with NO client call.
|
||||
|
||||
/**
|
||||
* Helper: a self-contained file with the given pageId + title in its meta. Used
|
||||
* both to seed the working tree (fs) and the prev tree (git.showFileAtRef).
|
||||
*/
|
||||
function fileWith(meta: { pageId: string; title?: string }): string {
|
||||
return serializeDocmostMarkdownBody(
|
||||
{ version: 1, pageId: meta.pageId, ...(meta.title ? { title: meta.title } : {}) },
|
||||
'body',
|
||||
);
|
||||
}
|
||||
|
||||
describe('applyPushActions — move (parent changed, title same; SPEC §5/§16)', () => {
|
||||
it('calls movePage(pageId, newParent) and NOT renamePage', async () => {
|
||||
// The page moved from the space root (Doc.md) under a folder (Parent/Doc.md).
|
||||
// The new parent page's file is `Parent.md`; its meta carries the parent id.
|
||||
const client = makeClient();
|
||||
const { git } = makeGit({
|
||||
// Prev pre-image: the file used to sit at the root (parent ROOT).
|
||||
prevTree: { 'Doc.md': fileWith({ pageId: 'p-mv', title: 'Doc' }) },
|
||||
});
|
||||
const fs = makeFs({
|
||||
// Current tree: the moved file + its new parent folder's `.md`.
|
||||
'Parent/Doc.md': fileWith({ pageId: 'p-mv', title: 'Doc' }),
|
||||
'Parent.md': fileWith({ pageId: 'parent-id', title: 'Parent' }),
|
||||
});
|
||||
|
||||
const res = await applyPushActions(
|
||||
deps(client, git, fs),
|
||||
actions({
|
||||
renamesMoves: [
|
||||
{ pageId: 'p-mv', oldPath: 'Doc.md', newPath: 'Parent/Doc.md' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.moved).toBe(1);
|
||||
expect(res.renamed).toBe(0);
|
||||
expect(client.movePage).toHaveBeenCalledTimes(1);
|
||||
// Reparented under `parent-id`; position left UNDEFINED (client default).
|
||||
expect(client.movePage).toHaveBeenCalledWith('p-mv', 'parent-id');
|
||||
expect(client.renamePage).not.toHaveBeenCalled();
|
||||
expect(res.noops).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPushActions — move-to-root (newParent null; SPEC §16)', () => {
|
||||
it('calls movePage(pageId, null) when the file lands at the space root', async () => {
|
||||
const client = makeClient();
|
||||
const { git } = makeGit({
|
||||
// Prev: the file used to live under `Parent/`, so its old parent is the
|
||||
// page whose file is `Parent.md` (parent-id).
|
||||
prevTree: {
|
||||
'Parent/Doc.md': fileWith({ pageId: 'p-mv', title: 'Doc' }),
|
||||
'Parent.md': fileWith({ pageId: 'parent-id', title: 'Parent' }),
|
||||
},
|
||||
});
|
||||
// Current: the file is now at the root -> no enclosing folder -> parent ROOT.
|
||||
const fs = makeFs({ 'Doc.md': fileWith({ pageId: 'p-mv', title: 'Doc' }) });
|
||||
|
||||
const res = await applyPushActions(
|
||||
deps(client, git, fs),
|
||||
actions({
|
||||
renamesMoves: [
|
||||
{ pageId: 'p-mv', oldPath: 'Parent/Doc.md', newPath: 'Doc.md' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.moved).toBe(1);
|
||||
expect(client.movePage).toHaveBeenCalledWith('p-mv', null);
|
||||
expect(client.renamePage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPushActions — rename (same parent, title changed; SPEC §5/§6)', () => {
|
||||
it('calls renamePage(pageId, title) and NOT movePage', async () => {
|
||||
// Same enclosing folder on both sides (parent unchanged), only the title
|
||||
// changed in meta -> a pure rename.
|
||||
const client = makeClient();
|
||||
const { git } = makeGit({
|
||||
prevTree: {
|
||||
'Folder/Old.md': fileWith({ pageId: 'p-rn', title: 'Old Title' }),
|
||||
'Folder.md': fileWith({ pageId: 'folder-id', title: 'Folder' }),
|
||||
},
|
||||
});
|
||||
const fs = makeFs({
|
||||
'Folder/New.md': fileWith({ pageId: 'p-rn', title: 'New Title' }),
|
||||
'Folder.md': fileWith({ pageId: 'folder-id', title: 'Folder' }),
|
||||
});
|
||||
|
||||
const res = await applyPushActions(
|
||||
deps(client, git, fs),
|
||||
actions({
|
||||
renamesMoves: [
|
||||
{ pageId: 'p-rn', oldPath: 'Folder/Old.md', newPath: 'Folder/New.md' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.renamed).toBe(1);
|
||||
expect(res.moved).toBe(0);
|
||||
expect(client.renamePage).toHaveBeenCalledTimes(1);
|
||||
expect(client.renamePage).toHaveBeenCalledWith('p-rn', 'New Title');
|
||||
expect(client.movePage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPushActions — both (reparent + retitle; move THEN rename)', () => {
|
||||
it('calls movePage first, then renamePage', async () => {
|
||||
const callOrder: string[] = [];
|
||||
const client = makeClient();
|
||||
client.movePage.mockImplementation(async () => {
|
||||
callOrder.push('move');
|
||||
return { success: true };
|
||||
});
|
||||
client.renamePage.mockImplementation(async (pageId: string, title: string) => {
|
||||
callOrder.push('rename');
|
||||
return { success: true, pageId, title };
|
||||
});
|
||||
const { git } = makeGit({
|
||||
// Prev: at root (parent ROOT) with the old title.
|
||||
prevTree: { 'Old.md': fileWith({ pageId: 'p-x', title: 'Old' }) },
|
||||
});
|
||||
const fs = makeFs({
|
||||
// Current: under a new folder AND retitled.
|
||||
'NewParent/New.md': fileWith({ pageId: 'p-x', title: 'New' }),
|
||||
'NewParent.md': fileWith({ pageId: 'np-id', title: 'NewParent' }),
|
||||
});
|
||||
|
||||
const res = await applyPushActions(
|
||||
deps(client, git, fs),
|
||||
actions({
|
||||
renamesMoves: [
|
||||
{ pageId: 'p-x', oldPath: 'Old.md', newPath: 'NewParent/New.md' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.moved).toBe(1);
|
||||
expect(res.renamed).toBe(1);
|
||||
expect(client.movePage).toHaveBeenCalledWith('p-x', 'np-id');
|
||||
expect(client.renamePage).toHaveBeenCalledWith('p-x', 'New');
|
||||
// Order matters: reparent FIRST, then retitle.
|
||||
expect(callOrder).toEqual(['move', 'rename']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPushActions — noop (path-only rename; NO Docmost call; SPEC §5)', () => {
|
||||
it('calls NEITHER movePage NOR renamePage and records the noop', async () => {
|
||||
// Same enclosing folder AND same title on both sides: a purely LOCAL file
|
||||
// rename. The page is its pageId; the path is cosmetic -> Docmost untouched.
|
||||
const client = makeClient();
|
||||
const { git } = makeGit({
|
||||
prevTree: {
|
||||
'Folder/A.md': fileWith({ pageId: 'p-noop', title: 'Same' }),
|
||||
'Folder.md': fileWith({ pageId: 'folder-id', title: 'Folder' }),
|
||||
},
|
||||
});
|
||||
const fs = makeFs({
|
||||
'Folder/B.md': fileWith({ pageId: 'p-noop', title: 'Same' }),
|
||||
'Folder.md': fileWith({ pageId: 'folder-id', title: 'Folder' }),
|
||||
});
|
||||
|
||||
const res = await applyPushActions(
|
||||
deps(client, git, fs),
|
||||
actions({
|
||||
renamesMoves: [
|
||||
{ pageId: 'p-noop', oldPath: 'Folder/A.md', newPath: 'Folder/B.md' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.moved).toBe(0);
|
||||
expect(res.renamed).toBe(0);
|
||||
// ZERO Docmost calls for a cosmetic rename.
|
||||
expect(client.movePage).not.toHaveBeenCalled();
|
||||
expect(client.renamePage).not.toHaveBeenCalled();
|
||||
expect(res.noops).toEqual([
|
||||
{
|
||||
pageId: 'p-noop',
|
||||
oldPath: 'Folder/A.md',
|
||||
newPath: 'Folder/B.md',
|
||||
reason: 'path-only-rename',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPushActions — move whose client call throws (SPEC §12 isolation)', () => {
|
||||
it('isolates the failure into `failures` and does NOT advance the refs', async () => {
|
||||
const client = makeClient();
|
||||
client.movePage.mockImplementation(async () => {
|
||||
throw new Error('move boom');
|
||||
});
|
||||
const { git, updateRefCalls, ffCalls } = makeGit({
|
||||
prevTree: { 'Doc.md': fileWith({ pageId: 'p-mv', title: 'Doc' }) },
|
||||
});
|
||||
const fs = makeFs({
|
||||
'Parent/Doc.md': fileWith({ pageId: 'p-mv', title: 'Doc' }),
|
||||
'Parent.md': fileWith({ pageId: 'parent-id', title: 'Parent' }),
|
||||
});
|
||||
|
||||
const res = await applyPushActions(
|
||||
deps(client, git, fs),
|
||||
actions({
|
||||
renamesMoves: [
|
||||
{ pageId: 'p-mv', oldPath: 'Doc.md', newPath: 'Parent/Doc.md' },
|
||||
],
|
||||
}),
|
||||
'sha-move-fail',
|
||||
);
|
||||
|
||||
expect(res.moved).toBe(0);
|
||||
expect(res.failures).toEqual([
|
||||
{
|
||||
kind: 'move',
|
||||
pageId: 'p-mv',
|
||||
path: 'Parent/Doc.md',
|
||||
error: 'move boom',
|
||||
},
|
||||
]);
|
||||
// A failure means the refs are NOT advanced — a re-run retries cleanly (§12).
|
||||
expect(res.lastPushedAdvanced).toBe(false);
|
||||
expect(updateRefCalls).toEqual([]);
|
||||
expect(ffCalls).toEqual([]);
|
||||
expect(git.updateRef).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPushActions — loop-close: ref advance + docmost ff (SPEC §6 step 3 / §10)', () => {
|
||||
it('advances last-pushed AND fast-forwards the docmost mirror on a clean push', async () => {
|
||||
const client = makeClient();
|
||||
const { git, updateRefCalls, ffCalls } = makeGit();
|
||||
const fs = makeFs();
|
||||
|
||||
const res = await applyPushActions(
|
||||
deps(client, git, fs),
|
||||
actions({ deletes: [{ pageId: 'p' }] }),
|
||||
'commit-sha-abc',
|
||||
);
|
||||
|
||||
expect(res.lastPushedAdvanced).toBe(true);
|
||||
expect(updateRefCalls).toEqual([
|
||||
{ ref: LAST_PUSHED_REF, target: 'commit-sha-abc' },
|
||||
]);
|
||||
// The loop-close: the docmost mirror is fast-forwarded to the pushed commit.
|
||||
expect(ffCalls).toEqual([{ branch: 'docmost', toCommit: 'commit-sha-abc' }]);
|
||||
expect(res.docmostFastForward).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('surfaces a REFUSED non-fast-forward (mirror NOT clobbered)', async () => {
|
||||
const client = makeClient();
|
||||
// The ff is refused because docmost is not an ancestor of the pushed commit.
|
||||
const { git, updateRefCalls, ffCalls } = makeGit({
|
||||
ffResult: { ok: false, reason: 'not-fast-forward' },
|
||||
});
|
||||
const fs = makeFs();
|
||||
|
||||
const res = await applyPushActions(
|
||||
deps(client, git, fs),
|
||||
actions({ deletes: [{ pageId: 'p' }] }),
|
||||
'sha-div',
|
||||
);
|
||||
|
||||
// last-pushed still advances (it is our own marker), but the ff result is
|
||||
// surfaced so the caller can log the refusal.
|
||||
expect(res.lastPushedAdvanced).toBe(true);
|
||||
expect(updateRefCalls).toEqual([{ ref: LAST_PUSHED_REF, target: 'sha-div' }]);
|
||||
expect(ffCalls).toEqual([{ branch: 'docmost', toCommit: 'sha-div' }]);
|
||||
expect(res.docmostFastForward).toEqual({ ok: false, reason: 'not-fast-forward' });
|
||||
});
|
||||
|
||||
it('does NOT advance either ref when no pushed commit is given', async () => {
|
||||
const client = makeClient();
|
||||
const { git, updateRefCalls } = makeGit();
|
||||
const fs = makeFs();
|
||||
|
||||
const res = await applyPushActions(
|
||||
deps(client, git, fs),
|
||||
actions({ updates: [] }),
|
||||
);
|
||||
|
||||
expect(res.lastPushedAdvanced).toBe(false);
|
||||
expect(updateRefCalls).toEqual([]);
|
||||
expect(res.docmostFastForward).toBeNull();
|
||||
expect(git.updateRef).not.toHaveBeenCalled();
|
||||
expect(git.fastForwardBranch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPushActions — per-page error isolation + refs gated on success (SPEC §12)', () => {
|
||||
it('continues the batch when an update throws; records the failure; refs NOT advanced', async () => {
|
||||
// A client whose 2nd importPageMarkdown call throws — the 1st and 3rd must
|
||||
// still be applied, the 2nd recorded as a failure, and NO ref advanced.
|
||||
let call = 0;
|
||||
const client = {
|
||||
importPageMarkdown: vi.fn(async (_pageId: string, _md: string) => {
|
||||
call++;
|
||||
if (call === 2) throw new Error('boom on page 2');
|
||||
return { success: true };
|
||||
}),
|
||||
createPage: vi.fn(),
|
||||
deletePage: vi.fn(),
|
||||
};
|
||||
const { git, updateRefCalls, ffCalls } = makeGit();
|
||||
const fs = makeFs({
|
||||
'A.md': 'a body',
|
||||
'B.md': 'b body',
|
||||
'C.md': 'c body',
|
||||
});
|
||||
|
||||
const res = await applyPushActions(
|
||||
deps(client, git, fs),
|
||||
actions({
|
||||
updates: [
|
||||
{ pageId: 'p-a', path: 'A.md' },
|
||||
{ pageId: 'p-b', path: 'B.md' },
|
||||
{ pageId: 'p-c', path: 'C.md' },
|
||||
],
|
||||
}),
|
||||
'sha-partial',
|
||||
);
|
||||
|
||||
// The 1st and 3rd were applied; the 2nd threw.
|
||||
expect(res.updated).toBe(2);
|
||||
expect(client.importPageMarkdown).toHaveBeenCalledTimes(3);
|
||||
expect(client.importPageMarkdown).toHaveBeenNthCalledWith(1, 'p-a', 'a body');
|
||||
expect(client.importPageMarkdown).toHaveBeenNthCalledWith(3, 'p-c', 'c body');
|
||||
|
||||
// The failure is recorded with kind/pageId/path/error.
|
||||
expect(res.failures).toEqual([
|
||||
{ kind: 'update', pageId: 'p-b', path: 'B.md', error: 'boom on page 2' },
|
||||
]);
|
||||
|
||||
// Only the successful pages carry a loop-guard push record.
|
||||
expect(res.pushed.map((p) => p.pageId)).toEqual(['p-a', 'p-c']);
|
||||
|
||||
// A PARTIAL push advances NEITHER ref, so a re-run retries cleanly (§12).
|
||||
expect(res.lastPushedAdvanced).toBe(false);
|
||||
expect(updateRefCalls).toEqual([]);
|
||||
expect(ffCalls).toEqual([]);
|
||||
expect(res.docmostFastForward).toBeNull();
|
||||
expect(git.updateRef).not.toHaveBeenCalled();
|
||||
expect(git.fastForwardBranch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPushActions — loop-guard push record (SPEC §10)', () => {
|
||||
it('records pageId + updatedAt + bodyHash per applied update', async () => {
|
||||
const fileBody =
|
||||
'<!-- docmost:meta\n{"version":1,"pageId":"p-1"}\n-->\n\nupdated body\n';
|
||||
const client = {
|
||||
importPageMarkdown: vi.fn(async (_pageId: string, _md: string) => ({
|
||||
// The write returns an updatedAt the loop-guard records.
|
||||
data: { updatedAt: '2026-06-20T10:00:00.000Z' },
|
||||
success: true,
|
||||
})),
|
||||
createPage: vi.fn(),
|
||||
deletePage: vi.fn(),
|
||||
};
|
||||
const { git } = makeGit();
|
||||
const fs = makeFs({ 'Doc.md': fileBody });
|
||||
|
||||
const res = await applyPushActions(
|
||||
deps(client, git, fs),
|
||||
actions({ updates: [{ pageId: 'p-1', path: 'Doc.md' }] }),
|
||||
);
|
||||
|
||||
expect(res.pushed).toHaveLength(1);
|
||||
expect(res.pushed[0].pageId).toBe('p-1');
|
||||
expect(res.pushed[0].updatedAt).toBe('2026-06-20T10:00:00.000Z');
|
||||
// The bodyHash is a stable sha256 hex of the pushed markdown.
|
||||
expect(res.pushed[0].bodyHash).toBe(bodyHash(fileBody));
|
||||
expect(res.pushed[0].bodyHash).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it('omits updatedAt when the client result does not expose one', async () => {
|
||||
const newFile = serializeDocmostMarkdownBody(
|
||||
{ version: 1, title: 'N', spaceId: 'sp' },
|
||||
'fresh body',
|
||||
);
|
||||
const client = makeClient({ createId: 'created-9' });
|
||||
const { git } = makeGit();
|
||||
const fs = makeFs({ 'N.md': newFile });
|
||||
|
||||
const res = await applyPushActions(
|
||||
deps(client, git, fs),
|
||||
actions({ creates: [{ path: 'N.md' }] }),
|
||||
);
|
||||
|
||||
expect(res.pushed).toHaveLength(1);
|
||||
expect(res.pushed[0].pageId).toBe('created-9');
|
||||
expect(res.pushed[0].updatedAt).toBeUndefined();
|
||||
// bodyHash of the ORIGINAL pushed file text (what createPage received).
|
||||
expect(res.pushed[0].bodyHash).toBe(bodyHash(newFile));
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPushActions — mixed batch + skipped passthrough', () => {
|
||||
it('applies update + create + delete and carries skipped rows through', async () => {
|
||||
const updFile =
|
||||
'<!-- docmost:meta\n{"version":1,"pageId":"u-1"}\n-->\n\nupd\n';
|
||||
const newFile = serializeDocmostMarkdownBody(
|
||||
{ version: 1, title: 'N', spaceId: 'sp' },
|
||||
'fresh body',
|
||||
);
|
||||
const client = makeClient({ createId: 'created-1' });
|
||||
const { git, updateRefCalls } = makeGit();
|
||||
const fs = makeFs({ 'U.md': updFile, 'N.md': newFile });
|
||||
|
||||
const skipped = [
|
||||
{ path: 'Stray.md', status: 'D' as const, reason: 'no recoverable pageId' },
|
||||
];
|
||||
const res = await applyPushActions(
|
||||
deps(client, git, fs),
|
||||
actions({
|
||||
updates: [{ pageId: 'u-1', path: 'U.md' }],
|
||||
creates: [{ path: 'N.md' }],
|
||||
deletes: [{ pageId: 'd-1' }],
|
||||
skipped,
|
||||
}),
|
||||
'sha-9',
|
||||
);
|
||||
|
||||
expect(res).toMatchObject({
|
||||
created: 1,
|
||||
updated: 1,
|
||||
deleted: 1,
|
||||
lastPushedAdvanced: true,
|
||||
});
|
||||
expect(res.writtenBack).toEqual([{ path: 'N.md', pageId: 'created-1' }]);
|
||||
expect(res.skipped).toEqual(skipped);
|
||||
expect(updateRefCalls).toEqual([{ ref: LAST_PUSHED_REF, target: 'sha-9' }]);
|
||||
expect(client.importPageMarkdown).toHaveBeenCalledWith('u-1', updFile);
|
||||
expect(client.deletePage).toHaveBeenCalledWith('d-1');
|
||||
});
|
||||
});
|
||||
263
packages/git-sync/test/classify-rename-moves.test.ts
Normal file
263
packages/git-sync/test/classify-rename-moves.test.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { classifyRenameMoves } from '../src/engine/push';
|
||||
import type {
|
||||
ClassifyRenameMovesDeps,
|
||||
MetaSide,
|
||||
RenameMoveAction,
|
||||
} from '../src/engine/push';
|
||||
import type { DocmostMdMeta } from '../src/lib/index';
|
||||
|
||||
// FS→Docmost push #3 (SPEC §5/§6/§16). `classifyRenameMoves` is the PURE half of
|
||||
// the move/rename apply: it resolves each `{pageId, oldPath, newPath}` into the
|
||||
// Docmost op(s) it needs, with NO IO (both resolvers are injected). The key
|
||||
// design (SPEC §5) is that the file PATH is the source of truth for tree
|
||||
// position — the NEW parent comes from the new path, the OLD parent from the old
|
||||
// path — and the title comes from the meta. An op is emitted ONLY when something
|
||||
// really changed; a path-only rename (same parent + same title) is a noop and
|
||||
// NEVER calls Docmost.
|
||||
|
||||
/** Build `metaAt` from a `path|side -> meta` table. */
|
||||
function metaTable(
|
||||
table: Record<string, DocmostMdMeta | null>,
|
||||
): (path: string, side: MetaSide) => DocmostMdMeta | null {
|
||||
return (path, side) => {
|
||||
const key = `${path}|${side}`;
|
||||
return key in table ? table[key] : null;
|
||||
};
|
||||
}
|
||||
|
||||
/** Build `resolveParentPageId` from a `path|side -> parentPageId|null` table. */
|
||||
function parentTable(
|
||||
table: Record<string, string | null>,
|
||||
): (path: string, side: MetaSide) => string | null {
|
||||
return (path, side) => {
|
||||
const key = `${path}|${side}`;
|
||||
return key in table ? table[key] : null;
|
||||
};
|
||||
}
|
||||
|
||||
function deps(
|
||||
metas: Record<string, DocmostMdMeta | null>,
|
||||
parents: Record<string, string | null>,
|
||||
): ClassifyRenameMovesDeps {
|
||||
return {
|
||||
metaAt: metaTable(metas),
|
||||
resolveParentPageId: parentTable(parents),
|
||||
};
|
||||
}
|
||||
|
||||
function meta(partial: Partial<DocmostMdMeta>): DocmostMdMeta {
|
||||
return { version: 1, ...partial };
|
||||
}
|
||||
|
||||
describe('classifyRenameMoves — move-only (parent changed, title same)', () => {
|
||||
it('emits move (new parent) and NO rename', () => {
|
||||
const rms: RenameMoveAction[] = [
|
||||
{ pageId: 'p1', oldPath: 'Doc.md', newPath: 'Parent/Doc.md' },
|
||||
];
|
||||
const out = classifyRenameMoves(
|
||||
rms,
|
||||
deps(
|
||||
{
|
||||
// Same title on both sides.
|
||||
'Parent/Doc.md|current': meta({ title: 'Doc' }),
|
||||
'Doc.md|prev': meta({ title: 'Doc' }),
|
||||
},
|
||||
{
|
||||
// Parent changed: root (null) -> 'parent-id'.
|
||||
'Parent/Doc.md|current': 'parent-id',
|
||||
'Doc.md|prev': null,
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(out).toEqual([
|
||||
{
|
||||
pageId: 'p1',
|
||||
oldPath: 'Doc.md',
|
||||
newPath: 'Parent/Doc.md',
|
||||
move: { parentPageId: 'parent-id' },
|
||||
},
|
||||
]);
|
||||
expect(out[0].rename).toBeUndefined();
|
||||
expect(out[0].noop).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyRenameMoves — rename-only (same parent, title changed)', () => {
|
||||
it('emits rename (new title) and NO move', () => {
|
||||
const rms: RenameMoveAction[] = [
|
||||
{ pageId: 'p2', oldPath: 'Folder/Old.md', newPath: 'Folder/New.md' },
|
||||
];
|
||||
const out = classifyRenameMoves(
|
||||
rms,
|
||||
deps(
|
||||
{
|
||||
'Folder/New.md|current': meta({ title: 'New Title' }),
|
||||
'Folder/Old.md|prev': meta({ title: 'Old Title' }),
|
||||
},
|
||||
{
|
||||
// Same parent on both sides.
|
||||
'Folder/New.md|current': 'folder-id',
|
||||
'Folder/Old.md|prev': 'folder-id',
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(out).toEqual([
|
||||
{
|
||||
pageId: 'p2',
|
||||
oldPath: 'Folder/Old.md',
|
||||
newPath: 'Folder/New.md',
|
||||
rename: { title: 'New Title' },
|
||||
},
|
||||
]);
|
||||
expect(out[0].move).toBeUndefined();
|
||||
expect(out[0].noop).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyRenameMoves — both (parent AND title changed)', () => {
|
||||
it('emits BOTH move and rename', () => {
|
||||
const rms: RenameMoveAction[] = [
|
||||
{ pageId: 'p3', oldPath: 'Old.md', newPath: 'NewParent/New.md' },
|
||||
];
|
||||
const out = classifyRenameMoves(
|
||||
rms,
|
||||
deps(
|
||||
{
|
||||
'NewParent/New.md|current': meta({ title: 'New' }),
|
||||
'Old.md|prev': meta({ title: 'Old' }),
|
||||
},
|
||||
{
|
||||
'NewParent/New.md|current': 'np-id',
|
||||
'Old.md|prev': null,
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(out).toEqual([
|
||||
{
|
||||
pageId: 'p3',
|
||||
oldPath: 'Old.md',
|
||||
newPath: 'NewParent/New.md',
|
||||
move: { parentPageId: 'np-id' },
|
||||
rename: { title: 'New' },
|
||||
},
|
||||
]);
|
||||
expect(out[0].noop).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyRenameMoves — noop (path-only rename, same parent + title)', () => {
|
||||
it('emits noop and NEITHER move NOR rename (SPEC §5: page is its pageId)', () => {
|
||||
const rms: RenameMoveAction[] = [
|
||||
{ pageId: 'p4', oldPath: 'Folder/A.md', newPath: 'Folder/B.md' },
|
||||
];
|
||||
const out = classifyRenameMoves(
|
||||
rms,
|
||||
deps(
|
||||
{
|
||||
'Folder/B.md|current': meta({ title: 'Same' }),
|
||||
'Folder/A.md|prev': meta({ title: 'Same' }),
|
||||
},
|
||||
{
|
||||
'Folder/B.md|current': 'folder-id',
|
||||
'Folder/A.md|prev': 'folder-id',
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(out).toEqual([
|
||||
{
|
||||
pageId: 'p4',
|
||||
oldPath: 'Folder/A.md',
|
||||
newPath: 'Folder/B.md',
|
||||
noop: true,
|
||||
},
|
||||
]);
|
||||
expect(out[0].move).toBeUndefined();
|
||||
expect(out[0].rename).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyRenameMoves — move-to-root (newParent null)', () => {
|
||||
it('emits move with parentPageId null when the file lands at the space root', () => {
|
||||
const rms: RenameMoveAction[] = [
|
||||
{ pageId: 'p5', oldPath: 'Parent/Doc.md', newPath: 'Doc.md' },
|
||||
];
|
||||
const out = classifyRenameMoves(
|
||||
rms,
|
||||
deps(
|
||||
{
|
||||
'Doc.md|current': meta({ title: 'Doc' }),
|
||||
'Parent/Doc.md|prev': meta({ title: 'Doc' }),
|
||||
},
|
||||
{
|
||||
// New parent is ROOT (null), old parent was 'parent-id'.
|
||||
'Doc.md|current': null,
|
||||
'Parent/Doc.md|prev': 'parent-id',
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(out).toEqual([
|
||||
{
|
||||
pageId: 'p5',
|
||||
oldPath: 'Parent/Doc.md',
|
||||
newPath: 'Doc.md',
|
||||
move: { parentPageId: null },
|
||||
},
|
||||
]);
|
||||
expect(out[0].rename).toBeUndefined();
|
||||
expect(out[0].noop).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyRenameMoves — title guards', () => {
|
||||
it('an EMPTY new title is NOT a rename (even if it differs from old)', () => {
|
||||
const rms: RenameMoveAction[] = [
|
||||
{ pageId: 'p6', oldPath: 'Folder/A.md', newPath: 'Folder/B.md' },
|
||||
];
|
||||
const out = classifyRenameMoves(
|
||||
rms,
|
||||
deps(
|
||||
{
|
||||
// New title is empty -> never a rename; same parent -> overall noop.
|
||||
'Folder/B.md|current': meta({ title: '' }),
|
||||
'Folder/A.md|prev': meta({ title: 'Had A Title' }),
|
||||
},
|
||||
{
|
||||
'Folder/B.md|current': 'folder-id',
|
||||
'Folder/A.md|prev': 'folder-id',
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(out[0].rename).toBeUndefined();
|
||||
expect(out[0].move).toBeUndefined();
|
||||
expect(out[0].noop).toBe(true);
|
||||
});
|
||||
|
||||
it('a missing new meta is NOT a rename; a parent change still yields a move', () => {
|
||||
const rms: RenameMoveAction[] = [
|
||||
{ pageId: 'p7', oldPath: 'Doc.md', newPath: 'Parent/Doc.md' },
|
||||
];
|
||||
const out = classifyRenameMoves(
|
||||
rms,
|
||||
deps(
|
||||
{
|
||||
// No current meta entry at all (resolver returns null).
|
||||
'Doc.md|prev': meta({ title: 'Doc' }),
|
||||
},
|
||||
{
|
||||
'Parent/Doc.md|current': 'parent-id',
|
||||
'Doc.md|prev': null,
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(out[0].move).toEqual({ parentPageId: 'parent-id' });
|
||||
expect(out[0].rename).toBeUndefined();
|
||||
expect(out[0].noop).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyRenameMoves — empty input', () => {
|
||||
it('returns an empty array for no rename/move entries', () => {
|
||||
expect(classifyRenameMoves([], deps({}, {}))).toEqual([]);
|
||||
});
|
||||
});
|
||||
193
packages/git-sync/test/compute-pull-actions.test.ts
Normal file
193
packages/git-sync/test/compute-pull-actions.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { computePullActions } from '../src/engine/pull';
|
||||
import type { PageNode } from '../src/engine/layout';
|
||||
|
||||
// R-Pull-2 (test-strategy report §5): `computePullActions` is the PURE half of
|
||||
// the pull cycle — layout + planReconciliation + the SPEC §8 absence-deletion
|
||||
// suppression decision, folded together, with NO IO. These tests exercise it
|
||||
// without git/fs/network. The thin IO applier is covered in apply-pull-actions.
|
||||
|
||||
/** A live tree node (only the fields the layout / reconciliation read). */
|
||||
function node(
|
||||
id: string,
|
||||
title: string,
|
||||
parentPageId: string | null = null,
|
||||
hasChildren = false,
|
||||
): PageNode {
|
||||
return { id, title, slugId: id, parentPageId, hasChildren };
|
||||
}
|
||||
|
||||
describe('computePullActions — normal complete fetch', () => {
|
||||
it('builds toWrite from the live layout and an empty existing set (all adds)', () => {
|
||||
const pages = [
|
||||
node('root', 'Root', null, true),
|
||||
node('child', 'Child', 'root'),
|
||||
];
|
||||
const actions = computePullActions({
|
||||
pages,
|
||||
treeComplete: true,
|
||||
existing: [],
|
||||
});
|
||||
// Each live page is (re)written at its deterministic layout path.
|
||||
expect(actions.toWrite).toEqual([
|
||||
{ pageId: 'root', relPath: 'Root.md' },
|
||||
{ pageId: 'child', relPath: 'Root/Child.md' },
|
||||
]);
|
||||
expect(actions.moved).toEqual([]);
|
||||
expect(actions.toDelete).toEqual([]);
|
||||
expect(actions.deletionDecision).toEqual({ apply: true });
|
||||
});
|
||||
|
||||
it('plans toWrite / moved / toDelete correctly for a mixed reconciliation', () => {
|
||||
const pages = [
|
||||
node('keep', 'Keep'),
|
||||
node('mover', 'Mover'),
|
||||
node('fresh', 'Fresh'),
|
||||
];
|
||||
// existing: keep (same path), mover (old path -> move), dead (absent -> delete).
|
||||
const existing = [
|
||||
{ pageId: 'keep', relPath: 'Keep.md' },
|
||||
{ pageId: 'mover', relPath: 'Old/Mover.md' },
|
||||
{ pageId: 'dead', relPath: 'Dead.md' },
|
||||
];
|
||||
const actions = computePullActions({ pages, treeComplete: true, existing });
|
||||
|
||||
expect(actions.toWrite).toEqual([
|
||||
{ pageId: 'keep', relPath: 'Keep.md' },
|
||||
{ pageId: 'mover', relPath: 'Mover.md' },
|
||||
{ pageId: 'fresh', relPath: 'Fresh.md' },
|
||||
]);
|
||||
// mover moved from Old/Mover.md to the new layout path Mover.md.
|
||||
expect(actions.moved).toEqual([
|
||||
{
|
||||
pageId: 'mover',
|
||||
fromRelPath: 'Old/Mover.md',
|
||||
toRelPath: 'Mover.md',
|
||||
removeOldPath: true,
|
||||
},
|
||||
]);
|
||||
// dead is absent from live -> an absence delete (decision applies it).
|
||||
expect(actions.toDelete).toEqual(['Dead.md']);
|
||||
expect(actions.deletionDecision).toEqual({ apply: true });
|
||||
});
|
||||
|
||||
it('a live page moved to a NEW path is in `moved`, its old path NOT in toDelete', () => {
|
||||
const pages = [node('p1', 'Doc', 'newparent'), node('newparent', 'NewParent', null, true)];
|
||||
const existing = [{ pageId: 'p1', relPath: 'OldParent/Doc.md' }];
|
||||
const actions = computePullActions({ pages, treeComplete: true, existing });
|
||||
|
||||
const moved = actions.moved.find((m) => m.pageId === 'p1');
|
||||
expect(moved).toBeTruthy();
|
||||
expect(moved!.fromRelPath).toBe('OldParent/Doc.md');
|
||||
expect(moved!.toRelPath).toBe('NewParent/Doc.md');
|
||||
// The old path is a MOVE removal, NEVER an absence delete.
|
||||
expect(actions.toDelete).not.toContain('OldParent/Doc.md');
|
||||
expect(actions.toDelete).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computePullActions — SPEC §8 suppression folded in', () => {
|
||||
it('INCOMPLETE fetch (treeComplete:false) SUPPRESSES absence deletions', () => {
|
||||
// dead is absent from the live tree, but the tree fetch was partial -> the
|
||||
// missing pageId is NOT proof of deletion, so toDelete must be EMPTY and the
|
||||
// decision must report apply:false / incomplete-fetch.
|
||||
const pages = [node('keep', 'Keep')];
|
||||
const existing = [
|
||||
{ pageId: 'keep', relPath: 'Keep.md' },
|
||||
{ pageId: 'dead', relPath: 'Dead.md' },
|
||||
];
|
||||
const actions = computePullActions({
|
||||
pages,
|
||||
treeComplete: false,
|
||||
existing,
|
||||
});
|
||||
|
||||
expect(actions.deletionDecision).toEqual({
|
||||
apply: false,
|
||||
reason: 'incomplete-fetch',
|
||||
});
|
||||
// Suppressed: nothing to delete this cycle...
|
||||
expect(actions.toDelete).toEqual([]);
|
||||
// ...but the planned count is still reported (for the suppression log).
|
||||
expect(actions.plannedDeleteCount).toBe(1);
|
||||
// Writes/updates still happen regardless of the suppression.
|
||||
expect(actions.toWrite).toEqual([{ pageId: 'keep', relPath: 'Keep.md' }]);
|
||||
});
|
||||
|
||||
it('MASS-DELETE guard (>50% of a non-trivial vault) SUPPRESSES deletions', () => {
|
||||
// 1 live page, 10 existing tracked, 9 of them absent -> 9/10 > 50% on a
|
||||
// non-trivial (>=4) vault -> mass-delete suppression.
|
||||
const pages = [node('p0', 'P0')];
|
||||
const existing = [
|
||||
{ pageId: 'p0', relPath: 'P0.md' },
|
||||
...Array.from({ length: 9 }, (_, i) => ({
|
||||
pageId: `gone${i}`,
|
||||
relPath: `Gone${i}.md`,
|
||||
})),
|
||||
];
|
||||
const actions = computePullActions({ pages, treeComplete: true, existing });
|
||||
|
||||
expect(actions.deletionDecision).toEqual({
|
||||
apply: false,
|
||||
reason: 'mass-delete',
|
||||
});
|
||||
expect(actions.toDelete).toEqual([]);
|
||||
expect(actions.plannedDeleteCount).toBe(9);
|
||||
expect(actions.existingCount).toBe(10);
|
||||
});
|
||||
|
||||
it('moves are NOT suppressed even on an incomplete fetch', () => {
|
||||
// A moved page is PRESENT in live, so its move is real regardless of the
|
||||
// suppression (which only governs ABSENCE deletes).
|
||||
const pages = [node('m', 'Moved')];
|
||||
const existing = [{ pageId: 'm', relPath: 'Old/Moved.md' }];
|
||||
const actions = computePullActions({
|
||||
pages,
|
||||
treeComplete: false,
|
||||
existing,
|
||||
});
|
||||
expect(actions.moved).toEqual([
|
||||
{
|
||||
pageId: 'm',
|
||||
fromRelPath: 'Old/Moved.md',
|
||||
toRelPath: 'Moved.md',
|
||||
removeOldPath: true,
|
||||
},
|
||||
]);
|
||||
// No absence deletes were planned here, so the decision trivially applies.
|
||||
expect(actions.toDelete).toEqual([]);
|
||||
});
|
||||
|
||||
it('empty-live with tracked files SUPPRESSES (failed fetch, not a real wipe)', () => {
|
||||
const existing = [
|
||||
{ pageId: 'a', relPath: 'A.md' },
|
||||
{ pageId: 'b', relPath: 'B.md' },
|
||||
];
|
||||
const actions = computePullActions({
|
||||
pages: [],
|
||||
treeComplete: true,
|
||||
existing,
|
||||
});
|
||||
expect(actions.deletionDecision).toEqual({
|
||||
apply: false,
|
||||
reason: 'empty-live',
|
||||
});
|
||||
expect(actions.toDelete).toEqual([]);
|
||||
expect(actions.toWrite).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computePullActions — degenerate inputs', () => {
|
||||
it('skips nodes without an id and nodes with no layout entry', () => {
|
||||
const pages = [
|
||||
node('p1', 'Valid'),
|
||||
{ id: '', title: 'NoId' } as PageNode, // skipped (no id)
|
||||
];
|
||||
const actions = computePullActions({
|
||||
pages,
|
||||
treeComplete: true,
|
||||
existing: [],
|
||||
});
|
||||
expect(actions.toWrite).toEqual([{ pageId: 'p1', relPath: 'Valid.md' }]);
|
||||
});
|
||||
});
|
||||
225
packages/git-sync/test/compute-push-actions.test.ts
Normal file
225
packages/git-sync/test/compute-push-actions.test.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { computePushActions } from '../src/engine/push';
|
||||
import type { DiffEntry, MetaSide } from '../src/engine/push';
|
||||
import type { DocmostMdMeta } from '../src/lib/index';
|
||||
|
||||
// FS→Docmost push, FIRST increment (SPEC §6). `computePushActions` is the PURE
|
||||
// half: it classifies each `git diff --name-status` row into a Docmost action by
|
||||
// `pageId` identity (SPEC §4/§8), with NO IO — the `metaAt` resolver is injected.
|
||||
// These tests cover every classification incl. edges.
|
||||
|
||||
/** Build a `metaAt` resolver from a `path|side -> meta` table. */
|
||||
function metaTable(
|
||||
table: Record<string, DocmostMdMeta | null>,
|
||||
): (path: string, side: MetaSide) => DocmostMdMeta | null {
|
||||
return (path, side) => {
|
||||
const key = `${path}|${side}`;
|
||||
return key in table ? table[key] : null;
|
||||
};
|
||||
}
|
||||
|
||||
function meta(partial: Partial<DocmostMdMeta>): DocmostMdMeta {
|
||||
return { version: 1, ...partial };
|
||||
}
|
||||
|
||||
describe('computePushActions — A (added)', () => {
|
||||
it('added file with NO pageId -> create', () => {
|
||||
const changes: DiffEntry[] = [{ status: 'A', path: 'New.md' }];
|
||||
const metaAt = metaTable({
|
||||
'New.md|current': meta({ title: 'New', spaceId: 'sp1' }),
|
||||
});
|
||||
const actions = computePushActions({ changes, metaAt });
|
||||
expect(actions.creates).toEqual([{ path: 'New.md' }]);
|
||||
expect(actions.updates).toEqual([]);
|
||||
expect(actions.deletes).toEqual([]);
|
||||
expect(actions.renamesMoves).toEqual([]);
|
||||
expect(actions.skipped).toEqual([]);
|
||||
});
|
||||
|
||||
it('added file with NO meta at all -> skipped (a create needs a spaceId)', () => {
|
||||
// No meta -> no spaceId -> cannot create (Docmost create_page requires it).
|
||||
const changes: DiffEntry[] = [{ status: 'A', path: 'Plain.md' }];
|
||||
const actions = computePushActions({ changes, metaAt: metaTable({}) });
|
||||
expect(actions.creates).toEqual([]);
|
||||
expect(actions.skipped).toEqual([
|
||||
{ path: 'Plain.md', status: 'A', reason: 'create-without-spaceId' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('added file with meta but NO spaceId -> skipped (create-without-spaceId)', () => {
|
||||
// Partial human meta (title only, no spaceId) -> refuse to create.
|
||||
const changes: DiffEntry[] = [{ status: 'A', path: 'Partial.md' }];
|
||||
const metaAt = metaTable({
|
||||
'Partial.md|current': meta({ title: 'Partial' }),
|
||||
});
|
||||
const actions = computePushActions({ changes, metaAt });
|
||||
expect(actions.creates).toEqual([]);
|
||||
expect(actions.skipped).toEqual([
|
||||
{ path: 'Partial.md', status: 'A', reason: 'create-without-spaceId' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('added file with an EMPTY-string spaceId -> skipped (create-without-spaceId)', () => {
|
||||
// An empty spaceId is not a usable target either.
|
||||
const changes: DiffEntry[] = [{ status: 'A', path: 'Empty.md' }];
|
||||
const metaAt = metaTable({
|
||||
'Empty.md|current': meta({ title: 'E', spaceId: '' }),
|
||||
});
|
||||
const actions = computePushActions({ changes, metaAt });
|
||||
expect(actions.creates).toEqual([]);
|
||||
expect(actions.skipped).toEqual([
|
||||
{ path: 'Empty.md', status: 'A', reason: 'create-without-spaceId' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('added file WITH a pageId (restored/copied) -> update (page exists)', () => {
|
||||
const changes: DiffEntry[] = [{ status: 'A', path: 'Restored.md' }];
|
||||
const metaAt = metaTable({
|
||||
'Restored.md|current': meta({ pageId: 'p-restored', title: 'R' }),
|
||||
});
|
||||
const actions = computePushActions({ changes, metaAt });
|
||||
// The page already exists -> push content as an UPDATE, never a duplicate.
|
||||
expect(actions.updates).toEqual([
|
||||
{ pageId: 'p-restored', path: 'Restored.md' },
|
||||
]);
|
||||
expect(actions.creates).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computePushActions — M (modified)', () => {
|
||||
it('modified file with a pageId -> update content', () => {
|
||||
const changes: DiffEntry[] = [{ status: 'M', path: 'Doc.md' }];
|
||||
const metaAt = metaTable({
|
||||
'Doc.md|current': meta({ pageId: 'p-doc' }),
|
||||
});
|
||||
const actions = computePushActions({ changes, metaAt });
|
||||
expect(actions.updates).toEqual([{ pageId: 'p-doc', path: 'Doc.md' }]);
|
||||
expect(actions.skipped).toEqual([]);
|
||||
});
|
||||
|
||||
it('modified file with NO pageId -> skipped (no target to update)', () => {
|
||||
const changes: DiffEntry[] = [{ status: 'M', path: 'Untracked.md' }];
|
||||
const actions = computePushActions({ changes, metaAt: metaTable({}) });
|
||||
expect(actions.updates).toEqual([]);
|
||||
expect(actions.skipped).toEqual([
|
||||
{
|
||||
path: 'Untracked.md',
|
||||
status: 'M',
|
||||
reason: 'modified file has no pageId in meta',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computePushActions — D (deleted)', () => {
|
||||
it('deleted file recovers pageId from the PRE-IMAGE meta -> delete', () => {
|
||||
const changes: DiffEntry[] = [{ status: 'D', path: 'Gone.md' }];
|
||||
// The file is gone from `current`; its pageId lives in the `prev` pre-image.
|
||||
const metaAt = metaTable({
|
||||
'Gone.md|prev': meta({ pageId: 'p-gone' }),
|
||||
});
|
||||
const actions = computePushActions({ changes, metaAt });
|
||||
expect(actions.deletes).toEqual([{ pageId: 'p-gone' }]);
|
||||
expect(actions.skipped).toEqual([]);
|
||||
});
|
||||
|
||||
it('deleted file with NO recoverable pageId -> skipped (untracked guard §8)', () => {
|
||||
const changes: DiffEntry[] = [{ status: 'D', path: 'Stray.md' }];
|
||||
// No pre-image pageId -> the untracked-file guard skips it (never deletes a
|
||||
// page that was never tracked, SPEC §8).
|
||||
const actions = computePushActions({ changes, metaAt: metaTable({}) });
|
||||
expect(actions.deletes).toEqual([]);
|
||||
expect(actions.skipped).toEqual([
|
||||
{
|
||||
path: 'Stray.md',
|
||||
status: 'D',
|
||||
reason: 'deleted file has no recoverable pageId (pre-image meta)',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the PREV side, not current, to recover the deleted pageId', () => {
|
||||
const changes: DiffEntry[] = [{ status: 'D', path: 'Gone.md' }];
|
||||
// A stale `current` meta must NOT be used; only the pre-image counts.
|
||||
const metaAt = metaTable({
|
||||
'Gone.md|current': meta({ pageId: 'WRONG' }),
|
||||
'Gone.md|prev': meta({ pageId: 'p-correct' }),
|
||||
});
|
||||
const actions = computePushActions({ changes, metaAt });
|
||||
expect(actions.deletes).toEqual([{ pageId: 'p-correct' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computePushActions — R/C (renamed/moved)', () => {
|
||||
it('renamed file -> renamesMoves (record only; resolution deferred)', () => {
|
||||
const changes: DiffEntry[] = [
|
||||
{ status: 'R', path: 'New/Path.md', oldPath: 'Old/Path.md', score: 100 },
|
||||
];
|
||||
const metaAt = metaTable({
|
||||
'New/Path.md|current': meta({ pageId: 'p-moved' }),
|
||||
});
|
||||
const actions = computePushActions({ changes, metaAt });
|
||||
expect(actions.renamesMoves).toEqual([
|
||||
{ pageId: 'p-moved', oldPath: 'Old/Path.md', newPath: 'New/Path.md' },
|
||||
]);
|
||||
// It is NOT also recorded as a create/update/delete.
|
||||
expect(actions.creates).toEqual([]);
|
||||
expect(actions.updates).toEqual([]);
|
||||
expect(actions.deletes).toEqual([]);
|
||||
});
|
||||
|
||||
it('copy (C) is recorded like a rename for the deferred apply', () => {
|
||||
const changes: DiffEntry[] = [
|
||||
{ status: 'C', path: 'Copy.md', oldPath: 'Src.md', score: 90 },
|
||||
];
|
||||
const metaAt = metaTable({
|
||||
'Copy.md|current': meta({ pageId: 'p-copy' }),
|
||||
});
|
||||
const actions = computePushActions({ changes, metaAt });
|
||||
expect(actions.renamesMoves).toEqual([
|
||||
{ pageId: 'p-copy', oldPath: 'Src.md', newPath: 'Copy.md' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('renamed file with NO pageId -> skipped', () => {
|
||||
const changes: DiffEntry[] = [
|
||||
{ status: 'R', path: 'New.md', oldPath: 'Old.md', score: 100 },
|
||||
];
|
||||
const actions = computePushActions({ changes, metaAt: metaTable({}) });
|
||||
expect(actions.renamesMoves).toEqual([]);
|
||||
expect(actions.skipped).toEqual([
|
||||
{ path: 'New.md', status: 'R', reason: 'renamed/moved file has no pageId in meta' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computePushActions — mixed batch', () => {
|
||||
it('classifies a realistic mixed diff in one pass', () => {
|
||||
const changes: DiffEntry[] = [
|
||||
{ status: 'A', path: 'Fresh.md' }, // create
|
||||
{ status: 'A', path: 'Restored.md' }, // update (has pageId)
|
||||
{ status: 'M', path: 'Edited.md' }, // update
|
||||
{ status: 'D', path: 'Removed.md' }, // delete
|
||||
{ status: 'R', path: 'Dst.md', oldPath: 'Srcc.md', score: 100 }, // move
|
||||
];
|
||||
const metaAt = metaTable({
|
||||
'Fresh.md|current': meta({ title: 'Fresh', spaceId: 'sp' }),
|
||||
'Restored.md|current': meta({ pageId: 'p-rest' }),
|
||||
'Edited.md|current': meta({ pageId: 'p-edit' }),
|
||||
'Removed.md|prev': meta({ pageId: 'p-rm' }),
|
||||
'Dst.md|current': meta({ pageId: 'p-mv' }),
|
||||
});
|
||||
const actions = computePushActions({ changes, metaAt });
|
||||
|
||||
expect(actions.creates).toEqual([{ path: 'Fresh.md' }]);
|
||||
expect(actions.updates).toEqual([
|
||||
{ pageId: 'p-rest', path: 'Restored.md' },
|
||||
{ pageId: 'p-edit', path: 'Edited.md' },
|
||||
]);
|
||||
expect(actions.deletes).toEqual([{ pageId: 'p-rm' }]);
|
||||
expect(actions.renamesMoves).toEqual([
|
||||
{ pageId: 'p-mv', oldPath: 'Srcc.md', newPath: 'Dst.md' },
|
||||
]);
|
||||
expect(actions.skipped).toEqual([]);
|
||||
});
|
||||
});
|
||||
139
packages/git-sync/test/config-errors-invalid.test.ts
Normal file
139
packages/git-sync/test/config-errors-invalid.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { z, ZodError } from 'zod';
|
||||
import { loadSettingsOrExit } from '../src/engine/config-errors';
|
||||
import { envSchema } from '../src/engine/settings';
|
||||
|
||||
// Companion to test/config-errors.test.ts. That file covers the success path,
|
||||
// the MISSING-required (undefined -> invalid_type) -> exit branch, and the
|
||||
// non-ZodError passthrough. This file fills the remaining GAP: the
|
||||
// INVALID-VALUE branch (config-errors.ts lines ~20, 27-30). A ZodError whose
|
||||
// issue is a CONSTRAINT violation (bad URL, bad enum, too-short string) is NOT
|
||||
// a missing key, so it must be routed into the `invalid` bucket and reported
|
||||
// under the "Invalid value(s)" heading with a `<name>: <message>` line — a
|
||||
// distinct, operator-facing message from the missing-variable case.
|
||||
describe('loadSettingsOrExit — invalid-value branch', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Stub process.exit so it throws (control stops at the exit point without
|
||||
// killing the runner) and capture everything written to stderr. Mirrors the
|
||||
// approach in the existing config-errors.test.ts.
|
||||
function stubExitAndStderr() {
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((
|
||||
code?: number,
|
||||
) => {
|
||||
throw new Error(`exit:${code}`);
|
||||
}) as never);
|
||||
const writeSpy = vi
|
||||
.spyOn(process.stderr, 'write')
|
||||
.mockImplementation(() => true);
|
||||
const written = () => writeSpy.mock.calls.map((c) => String(c[0])).join('');
|
||||
return { exitSpy, writeSpy, written };
|
||||
}
|
||||
|
||||
it('exits(1) and reports an invalid value (bad URL) under "Invalid value(s)"', () => {
|
||||
const { exitSpy, written } = stubExitAndStderr();
|
||||
|
||||
// A present-but-invalid DOCMOST_API_URL: the value exists (so it is NOT a
|
||||
// missing-key issue), but fails the .url() constraint -> goes to `invalid`.
|
||||
expect(() =>
|
||||
loadSettingsOrExit(() =>
|
||||
envSchema.parse({
|
||||
DOCMOST_API_URL: 'not-a-url',
|
||||
DOCMOST_EMAIL: 'ops@example.com',
|
||||
DOCMOST_PASSWORD: 'hunter2',
|
||||
DOCMOST_SPACE_ID: 'space-1',
|
||||
}),
|
||||
),
|
||||
).toThrow('exit:1');
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
const out = written();
|
||||
// The invalid-value heading must appear...
|
||||
expect(out).toContain('Invalid value(s)');
|
||||
// ...and it must name the offending variable on a `<name>: <message>` line.
|
||||
expect(out).toContain('DOCMOST_API_URL:');
|
||||
// The header line is always present.
|
||||
expect(out).toContain('Configuration error in environment / .env:');
|
||||
// It must NOT misreport an invalid value as a missing one.
|
||||
expect(out).not.toContain('Missing required variable(s)');
|
||||
});
|
||||
|
||||
it('exits(1) and reports an invalid enum value (LOG_LEVEL)', () => {
|
||||
const { exitSpy, written } = stubExitAndStderr();
|
||||
|
||||
// All required vars present and valid; only LOG_LEVEL violates the enum.
|
||||
expect(() =>
|
||||
loadSettingsOrExit(() =>
|
||||
envSchema.parse({
|
||||
DOCMOST_API_URL: 'https://docs.example.com/api',
|
||||
DOCMOST_EMAIL: 'ops@example.com',
|
||||
DOCMOST_PASSWORD: 'hunter2',
|
||||
DOCMOST_SPACE_ID: 'space-1',
|
||||
LOG_LEVEL: 'verbose', // not in ['debug','info','warn','error']
|
||||
}),
|
||||
),
|
||||
).toThrow('exit:1');
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
const out = written();
|
||||
expect(out).toContain('Invalid value(s)');
|
||||
expect(out).toContain('LOG_LEVEL:');
|
||||
expect(out).not.toContain('Missing required variable(s)');
|
||||
});
|
||||
|
||||
it('routes a hand-built constraint-violation ZodError into the invalid bucket', () => {
|
||||
const { exitSpy, written } = stubExitAndStderr();
|
||||
|
||||
// Construct the ZodError directly from a min-length violation so the test
|
||||
// does not depend on the project schema's exact field set. The issue has a
|
||||
// non-empty path (so a variable name is printed) and code "too_small"
|
||||
// (NOT invalid_type/undefined), so config-errors.ts classifies it as
|
||||
// invalid rather than missing.
|
||||
const zerr = new ZodError([
|
||||
{
|
||||
code: 'too_small',
|
||||
minimum: 1,
|
||||
type: 'string',
|
||||
inclusive: true,
|
||||
path: ['DOCMOST_PASSWORD'],
|
||||
message: 'String must contain at least 1 character(s)',
|
||||
} as z.ZodIssue,
|
||||
]);
|
||||
|
||||
expect(() =>
|
||||
loadSettingsOrExit(() => {
|
||||
throw zerr;
|
||||
}),
|
||||
).toThrow('exit:1');
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
const out = written();
|
||||
expect(out).toContain('Invalid value(s)');
|
||||
expect(out).toContain('DOCMOST_PASSWORD: String must contain at least 1');
|
||||
expect(out).not.toContain('Missing required variable(s)');
|
||||
});
|
||||
|
||||
it('reports missing AND invalid in their own sections when both occur', () => {
|
||||
const { exitSpy, written } = stubExitAndStderr();
|
||||
|
||||
// DOCMOST_API_URL present but invalid (-> invalid section); the three other
|
||||
// required vars absent (-> missing section). Confirms the two branches are
|
||||
// populated and emitted independently.
|
||||
expect(() =>
|
||||
loadSettingsOrExit(() =>
|
||||
envSchema.parse({
|
||||
DOCMOST_API_URL: 'not-a-url',
|
||||
}),
|
||||
),
|
||||
).toThrow('exit:1');
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
const out = written();
|
||||
expect(out).toContain('Missing required variable(s)');
|
||||
expect(out).toContain('Invalid value(s)');
|
||||
expect(out).toContain('DOCMOST_API_URL:');
|
||||
expect(out).toContain('DOCMOST_EMAIL');
|
||||
});
|
||||
});
|
||||
56
packages/git-sync/test/config-errors.test.ts
Normal file
56
packages/git-sync/test/config-errors.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
import { loadSettingsOrExit } from '../src/engine/config-errors';
|
||||
|
||||
describe('loadSettingsOrExit', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns the factory value and does not exit on success', () => {
|
||||
const exitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined) as never);
|
||||
|
||||
const result = loadSettingsOrExit(() => ({ ok: true }));
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(exitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prints a named-variable message and exits(1) on a ZodError', () => {
|
||||
// Mock process.exit to throw so control stops at the exit point, mirroring
|
||||
// the real exit-the-process behaviour without killing the test runner.
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((
|
||||
code?: number,
|
||||
) => {
|
||||
throw new Error(`exit:${code}`);
|
||||
}) as never);
|
||||
const writeSpy = vi
|
||||
.spyOn(process.stderr, 'write')
|
||||
.mockImplementation(() => true);
|
||||
|
||||
expect(() =>
|
||||
loadSettingsOrExit(() => z.object({ FOO: z.string() }).parse({})),
|
||||
).toThrow('exit:1');
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
const written = writeSpy.mock.calls.map((c) => String(c[0])).join('');
|
||||
expect(written).toContain('Missing required variable(s)');
|
||||
expect(written).toContain('FOO');
|
||||
});
|
||||
|
||||
it('propagates a non-ZodError without exiting', () => {
|
||||
const exitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined) as never);
|
||||
const boom = new Error('x');
|
||||
|
||||
expect(() =>
|
||||
loadSettingsOrExit(() => {
|
||||
throw boom;
|
||||
}),
|
||||
).toThrow(boom);
|
||||
expect(exitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
151
packages/git-sync/test/git-merge.test.ts
Normal file
151
packages/git-sync/test/git-merge.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
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';
|
||||
|
||||
// git 3-way merge integration (test-strategy report §2 git gap). The existing
|
||||
// git.test.ts covers a fast-forward merge and a conflicting merge; this file
|
||||
// adds the two MISSING cases against a REAL temp git repo under os.tmpdir():
|
||||
// 1. a clean NON-fast-forward 3-way merge of non-overlapping changes ->
|
||||
// { ok:true, conflict:false } and a real merge commit (two parents);
|
||||
// 2. a NON-conflict merge FAILURE -> { ok:false, conflict:false } so the pull
|
||||
// cycle does not mislabel it a "conflict markers in vault" situation.
|
||||
// The conflicting-merge case (markers + conflict:true) already lives in
|
||||
// git.test.ts and is NOT duplicated here. Skips gracefully if git is missing.
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
async function gitAvailable(): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync('git', ['--version']);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Number of parents of HEAD (2 => a real merge commit). */
|
||||
async function headParentCount(dir: string): Promise<number> {
|
||||
const { stdout } = await execFileAsync(
|
||||
'git',
|
||||
['--no-pager', 'rev-list', '--parents', '-n', '1', 'HEAD'],
|
||||
{ cwd: dir },
|
||||
);
|
||||
// Output: "<commit> <parent1> <parent2?>..." — parents are the trailing ids.
|
||||
return stdout.trim().split(/\s+/).length - 1;
|
||||
}
|
||||
|
||||
describe('VaultGit.merge — 3-way merge integration (temp repo)', () => {
|
||||
let available = false;
|
||||
let dir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
available = await gitAvailable();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (dir) await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function freshRepo(): Promise<{ vault: string; git: VaultGit }> {
|
||||
dir = await mkdtemp(join(tmpdir(), 'docmost-merge-'));
|
||||
const git = new VaultGit(dir);
|
||||
await git.ensureRepo();
|
||||
await git.ensureBranch('docmost', 'main');
|
||||
return { vault: dir, git };
|
||||
}
|
||||
|
||||
async function commit(
|
||||
git: VaultGit,
|
||||
subject: string,
|
||||
author = { name: BOT_AUTHOR_NAME, email: BOT_AUTHOR_EMAIL },
|
||||
): Promise<void> {
|
||||
await git.stageAll();
|
||||
await git.commit(subject, {
|
||||
authorName: author.name,
|
||||
authorEmail: author.email,
|
||||
});
|
||||
}
|
||||
|
||||
it('clean NON-fast-forward 3-way merge of non-overlapping changes -> merge commit', async () => {
|
||||
if (!available) return; // skip gracefully when git is unavailable
|
||||
const { vault, git } = await freshRepo();
|
||||
|
||||
// Seed a shared base file on main so both branches diverge from a real
|
||||
// merge-base (not an empty tree).
|
||||
await writeFile(join(vault, 'base.md'), 'shared base\n', 'utf8');
|
||||
await commit(git, 'base');
|
||||
// Re-create docmost from this base so the merge-base is `base`.
|
||||
await execFileAsync('git', ['--no-pager', 'branch', '-f', 'docmost', 'main'], {
|
||||
cwd: vault,
|
||||
});
|
||||
|
||||
// docmost adds doc-only.md (a DIFFERENT file than main touches).
|
||||
await git.checkout('docmost');
|
||||
await writeFile(join(vault, 'doc-only.md'), 'from docmost\n', 'utf8');
|
||||
await commit(git, 'docmost: add doc-only');
|
||||
|
||||
// main adds main-only.md AND advances past the merge-base, so the merge can
|
||||
// NOT fast-forward — it must create a real 3-way merge commit.
|
||||
await git.checkout('main');
|
||||
await writeFile(join(vault, 'main-only.md'), 'from main\n', 'utf8');
|
||||
await commit(git, 'local: add main-only', {
|
||||
name: 'Human',
|
||||
email: 'human@local',
|
||||
});
|
||||
|
||||
const res = await git.merge('docmost');
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.conflict).toBe(false);
|
||||
|
||||
// A real (non-FF) merge: HEAD has TWO parents.
|
||||
expect(await headParentCount(vault)).toBe(2);
|
||||
|
||||
// Both non-overlapping changes are present on main after the merge.
|
||||
const tracked = await git.listTrackedFiles();
|
||||
expect(new Set(tracked)).toEqual(
|
||||
new Set(['base.md', 'main-only.md', 'doc-only.md']),
|
||||
);
|
||||
});
|
||||
|
||||
it('NON-conflict merge FAILURE -> { ok:false, conflict:false } (not mislabeled a conflict)', async () => {
|
||||
if (!available) return;
|
||||
const { vault, git } = await freshRepo();
|
||||
|
||||
// base file on main, then fork docmost from this base.
|
||||
await writeFile(join(vault, 'f.md'), 'base\n', 'utf8');
|
||||
await commit(git, 'base');
|
||||
await execFileAsync('git', ['--no-pager', 'branch', '-f', 'docmost', 'main'], {
|
||||
cwd: vault,
|
||||
});
|
||||
|
||||
// docmost modifies f.md (committed).
|
||||
await git.checkout('docmost');
|
||||
await writeFile(join(vault, 'f.md'), 'docmost change\n', 'utf8');
|
||||
await commit(git, 'docmost: edit f');
|
||||
|
||||
// Back on main, leave an UNCOMMITTED local change to f.md. git refuses the
|
||||
// merge ("Your local changes ... would be overwritten by merge") and exits
|
||||
// non-zero — but there are NO unmerged index paths, so this is a clean
|
||||
// FAILURE, not a conflict. `merge()` must report { ok:false, conflict:false }
|
||||
// so pull.ts does not falsely claim conflict markers are in the vault.
|
||||
await git.checkout('main');
|
||||
await writeFile(join(vault, 'f.md'), 'uncommitted local edit\n', 'utf8');
|
||||
// NOTE: deliberately NOT staged/committed.
|
||||
|
||||
const res = await git.merge('docmost');
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.conflict).toBe(false);
|
||||
// The merge did not start: HEAD is still a single-parent commit.
|
||||
expect(await headParentCount(vault)).toBe(1);
|
||||
// And the repo is NOT left mid-merge (no MERGE_HEAD / unmerged paths).
|
||||
expect(await git.isMergeInProgress()).toBe(false);
|
||||
});
|
||||
});
|
||||
710
packages/git-sync/test/git.test.ts
Normal file
710
packages/git-sync/test/git.test.ts
Normal file
@@ -0,0 +1,710 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { mkdir, 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 { chmod } from 'node:fs/promises';
|
||||
import {
|
||||
VaultGit,
|
||||
BOT_AUTHOR_NAME,
|
||||
BOT_AUTHOR_EMAIL,
|
||||
buildCommitMessage,
|
||||
vaultGitEnv,
|
||||
} from '../src/engine/git';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/** True if a usable `git` binary is on PATH (skip the suite otherwise). */
|
||||
async function gitAvailable(): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync('git', ['--version']);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the full commit message of HEAD (subject + body) in a repo dir. */
|
||||
async function headMessage(dir: string): Promise<string> {
|
||||
const { stdout } = await execFileAsync(
|
||||
'git',
|
||||
['--no-pager', 'log', '-1', '--pretty=%B'],
|
||||
{ cwd: dir },
|
||||
);
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
/** Read the author "Name <email>" of HEAD in a repo dir. */
|
||||
async function headAuthor(dir: string): Promise<string> {
|
||||
const { stdout } = await execFileAsync(
|
||||
'git',
|
||||
['--no-pager', 'log', '-1', '--pretty=%an <%ae>'],
|
||||
{ cwd: dir },
|
||||
);
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
describe('buildCommitMessage (pure)', () => {
|
||||
it('returns the bare subject when there are no trailers', () => {
|
||||
expect(buildCommitMessage('subject')).toBe('subject');
|
||||
expect(buildCommitMessage('subject', [])).toBe('subject');
|
||||
});
|
||||
|
||||
it('appends trailers separated from the subject by a blank line', () => {
|
||||
expect(buildCommitMessage('subject', ['Docmost-Sync-Source: docmost'])).toBe(
|
||||
'subject\n\nDocmost-Sync-Source: docmost',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vaultGitEnv (pure)', () => {
|
||||
it('pins locale, pager and prompt, and strips GIT_DIR/GIT_WORK_TREE', () => {
|
||||
// Seed inputs that MUST be neutralized/stripped: a redirecting GIT_DIR and
|
||||
// GIT_WORK_TREE would defeat the cwd-isolation guarantee (SPEC §12).
|
||||
process.env.GIT_DIR = '/somewhere/else/.git';
|
||||
process.env.GIT_WORK_TREE = '/somewhere/else';
|
||||
try {
|
||||
const env = vaultGitEnv();
|
||||
// Locale-independent output.
|
||||
expect(env.LC_ALL).toBe('C');
|
||||
expect(env.LANG).toBe('C');
|
||||
// Never page, never block on an interactive prompt.
|
||||
expect(env.GIT_PAGER).toBe('cat');
|
||||
expect(env.GIT_TERMINAL_PROMPT).toBe('0');
|
||||
// The redirecting vars are removed regardless of what process.env held.
|
||||
expect(env.GIT_DIR).toBeUndefined();
|
||||
expect(env.GIT_WORK_TREE).toBeUndefined();
|
||||
} finally {
|
||||
delete process.env.GIT_DIR;
|
||||
delete process.env.GIT_WORK_TREE;
|
||||
}
|
||||
});
|
||||
|
||||
it('passes through caller extras (e.g. author/committer identity)', () => {
|
||||
const env = vaultGitEnv({ GIT_AUTHOR_NAME: 'X', GIT_AUTHOR_EMAIL: 'x@y' });
|
||||
expect(env.GIT_AUTHOR_NAME).toBe('X');
|
||||
expect(env.GIT_AUTHOR_EMAIL).toBe('x@y');
|
||||
// Still strips the redirecting vars even with extras present.
|
||||
expect(env.GIT_DIR).toBeUndefined();
|
||||
expect(env.GIT_WORK_TREE).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('VaultGit (integration; temp repo)', () => {
|
||||
let available = false;
|
||||
let dir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
available = await gitAvailable();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (dir) {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/** Make a fresh temp dir for one test (under the OS tmpdir, NOT the repo). */
|
||||
async function freshDir(): Promise<string> {
|
||||
dir = await mkdtemp(join(tmpdir(), 'docmost-vault-'));
|
||||
return dir;
|
||||
}
|
||||
|
||||
it('ensureRepo creates .git + main + an initial commit', async () => {
|
||||
if (!available) return; // skip gracefully when git is unavailable
|
||||
const vault = await freshDir();
|
||||
const git = new VaultGit(vault);
|
||||
await git.ensureRepo();
|
||||
|
||||
// It is a git work-tree now.
|
||||
const { stdout: insideWt } = await execFileAsync(
|
||||
'git',
|
||||
['rev-parse', '--is-inside-work-tree'],
|
||||
{ cwd: vault },
|
||||
);
|
||||
expect(insideWt.trim()).toBe('true');
|
||||
|
||||
// On `main`.
|
||||
expect(await git.currentBranch()).toBe('main');
|
||||
|
||||
// Has the initial commit.
|
||||
expect(await headMessage(vault)).toBe('init vault');
|
||||
|
||||
// Idempotent: calling again does not create a second commit.
|
||||
await git.ensureRepo();
|
||||
const { stdout: count } = await execFileAsync(
|
||||
'git',
|
||||
['rev-list', '--count', 'HEAD'],
|
||||
{ cwd: vault },
|
||||
);
|
||||
expect(count.trim()).toBe('1');
|
||||
});
|
||||
|
||||
it('ensureRepo neutralizes correctness-affecting LOCAL config', async () => {
|
||||
if (!available) return;
|
||||
const vault = await freshDir();
|
||||
const git = new VaultGit(vault);
|
||||
await git.ensureRepo();
|
||||
|
||||
// These LOCAL values neutralize a hostile GLOBAL/system config that would
|
||||
// otherwise change porcelain BEHAVIOR and corrupt the vault (SPEC §11 for
|
||||
// core.autocrlf; gpgsign/safecrlf for the headless daemon).
|
||||
const localConfig = async (key: string): Promise<string> => {
|
||||
const { stdout } = await execFileAsync(
|
||||
'git',
|
||||
['config', '--local', '--get', key],
|
||||
{ cwd: vault },
|
||||
);
|
||||
return stdout.trim();
|
||||
};
|
||||
expect(await localConfig('core.autocrlf')).toBe('false');
|
||||
expect(await localConfig('commit.gpgsign')).toBe('false');
|
||||
expect(await localConfig('core.safecrlf')).toBe('false');
|
||||
expect(await localConfig('core.attributesFile')).toBe('/dev/null');
|
||||
|
||||
// Idempotent: a second run leaves the same single values (no duplicates).
|
||||
await git.ensureRepo();
|
||||
expect(await localConfig('core.autocrlf')).toBe('false');
|
||||
expect(await localConfig('commit.gpgsign')).toBe('false');
|
||||
expect(await localConfig('core.safecrlf')).toBe('false');
|
||||
});
|
||||
|
||||
it('preserves LF bytes verbatim on commit (SPEC §11: autocrlf=false)', async () => {
|
||||
if (!available) return;
|
||||
const vault = await freshDir();
|
||||
const git = new VaultGit(vault);
|
||||
await git.ensureRepo();
|
||||
|
||||
// Write content with explicit LF line endings. With a hostile
|
||||
// core.autocrlf=true git would translate these to CRLF in the stored blob,
|
||||
// breaking the byte-stable round-trip invariant. ensureRepo pins
|
||||
// core.autocrlf=false locally, so the stored bytes must round-trip exactly.
|
||||
const fileName = 'lf.md';
|
||||
const content = 'line1\nline2\nline3\n';
|
||||
await writeFile(join(vault, fileName), content, 'utf8');
|
||||
await git.stageAll();
|
||||
const made = await git.commit('add LF file', {
|
||||
authorName: BOT_AUTHOR_NAME,
|
||||
authorEmail: BOT_AUTHOR_EMAIL,
|
||||
});
|
||||
expect(made).toBe(true);
|
||||
|
||||
// Read the STORED blob (not the worktree file) and assert verbatim bytes:
|
||||
// still LF-only, no CRLF translation.
|
||||
const { stdout: stored } = await execFileAsync(
|
||||
'git',
|
||||
['--no-pager', 'show', `HEAD:${fileName}`],
|
||||
{ cwd: vault, encoding: 'buffer' },
|
||||
);
|
||||
const storedBuf = stored as unknown as Buffer;
|
||||
expect(storedBuf.includes(Buffer.from('\r\n'))).toBe(false);
|
||||
expect(storedBuf.toString('utf8')).toBe(content);
|
||||
});
|
||||
|
||||
it('ensureBranch creates the docmost branch from main', async () => {
|
||||
if (!available) return;
|
||||
const vault = await freshDir();
|
||||
const git = new VaultGit(vault);
|
||||
await git.ensureRepo();
|
||||
|
||||
expect(await git.branchExists('docmost')).toBe(false);
|
||||
await git.ensureBranch('docmost', 'main');
|
||||
expect(await git.branchExists('docmost')).toBe(true);
|
||||
|
||||
// Idempotent.
|
||||
await git.ensureBranch('docmost', 'main');
|
||||
expect(await git.branchExists('docmost')).toBe(true);
|
||||
});
|
||||
|
||||
it('commit writes a commit with the provenance trailer and the bot identity', async () => {
|
||||
if (!available) return;
|
||||
const vault = await freshDir();
|
||||
const git = new VaultGit(vault);
|
||||
await git.ensureRepo();
|
||||
|
||||
await writeFile(join(vault, 'page.md'), 'hello\n', 'utf8');
|
||||
await git.stageAll();
|
||||
const made = await git.commit('docmost: sync 1 page(s)', {
|
||||
authorName: BOT_AUTHOR_NAME,
|
||||
authorEmail: BOT_AUTHOR_EMAIL,
|
||||
trailers: ['Docmost-Sync-Source: docmost'],
|
||||
});
|
||||
expect(made).toBe(true);
|
||||
|
||||
const msg = await headMessage(vault);
|
||||
expect(msg).toContain('docmost: sync 1 page(s)');
|
||||
expect(msg).toContain('Docmost-Sync-Source: docmost');
|
||||
|
||||
const author = await headAuthor(vault);
|
||||
expect(author).toBe(`${BOT_AUTHOR_NAME} <${BOT_AUTHOR_EMAIL}>`);
|
||||
|
||||
// The trailer is parseable by git itself.
|
||||
const { stdout: trailers } = await execFileAsync(
|
||||
'git',
|
||||
['--no-pager', 'log', '-1', '--pretty=%(trailers:key=Docmost-Sync-Source,valueonly)'],
|
||||
{ cwd: vault },
|
||||
);
|
||||
expect(trailers.trim()).toBe('docmost');
|
||||
});
|
||||
|
||||
it('commit is a no-op when there is nothing to commit', async () => {
|
||||
if (!available) return;
|
||||
const vault = await freshDir();
|
||||
const git = new VaultGit(vault);
|
||||
await git.ensureRepo();
|
||||
|
||||
await git.stageAll(); // nothing changed since the init commit
|
||||
const made = await git.commit('docmost: sync 0 page(s)', {
|
||||
authorName: BOT_AUTHOR_NAME,
|
||||
authorEmail: BOT_AUTHOR_EMAIL,
|
||||
trailers: ['Docmost-Sync-Source: docmost'],
|
||||
});
|
||||
expect(made).toBe(false);
|
||||
|
||||
// Still exactly one commit (the init one).
|
||||
const { stdout: count } = await execFileAsync(
|
||||
'git',
|
||||
['rev-list', '--count', 'HEAD'],
|
||||
{ cwd: vault },
|
||||
);
|
||||
expect(count.trim()).toBe('1');
|
||||
});
|
||||
|
||||
it('commit honors --no-verify (a failing pre-commit hook does not block it)', async () => {
|
||||
if (!available) return;
|
||||
const vault = await freshDir();
|
||||
const git = new VaultGit(vault);
|
||||
await git.ensureRepo();
|
||||
|
||||
// Commit count BEFORE: just the init commit.
|
||||
const countBefore = async (): Promise<number> => {
|
||||
const { stdout } = await execFileAsync(
|
||||
'git',
|
||||
['rev-list', '--count', 'HEAD'],
|
||||
{ cwd: vault },
|
||||
);
|
||||
return Number(stdout.trim());
|
||||
};
|
||||
const before = await countBefore();
|
||||
|
||||
// Install an EXECUTABLE pre-commit hook that always fails. Without
|
||||
// `--no-verify`, `git commit` would run it, the hook would `exit 1`, and the
|
||||
// commit would be ABORTED. So this test fails (no commit created, made !==
|
||||
// true) the moment `--no-verify` is removed from commitRaw.
|
||||
const hookPath = join(vault, '.git', 'hooks', 'pre-commit');
|
||||
await writeFile(hookPath, '#!/bin/sh\nexit 1\n', 'utf8');
|
||||
await chmod(hookPath, 0o755);
|
||||
|
||||
await writeFile(join(vault, 'hooked.md'), 'content\n', 'utf8');
|
||||
await git.stageAll();
|
||||
const made = await git.commit('commit past a failing hook', {
|
||||
authorName: BOT_AUTHOR_NAME,
|
||||
authorEmail: BOT_AUTHOR_EMAIL,
|
||||
trailers: ['Docmost-Sync-Source: docmost'],
|
||||
});
|
||||
|
||||
// The commit was reported made AND actually landed (HEAD advanced by one).
|
||||
expect(made).toBe(true);
|
||||
expect(await countBefore()).toBe(before + 1);
|
||||
expect(await headMessage(vault)).toContain('commit past a failing hook');
|
||||
});
|
||||
|
||||
it('merge fast-forwards main to docmost', async () => {
|
||||
if (!available) return;
|
||||
const vault = await freshDir();
|
||||
const git = new VaultGit(vault);
|
||||
await git.ensureRepo();
|
||||
await git.ensureBranch('docmost', 'main');
|
||||
|
||||
// Commit a file on docmost.
|
||||
await git.checkout('docmost');
|
||||
await writeFile(join(vault, 'a.md'), 'a\n', 'utf8');
|
||||
await git.stageAll();
|
||||
await git.commit('docmost: sync 1 page(s)', {
|
||||
authorName: BOT_AUTHOR_NAME,
|
||||
authorEmail: BOT_AUTHOR_EMAIL,
|
||||
trailers: ['Docmost-Sync-Source: docmost'],
|
||||
});
|
||||
|
||||
// main has not diverged, so the merge is a clean fast-forward.
|
||||
await git.checkout('main');
|
||||
const res = await git.merge('docmost');
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.conflict).toBe(false);
|
||||
|
||||
// main now contains the file and the docmost commit.
|
||||
const tracked = await git.listTrackedFiles();
|
||||
expect(tracked).toContain('a.md');
|
||||
expect(await headMessage(vault)).toContain('docmost: sync 1 page(s)');
|
||||
});
|
||||
|
||||
it('merge surfaces a conflict distinctly (no auto-resolve)', async () => {
|
||||
if (!available) return;
|
||||
const vault = await freshDir();
|
||||
const git = new VaultGit(vault);
|
||||
await git.ensureRepo();
|
||||
await git.ensureBranch('docmost', 'main');
|
||||
|
||||
// Divergent edits to the SAME file on both branches -> real conflict.
|
||||
await git.checkout('docmost');
|
||||
await writeFile(join(vault, 'c.md'), 'from docmost\n', 'utf8');
|
||||
await git.stageAll();
|
||||
await git.commit('docmost edit', {
|
||||
authorName: BOT_AUTHOR_NAME,
|
||||
authorEmail: BOT_AUTHOR_EMAIL,
|
||||
});
|
||||
|
||||
await git.checkout('main');
|
||||
await writeFile(join(vault, 'c.md'), 'from main\n', 'utf8');
|
||||
await git.stageAll();
|
||||
await git.commit('main edit', {
|
||||
authorName: 'Human',
|
||||
authorEmail: 'human@local',
|
||||
});
|
||||
|
||||
const res = await git.merge('docmost');
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.conflict).toBe(true);
|
||||
});
|
||||
|
||||
it('isMergeInProgress is false on a clean repo and true mid-merge', async () => {
|
||||
if (!available) return;
|
||||
const vault = await freshDir();
|
||||
const git = new VaultGit(vault);
|
||||
await git.ensureRepo();
|
||||
await git.ensureBranch('docmost', 'main');
|
||||
|
||||
// Clean repo, no merge in progress.
|
||||
expect(await git.isMergeInProgress()).toBe(false);
|
||||
|
||||
// Create a REAL conflict: divergent edits to the same file on both branches.
|
||||
await git.checkout('docmost');
|
||||
await writeFile(join(vault, 'c.md'), 'from docmost\n', 'utf8');
|
||||
await git.stageAll();
|
||||
await git.commit('docmost edit', {
|
||||
authorName: BOT_AUTHOR_NAME,
|
||||
authorEmail: BOT_AUTHOR_EMAIL,
|
||||
});
|
||||
|
||||
await git.checkout('main');
|
||||
await writeFile(join(vault, 'c.md'), 'from main\n', 'utf8');
|
||||
await git.stageAll();
|
||||
await git.commit('main edit', {
|
||||
authorName: 'Human',
|
||||
authorEmail: 'human@local',
|
||||
});
|
||||
|
||||
// Merge conflicts -> the repo is now left mid-merge.
|
||||
const res = await git.merge('docmost');
|
||||
expect(res.conflict).toBe(true);
|
||||
expect(await git.isMergeInProgress()).toBe(true);
|
||||
|
||||
// Aborting the merge clears the in-progress state again.
|
||||
await execFileAsync('git', ['--no-pager', 'merge', '--abort'], { cwd: vault });
|
||||
expect(await git.isMergeInProgress()).toBe(false);
|
||||
});
|
||||
|
||||
it('listTrackedFiles supports a glob and returns forward-slash paths', async () => {
|
||||
if (!available) return;
|
||||
const vault = await freshDir();
|
||||
const git = new VaultGit(vault);
|
||||
await git.ensureRepo();
|
||||
|
||||
await writeFile(join(vault, 'keep.md'), 'k\n', 'utf8');
|
||||
await writeFile(join(vault, 'note.txt'), 't\n', 'utf8');
|
||||
await git.stageAll();
|
||||
await git.commit('add files', {
|
||||
authorName: BOT_AUTHOR_NAME,
|
||||
authorEmail: BOT_AUTHOR_EMAIL,
|
||||
});
|
||||
|
||||
const md = await git.listTrackedFiles('*.md');
|
||||
expect(md).toEqual(['keep.md']);
|
||||
const all = await git.listTrackedFiles();
|
||||
expect(new Set(all)).toEqual(new Set(['keep.md', 'note.txt']));
|
||||
});
|
||||
|
||||
it('listTrackedFiles returns RAW UTF-8 Cyrillic paths (not octal-escaped/quoted)', async () => {
|
||||
if (!available) return;
|
||||
const vault = await freshDir();
|
||||
const git = new VaultGit(vault);
|
||||
await git.ensureRepo();
|
||||
|
||||
// The target wiki is Russian, so file names contain Cyrillic. With git's
|
||||
// DEFAULT core.quotepath=true these come back as `"\320\232..."` from
|
||||
// ls-files; `listTrackedFiles` must return them verbatim as UTF-8.
|
||||
const topName = 'Колонка.md';
|
||||
const nestedDir = 'Раздел';
|
||||
const nestedName = 'Подстраница.md';
|
||||
await writeFile(join(vault, topName), 'top\n', 'utf8');
|
||||
await mkdir(join(vault, nestedDir), { recursive: true });
|
||||
await writeFile(join(vault, nestedDir, nestedName), 'nested\n', 'utf8');
|
||||
await git.stageAll();
|
||||
await git.commit('add cyrillic files', {
|
||||
authorName: BOT_AUTHOR_NAME,
|
||||
authorEmail: BOT_AUTHOR_EMAIL,
|
||||
});
|
||||
|
||||
const md = await git.listTrackedFiles('*.md');
|
||||
// Exact UTF-8 names, forward-slash separated for the nested one — NOT an
|
||||
// escaped/quoted form like `"\320\232..."`.
|
||||
expect(new Set(md)).toEqual(
|
||||
new Set([topName, `${nestedDir}/${nestedName}`]),
|
||||
);
|
||||
// Guard explicitly against the quotepath regression: no entry is quoted or
|
||||
// contains a backslash escape sequence.
|
||||
for (const p of md) {
|
||||
expect(p.startsWith('"')).toBe(false);
|
||||
expect(p.includes('\\')).toBe(false);
|
||||
}
|
||||
|
||||
// No-glob listing also returns the raw Cyrillic names.
|
||||
const all = await git.listTrackedFiles();
|
||||
expect(all).toContain(topName);
|
||||
expect(all).toContain(`${nestedDir}/${nestedName}`);
|
||||
});
|
||||
|
||||
it('assertGitAvailable resolves when git is present', async () => {
|
||||
if (!available) return;
|
||||
const vault = await freshDir();
|
||||
const git = new VaultGit(vault);
|
||||
// No repo needed: it only probes `git --version` (and the vault dir need
|
||||
// not even exist yet).
|
||||
await expect(git.assertGitAvailable()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
// --- Push-direction primitives (SPEC §6 "ФС → Docmost", FIRST increment) ---
|
||||
|
||||
it('diffNameStatus parses A / M / D rows between two commits', async () => {
|
||||
if (!available) return;
|
||||
const vault = await freshDir();
|
||||
const git = new VaultGit(vault);
|
||||
await git.ensureRepo();
|
||||
|
||||
// Commit 1: two files (keep.md will be modified, gone.md will be deleted).
|
||||
await writeFile(join(vault, 'keep.md'), 'v1\n', 'utf8');
|
||||
await writeFile(join(vault, 'gone.md'), 'old\n', 'utf8');
|
||||
await git.stageAll();
|
||||
await git.commit('base', { authorName: BOT_AUTHOR_NAME, authorEmail: BOT_AUTHOR_EMAIL });
|
||||
const base = await git.revParse('HEAD');
|
||||
expect(base).toBeTruthy();
|
||||
|
||||
// Commit 2: modify keep.md, add fresh.md, delete gone.md.
|
||||
await writeFile(join(vault, 'keep.md'), 'v2\n', 'utf8');
|
||||
await writeFile(join(vault, 'fresh.md'), 'new\n', 'utf8');
|
||||
await rm(join(vault, 'gone.md'));
|
||||
await git.stageAll();
|
||||
await git.commit('change', { authorName: BOT_AUTHOR_NAME, authorEmail: BOT_AUTHOR_EMAIL });
|
||||
|
||||
const entries = await git.diffNameStatus(base!, 'HEAD');
|
||||
// Sort for deterministic assertion regardless of git's row order.
|
||||
const byPath = new Map(entries.map((e) => [e.path, e]));
|
||||
expect(byPath.get('keep.md')).toEqual({ status: 'M', path: 'keep.md' });
|
||||
expect(byPath.get('fresh.md')).toEqual({ status: 'A', path: 'fresh.md' });
|
||||
expect(byPath.get('gone.md')).toEqual({ status: 'D', path: 'gone.md' });
|
||||
expect(entries.length).toBe(3);
|
||||
});
|
||||
|
||||
it('diffNameStatus parses a real rename (R) with old + new path', async () => {
|
||||
if (!available) return;
|
||||
const vault = await freshDir();
|
||||
const git = new VaultGit(vault);
|
||||
await git.ensureRepo();
|
||||
|
||||
// A file with enough content that git's -M rename detection ties the rename
|
||||
// to the same blob (identical content -> R100).
|
||||
const body = 'line a\nline b\nline c\nline d\n';
|
||||
await writeFile(join(vault, 'old-name.md'), body, 'utf8');
|
||||
await git.stageAll();
|
||||
await git.commit('add', { authorName: BOT_AUTHOR_NAME, authorEmail: BOT_AUTHOR_EMAIL });
|
||||
const base = await git.revParse('HEAD');
|
||||
|
||||
// Rename it (same content) so -M detects a rename, not delete+add.
|
||||
await rm(join(vault, 'old-name.md'));
|
||||
await writeFile(join(vault, 'new-name.md'), body, 'utf8');
|
||||
await git.stageAll();
|
||||
await git.commit('rename', { authorName: BOT_AUTHOR_NAME, authorEmail: BOT_AUTHOR_EMAIL });
|
||||
|
||||
const entries = await git.diffNameStatus(base!, 'HEAD');
|
||||
expect(entries.length).toBe(1);
|
||||
const r = entries[0];
|
||||
expect(r.status).toBe('R');
|
||||
expect(r.oldPath).toBe('old-name.md');
|
||||
expect(r.path).toBe('new-name.md');
|
||||
// Identical content -> a 100% similarity score.
|
||||
expect(r.score).toBe(100);
|
||||
});
|
||||
|
||||
it('diffNameStatus returns RAW UTF-8 Cyrillic paths (no quoting)', async () => {
|
||||
if (!available) return;
|
||||
const vault = await freshDir();
|
||||
const git = new VaultGit(vault);
|
||||
await git.ensureRepo();
|
||||
|
||||
const base = await git.revParse('HEAD');
|
||||
await writeFile(join(vault, 'Статья.md'), 'тело\n', 'utf8');
|
||||
await git.stageAll();
|
||||
await git.commit('add cyrillic', { authorName: BOT_AUTHOR_NAME, authorEmail: BOT_AUTHOR_EMAIL });
|
||||
|
||||
const entries = await git.diffNameStatus(base!, 'HEAD');
|
||||
expect(entries).toEqual([{ status: 'A', path: 'Статья.md' }]);
|
||||
});
|
||||
|
||||
it('revParse / readRef resolve a ref to a SHA, null when missing', async () => {
|
||||
if (!available) return;
|
||||
const vault = await freshDir();
|
||||
const git = new VaultGit(vault);
|
||||
await git.ensureRepo();
|
||||
|
||||
const head = await git.revParse('HEAD');
|
||||
expect(head).toMatch(/^[0-9a-f]{40}$/);
|
||||
// A non-existent ref resolves to null (not a throw).
|
||||
expect(await git.revParse('refs/docmost/last-pushed')).toBeNull();
|
||||
expect(await git.readRef('refs/docmost/last-pushed')).toBeNull();
|
||||
});
|
||||
|
||||
it('updateRef / readRef round-trip a custom ref', async () => {
|
||||
if (!available) return;
|
||||
const vault = await freshDir();
|
||||
const git = new VaultGit(vault);
|
||||
await git.ensureRepo();
|
||||
|
||||
const head = await git.revParse('HEAD');
|
||||
expect(await git.readRef('refs/docmost/last-pushed')).toBeNull();
|
||||
|
||||
await git.updateRef('refs/docmost/last-pushed', head!);
|
||||
// It now resolves to the same SHA as HEAD.
|
||||
expect(await git.readRef('refs/docmost/last-pushed')).toBe(head);
|
||||
expect(await git.revParse('refs/docmost/last-pushed')).toBe(head);
|
||||
});
|
||||
|
||||
it('showFileAtRef returns a committed file content and null for a missing path', async () => {
|
||||
if (!available) return;
|
||||
const vault = await freshDir();
|
||||
const git = new VaultGit(vault);
|
||||
await git.ensureRepo();
|
||||
|
||||
const content = 'hello at ref\nsecond line\n';
|
||||
await writeFile(join(vault, 'doc.md'), content, 'utf8');
|
||||
await git.stageAll();
|
||||
await git.commit('add doc', { authorName: BOT_AUTHOR_NAME, authorEmail: BOT_AUTHOR_EMAIL });
|
||||
|
||||
// The committed file is readable at HEAD verbatim.
|
||||
expect(await git.showFileAtRef('HEAD', 'doc.md')).toBe(content);
|
||||
// A path that does not exist at that ref maps to null (not a throw).
|
||||
expect(await git.showFileAtRef('HEAD', 'nope.md')).toBeNull();
|
||||
});
|
||||
|
||||
it('showFileAtRef reads a DELETED file pre-image at an earlier ref', async () => {
|
||||
if (!available) return;
|
||||
const vault = await freshDir();
|
||||
const git = new VaultGit(vault);
|
||||
await git.ensureRepo();
|
||||
|
||||
// Commit a tracked page, capture the ref, then delete it.
|
||||
const meta =
|
||||
'<!-- docmost:meta\n{"version":1,"pageId":"page-123"}\n-->\n\nbody\n';
|
||||
await writeFile(join(vault, 'tracked.md'), meta, 'utf8');
|
||||
await git.stageAll();
|
||||
await git.commit('add tracked', { authorName: BOT_AUTHOR_NAME, authorEmail: BOT_AUTHOR_EMAIL });
|
||||
const beforeDelete = await git.revParse('HEAD');
|
||||
|
||||
await rm(join(vault, 'tracked.md'));
|
||||
await git.stageAll();
|
||||
await git.commit('delete tracked', { authorName: BOT_AUTHOR_NAME, authorEmail: BOT_AUTHOR_EMAIL });
|
||||
|
||||
// The pre-image (pageId) is recoverable at the earlier ref even though the
|
||||
// file is gone from HEAD — this is how the push direction recovers the
|
||||
// pageId of a deleted file (SPEC §6/§8).
|
||||
expect(await git.showFileAtRef('HEAD', 'tracked.md')).toBeNull();
|
||||
const preImage = await git.showFileAtRef(beforeDelete!, 'tracked.md');
|
||||
expect(preImage).toBe(meta);
|
||||
expect(preImage).toContain('page-123');
|
||||
});
|
||||
|
||||
it('fastForwardBranch advances a true fast-forward (the loop-close, SPEC §6 step 3)', async () => {
|
||||
if (!available) return;
|
||||
const vault = await freshDir();
|
||||
const git = new VaultGit(vault);
|
||||
await git.ensureRepo();
|
||||
|
||||
// docmost branches off main at the initial commit; main then moves ahead.
|
||||
await git.ensureBranch('docmost', 'main');
|
||||
const base = await git.revParse('refs/heads/docmost');
|
||||
|
||||
await writeFile(join(vault, 'page.md'), 'pushed content\n', 'utf8');
|
||||
await git.stageAll();
|
||||
await git.commit('push page', { authorName: BOT_AUTHOR_NAME, authorEmail: BOT_AUTHOR_EMAIL });
|
||||
const mainTip = await git.revParse('HEAD');
|
||||
|
||||
// docmost is BEHIND main and an ancestor -> a true fast-forward advances it.
|
||||
expect(await git.revParse('refs/heads/docmost')).toBe(base);
|
||||
const res = await git.fastForwardBranch('docmost', mainTip!);
|
||||
expect(res).toEqual({ ok: true });
|
||||
// The branch now points at the pushed main commit (mirror reflects Docmost).
|
||||
expect(await git.revParse('refs/heads/docmost')).toBe(mainTip);
|
||||
|
||||
// It does NOT touch the working tree / current branch (still on main).
|
||||
expect(await git.currentBranch()).toBe('main');
|
||||
});
|
||||
|
||||
it('fastForwardBranch is a no-op (ok) when the branch is already at the target', async () => {
|
||||
if (!available) return;
|
||||
const vault = await freshDir();
|
||||
const git = new VaultGit(vault);
|
||||
await git.ensureRepo();
|
||||
await git.ensureBranch('docmost', 'main');
|
||||
const mainTip = await git.revParse('HEAD');
|
||||
|
||||
// Already equal -> a degenerate fast-forward, still ok, branch unchanged.
|
||||
const res = await git.fastForwardBranch('docmost', mainTip!);
|
||||
expect(res).toEqual({ ok: true });
|
||||
expect(await git.revParse('refs/heads/docmost')).toBe(mainTip);
|
||||
});
|
||||
|
||||
it('fastForwardBranch REFUSES a non-fast-forward (never clobbers divergent history)', async () => {
|
||||
if (!available) return;
|
||||
const vault = await freshDir();
|
||||
const git = new VaultGit(vault);
|
||||
await git.ensureRepo();
|
||||
|
||||
// Make docmost diverge: it has a commit that main does NOT contain.
|
||||
await git.checkout('main'); // ensure on main first
|
||||
await git.ensureBranch('docmost', 'main');
|
||||
await git.checkout('docmost');
|
||||
await writeFile(join(vault, 'only-on-docmost.md'), 'mirror-only\n', 'utf8');
|
||||
await git.stageAll();
|
||||
await git.commit('docmost-only commit', { authorName: BOT_AUTHOR_NAME, authorEmail: BOT_AUTHOR_EMAIL });
|
||||
const docmostTip = await git.revParse('refs/heads/docmost');
|
||||
|
||||
// main moves ahead independently (divergent from docmost).
|
||||
await git.checkout('main');
|
||||
await writeFile(join(vault, 'only-on-main.md'), 'main-only\n', 'utf8');
|
||||
await git.stageAll();
|
||||
await git.commit('main-only commit', { authorName: BOT_AUTHOR_NAME, authorEmail: BOT_AUTHOR_EMAIL });
|
||||
const mainTip = await git.revParse('HEAD');
|
||||
|
||||
// docmost is NOT an ancestor of main -> the ff is REFUSED, branch untouched.
|
||||
const res = await git.fastForwardBranch('docmost', mainTip!);
|
||||
expect(res).toEqual({ ok: false, reason: 'not-fast-forward' });
|
||||
expect(await git.revParse('refs/heads/docmost')).toBe(docmostTip);
|
||||
});
|
||||
|
||||
it('fastForwardBranch refuses a missing branch / unresolved target with a reason', async () => {
|
||||
if (!available) return;
|
||||
const vault = await freshDir();
|
||||
const git = new VaultGit(vault);
|
||||
await git.ensureRepo();
|
||||
const mainTip = await git.revParse('HEAD');
|
||||
|
||||
const noBranch = await git.fastForwardBranch('nope', mainTip!);
|
||||
expect(noBranch.ok).toBe(false);
|
||||
expect(noBranch.reason).toContain('nope');
|
||||
|
||||
await git.ensureBranch('docmost', 'main');
|
||||
const noTarget = await git.fastForwardBranch('docmost', 'deadbeefdeadbeef');
|
||||
expect(noTarget.ok).toBe(false);
|
||||
expect(noTarget.reason).toContain('deadbeefdeadbeef');
|
||||
});
|
||||
});
|
||||
120
packages/git-sync/test/read-existing.test.ts
Normal file
120
packages/git-sync/test/read-existing.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readExisting } from '../src/engine/pull';
|
||||
|
||||
// R-Pull-1 (test-strategy report §5): `readExisting` now takes injectable IO
|
||||
// (`listTracked` / `readFile`), so its parsing + skip rules are unit-testable
|
||||
// without a real git repo or filesystem. These tests pass fakes only — no git,
|
||||
// no fs, no network.
|
||||
|
||||
/** Build a valid self-contained file with a `docmost:meta` block. */
|
||||
function withMeta(meta: Record<string, unknown>, body = '# Title\nbody\n'): string {
|
||||
return `<!-- docmost:meta\n${JSON.stringify(meta)}\n-->\n\n${body}`;
|
||||
}
|
||||
|
||||
/** A fake `readFile` backed by an in-memory map (rejects on a missing key). */
|
||||
function fakeReadFile(files: Record<string, string>) {
|
||||
return async (rel: string): Promise<string> => {
|
||||
if (!(rel in files)) {
|
||||
throw Object.assign(new Error(`ENOENT: ${rel}`), { code: 'ENOENT' });
|
||||
}
|
||||
return files[rel];
|
||||
};
|
||||
}
|
||||
|
||||
describe('readExisting (R-Pull-1, injected IO)', () => {
|
||||
it('recovers { pageId, relPath } for valid tracked files', async () => {
|
||||
const files = {
|
||||
'Space/A.md': withMeta({ version: 1, pageId: 'p1', title: 'A' }),
|
||||
'Space/Sub/B.md': withMeta({ version: 1, pageId: 'p2', title: 'B' }),
|
||||
};
|
||||
const result = await readExisting({
|
||||
listTracked: async () => Object.keys(files),
|
||||
readFile: fakeReadFile(files),
|
||||
});
|
||||
expect(result).toEqual([
|
||||
{ pageId: 'p1', relPath: 'Space/A.md' },
|
||||
{ pageId: 'p2', relPath: 'Space/Sub/B.md' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('SKIPS a file with no docmost:meta block (plain hand-written markdown)', async () => {
|
||||
const files = {
|
||||
'tracked.md': withMeta({ version: 1, pageId: 'p1' }),
|
||||
'stray.md': '# Just a hand-written note\n\nNo meta here.\n',
|
||||
};
|
||||
const result = await readExisting({
|
||||
listTracked: async () => Object.keys(files),
|
||||
readFile: fakeReadFile(files),
|
||||
});
|
||||
// Only the engine-tracked file (with a pageId) survives.
|
||||
expect(result).toEqual([{ pageId: 'p1', relPath: 'tracked.md' }]);
|
||||
});
|
||||
|
||||
it('SKIPS a file whose meta has no pageId', async () => {
|
||||
const files = {
|
||||
'has-id.md': withMeta({ version: 1, pageId: 'keep' }),
|
||||
'no-id.md': withMeta({ version: 1, title: 'untitled', slugId: 's' }),
|
||||
};
|
||||
const result = await readExisting({
|
||||
listTracked: async () => Object.keys(files),
|
||||
readFile: fakeReadFile(files),
|
||||
});
|
||||
expect(result).toEqual([{ pageId: 'keep', relPath: 'has-id.md' }]);
|
||||
});
|
||||
|
||||
it('SKIPS a file with an unparseable (invalid-JSON) meta block, does not throw', async () => {
|
||||
// Invalid JSON inside the meta block makes parseDocmostMarkdown throw; the
|
||||
// skip-rule must swallow it and treat the file as not-engine-tracked.
|
||||
const files = {
|
||||
'good.md': withMeta({ version: 1, pageId: 'good' }),
|
||||
'broken.md': '<!-- docmost:meta\n{ this is : not, json }\n-->\n\nbody\n',
|
||||
};
|
||||
const result = await readExisting({
|
||||
listTracked: async () => Object.keys(files),
|
||||
readFile: fakeReadFile(files),
|
||||
});
|
||||
expect(result).toEqual([{ pageId: 'good', relPath: 'good.md' }]);
|
||||
});
|
||||
|
||||
it('does NOT throw when readFile REJECTS (tracked but missing) — treats it as skipped', async () => {
|
||||
const files = {
|
||||
'present.md': withMeta({ version: 1, pageId: 'present' }),
|
||||
// "ghost.md" is listed as tracked but absent from the file map -> reject.
|
||||
};
|
||||
const result = await readExisting({
|
||||
listTracked: async () => ['present.md', 'ghost.md'],
|
||||
readFile: fakeReadFile(files),
|
||||
});
|
||||
// The rejection is swallowed; the present file still comes through.
|
||||
expect(result).toEqual([{ pageId: 'present', relPath: 'present.md' }]);
|
||||
});
|
||||
|
||||
it('returns an empty list when nothing is tracked', async () => {
|
||||
const result = await readExisting({
|
||||
listTracked: async () => [],
|
||||
readFile: async () => {
|
||||
throw new Error('should not be called');
|
||||
},
|
||||
});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('combines all skip rules in one listing (only the valid files survive)', async () => {
|
||||
const files = {
|
||||
'ok1.md': withMeta({ version: 1, pageId: 'a' }),
|
||||
'no-meta.md': 'plain\n',
|
||||
'no-id.md': withMeta({ version: 1, title: 'x' }),
|
||||
'broken.md': '<!-- docmost:meta\n{bad\n-->\nbody\n',
|
||||
'ok2.md': withMeta({ version: 1, pageId: 'b' }),
|
||||
// missing.md rejects on read.
|
||||
};
|
||||
const result = await readExisting({
|
||||
listTracked: async () => [...Object.keys(files), 'missing.md'],
|
||||
readFile: fakeReadFile(files),
|
||||
});
|
||||
expect(result).toEqual([
|
||||
{ pageId: 'a', relPath: 'ok1.md' },
|
||||
{ pageId: 'b', relPath: 'ok2.md' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
142
packages/git-sync/test/run-push-realgit.test.ts
Normal file
142
packages/git-sync/test/run-push-realgit.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
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, vi } from 'vitest';
|
||||
import { runPush, LAST_PUSHED_REF } from '../src/engine/push';
|
||||
import type { PushDeps } from '../src/engine/push';
|
||||
import { VaultGit } from '../src/engine/git';
|
||||
import type { Settings } from '../src/engine/settings';
|
||||
import { serializeDocmostMarkdownBody } from '../src/lib/index';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// runPush `--apply` against a REAL VaultGit in a temp repo (NO Docmost — the
|
||||
// client is faked). This guards the real-git BINDING contract that the plain-
|
||||
// object git fakes in run-push.test.ts cannot catch: the applier's git deps
|
||||
// (`updateRef`/`fastForwardBranch`/`showFileAtRef`) call `this.run`/`this.runRaw`
|
||||
// internally, so they only work when their `this` receiver is preserved. Passing
|
||||
// bare method references (`git.updateRef`, …) would throw `this.runRaw is not a
|
||||
// function` here. Only the LOCAL temp git is mutated; nothing is sent to Docmost.
|
||||
|
||||
/** True if a usable `git` binary is on PATH (skip the suite otherwise). */
|
||||
async function gitAvailable(): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync('git', ['--version']);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** A minimal valid Settings fixture (only fields runPush reads matter). */
|
||||
function makeSettings(vaultPath: string): Settings {
|
||||
return {
|
||||
docmostApiUrl: 'https://docmost.example.com',
|
||||
docmostEmail: 'you@example.com',
|
||||
docmostPassword: 'secret',
|
||||
docmostSpaceId: 'space-1',
|
||||
vaultPath,
|
||||
pollIntervalMs: 15000,
|
||||
debounceMs: 2000,
|
||||
logLevel: 'info',
|
||||
};
|
||||
}
|
||||
|
||||
/** A recording client fake; createPage returns an assigned id + updatedAt. */
|
||||
function makeClientFake() {
|
||||
return {
|
||||
importPageMarkdown: vi.fn(async () => ({
|
||||
data: { updatedAt: '2026-06-20T00:00:00.000Z' },
|
||||
success: true,
|
||||
})),
|
||||
createPage: vi.fn(async (title: string) => ({
|
||||
data: { id: 'new-id', title, updatedAt: '2026-06-20T00:00:00.000Z' },
|
||||
success: true,
|
||||
})),
|
||||
deletePage: vi.fn(async () => ({ success: true })),
|
||||
movePage: vi.fn(async () => ({ success: true })),
|
||||
renamePage: vi.fn(async () => ({ success: true })),
|
||||
};
|
||||
}
|
||||
|
||||
describe('runPush --apply against a REAL VaultGit (binding contract)', () => {
|
||||
let available = false;
|
||||
let dir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
available = await gitAvailable();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (dir) {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('writes through real git: createPage runs, last-pushed advances, no throw', async () => {
|
||||
if (!available) return; // skip gracefully when git is unavailable
|
||||
|
||||
// Temp vault repo under the OS tmpdir (mirrors test/git.test.ts setup).
|
||||
dir = await mkdtemp(join(tmpdir(), 'docmost-push-realgit-'));
|
||||
const vault = dir;
|
||||
const git = new VaultGit(vault);
|
||||
await git.ensureRepo();
|
||||
// The `docmost` mirror branches off `main` at the initial commit; this is
|
||||
// also the diff base (last-pushed is unset, so runPush falls back to it).
|
||||
await git.ensureBranch('docmost', 'main');
|
||||
|
||||
// A brand-new local file with meta carrying title + spaceId but NO pageId,
|
||||
// committed on `main` AHEAD of the base -> computePushActions yields a CREATE.
|
||||
const newFile = serializeDocmostMarkdownBody(
|
||||
{ version: 1, title: 'New', spaceId: 'sp-1' },
|
||||
'fresh body',
|
||||
);
|
||||
await writeFile(join(vault, 'New.md'), newFile, 'utf8');
|
||||
await git.stageAll();
|
||||
await git.commit('add New.md', {
|
||||
authorName: 'Human',
|
||||
authorEmail: 'human@local',
|
||||
});
|
||||
|
||||
// last-pushed must be UNSET so the run actually advances it for the first time.
|
||||
expect(await git.revParse(LAST_PUSHED_REF)).toBeNull();
|
||||
|
||||
const client = makeClientFake();
|
||||
const logs: string[] = [];
|
||||
const deps: PushDeps = {
|
||||
settings: makeSettings(vault),
|
||||
// The WHOLE real VaultGit — its methods must keep their `this` binding.
|
||||
git,
|
||||
makeClient: () => client as any,
|
||||
readFile: (path) =>
|
||||
import('node:fs/promises').then((fs) =>
|
||||
fs.readFile(join(vault, ...path.split('/')), 'utf8'),
|
||||
),
|
||||
writeFile: async (path, text) => {
|
||||
const fs = await import('node:fs/promises');
|
||||
await fs.writeFile(join(vault, ...path.split('/')), text, 'utf8');
|
||||
},
|
||||
log: (line) => logs.push(line),
|
||||
};
|
||||
|
||||
// The run must NOT throw — this is what FAILS before Fix 1 (the bare-method
|
||||
// git deps would throw `this.runRaw is not a function` on the real VaultGit).
|
||||
const res = await runPush(deps, { dryRun: false });
|
||||
|
||||
expect(res.mode).toBe('apply');
|
||||
expect(res.failures).toEqual([]);
|
||||
// The FAKE client was actually called (the write path ran).
|
||||
expect(client.createPage).toHaveBeenCalledTimes(1);
|
||||
expect(res.applied?.created).toBe(1);
|
||||
// The assigned pageId was written back to disk + committed.
|
||||
expect(res.applied?.writtenBack).toEqual([{ path: 'New.md', pageId: 'new-id' }]);
|
||||
|
||||
// CRITICALLY: refs/docmost/last-pushed ACTUALLY advanced in the real repo —
|
||||
// it now resolves to a real commit (proving updateRef ran with binding).
|
||||
const lastPushed = await git.revParse(LAST_PUSHED_REF);
|
||||
expect(lastPushed).toMatch(/^[0-9a-f]{40}$/);
|
||||
expect(res.divergentDocmost).toBe(false);
|
||||
});
|
||||
});
|
||||
398
packages/git-sync/test/run-push.test.ts
Normal file
398
packages/git-sync/test/run-push.test.ts
Normal file
@@ -0,0 +1,398 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { runPush, LAST_PUSHED_REF, DOCMOST_BRANCH } from '../src/engine/push';
|
||||
import type { PushDeps } from '../src/engine/push';
|
||||
import type { Settings } from '../src/engine/settings';
|
||||
import { serializeDocmostMarkdownBody } from '../src/lib/index';
|
||||
|
||||
// runPush orchestration (SPEC §6 "ФС → Docmost"), DRY-RUN BY DEFAULT. Driven by
|
||||
// FAKES only — no live Docmost, git, fs, or network. Asserts the SAFE-BY-DEFAULT
|
||||
// contract: a dry-run builds NO client, makes ZERO Docmost calls, advances NO
|
||||
// refs; `--apply` is the ONLY path that writes. Also covers the merge-in-progress
|
||||
// abort, the divergent-`docmost` escalation, and the base selection fallback.
|
||||
|
||||
/** A minimal valid Settings fixture (only fields runPush reads matter). */
|
||||
function makeSettings(): Settings {
|
||||
return {
|
||||
docmostApiUrl: 'https://docmost.example.com',
|
||||
docmostEmail: 'you@example.com',
|
||||
docmostPassword: 'secret',
|
||||
docmostSpaceId: 'space-1',
|
||||
vaultPath: '/vault',
|
||||
pollIntervalMs: 15000,
|
||||
debounceMs: 2000,
|
||||
logLevel: 'info',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A recording git fake covering exactly the `PushDeps['git']` surface. Options
|
||||
* configure the diff rows, which refs resolve, and what the ff returns.
|
||||
*/
|
||||
function makeGit(opts?: {
|
||||
mergeInProgress?: boolean;
|
||||
lastPushed?: string | null;
|
||||
docmostSha?: string | null;
|
||||
mainSha?: string;
|
||||
/** Diff rows returned by diffNameStatus(base, main). */
|
||||
changes?: { status: 'A' | 'M' | 'D' | 'R' | 'C'; path: string; oldPath?: string }[];
|
||||
/** Pre-image tree at the base ref (path -> text) for showFileAtRef. */
|
||||
prevTree?: Record<string, string>;
|
||||
ffResult?: { ok: boolean; reason?: string };
|
||||
/** When set, commit returns this per call (queue); defaults to always-true. */
|
||||
commitResults?: boolean[];
|
||||
}) {
|
||||
const calls = {
|
||||
assertGitAvailable: 0,
|
||||
ensureRepo: 0,
|
||||
checkout: [] as string[],
|
||||
stageAll: 0,
|
||||
commit: [] as string[],
|
||||
updateRef: [] as { ref: string; target: string }[],
|
||||
fastForwardBranch: [] as { branch: string; toCommit: string }[],
|
||||
diffNameStatus: [] as { from: string; to: string }[],
|
||||
};
|
||||
const prevTree = opts?.prevTree ?? {};
|
||||
const commitQueue = [...(opts?.commitResults ?? [])];
|
||||
let mainSha = opts?.mainSha ?? 'main-sha-1';
|
||||
|
||||
const git: PushDeps['git'] = {
|
||||
assertGitAvailable: vi.fn(async () => {
|
||||
calls.assertGitAvailable++;
|
||||
}),
|
||||
ensureRepo: vi.fn(async () => {
|
||||
calls.ensureRepo++;
|
||||
}),
|
||||
isMergeInProgress: vi.fn(async () => opts?.mergeInProgress ?? false),
|
||||
checkout: vi.fn(async (name: string) => {
|
||||
calls.checkout.push(name);
|
||||
}),
|
||||
stageAll: vi.fn(async () => {
|
||||
calls.stageAll++;
|
||||
}),
|
||||
commit: vi.fn(async (subject: string) => {
|
||||
calls.commit.push(subject);
|
||||
return commitQueue.length > 0 ? (commitQueue.shift() as boolean) : true;
|
||||
}),
|
||||
readRef: vi.fn(async (ref: string) =>
|
||||
ref === LAST_PUSHED_REF ? (opts?.lastPushed ?? null) : null,
|
||||
),
|
||||
revParse: vi.fn(async (ref: string) => {
|
||||
if (ref === DOCMOST_BRANCH) return opts?.docmostSha ?? null;
|
||||
if (ref === 'main') return mainSha;
|
||||
return null;
|
||||
}),
|
||||
diffNameStatus: vi.fn(async (from: string, to: string) => {
|
||||
calls.diffNameStatus.push({ from, to });
|
||||
return opts?.changes ?? [];
|
||||
}),
|
||||
showFileAtRef: vi.fn(async (_ref: string, path: string) =>
|
||||
path in prevTree ? prevTree[path] : null,
|
||||
),
|
||||
updateRef: vi.fn(async (ref: string, target: string) => {
|
||||
calls.updateRef.push({ ref, target });
|
||||
}),
|
||||
fastForwardBranch: vi.fn(async (branch: string, toCommit: string) => {
|
||||
calls.fastForwardBranch.push({ branch, toCommit });
|
||||
return opts?.ffResult ?? { ok: true };
|
||||
}),
|
||||
};
|
||||
return {
|
||||
git,
|
||||
calls,
|
||||
/** Advance the fake `main` HEAD (so a write-back commit yields a new sha). */
|
||||
setMainSha: (sha: string) => {
|
||||
mainSha = sha;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A recording client fake; createPage returns a configurable assigned id. */
|
||||
function makeClientFake(opts?: { createId?: string }) {
|
||||
return {
|
||||
importPageMarkdown: vi.fn(async () => ({ success: true })),
|
||||
createPage: vi.fn(async (title: string) => ({
|
||||
data: { id: opts?.createId ?? 'assigned-id', title },
|
||||
success: true,
|
||||
})),
|
||||
deletePage: vi.fn(async () => ({ success: true })),
|
||||
movePage: vi.fn(async () => ({ success: true })),
|
||||
renamePage: vi.fn(async () => ({ success: true })),
|
||||
};
|
||||
}
|
||||
|
||||
/** A recording fs fake over a path->text store. */
|
||||
function makeFs(initial: Record<string, string> = {}) {
|
||||
const store: Record<string, string> = { ...initial };
|
||||
const reads: string[] = [];
|
||||
const writes: { path: string; text: string }[] = [];
|
||||
return {
|
||||
store,
|
||||
reads,
|
||||
writes,
|
||||
readFile: vi.fn(async (path: string) => {
|
||||
reads.push(path);
|
||||
if (!(path in store)) throw new Error(`no such file: ${path}`);
|
||||
return store[path];
|
||||
}),
|
||||
writeFile: vi.fn(async (path: string, text: string) => {
|
||||
store[path] = text;
|
||||
writes.push({ path, text });
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Assemble PushDeps with a recording logger and a makeClient FACTORY spy. */
|
||||
function makeDeps(
|
||||
git: PushDeps['git'],
|
||||
fs: ReturnType<typeof makeFs>,
|
||||
client?: ReturnType<typeof makeClientFake>,
|
||||
) {
|
||||
const logs: string[] = [];
|
||||
const makeClient = vi.fn(() => (client ?? makeClientFake()) as any);
|
||||
const deps: PushDeps = {
|
||||
settings: makeSettings(),
|
||||
git,
|
||||
makeClient,
|
||||
readFile: fs.readFile,
|
||||
writeFile: fs.writeFile,
|
||||
log: (line) => logs.push(line),
|
||||
};
|
||||
return { deps, logs, makeClient };
|
||||
}
|
||||
|
||||
describe('runPush — dry-run is the DEFAULT (safe)', () => {
|
||||
it('logs a plan, builds NO client, makes ZERO Docmost calls, advances NO refs', async () => {
|
||||
const file =
|
||||
'<!-- docmost:meta\n{"version":1,"pageId":"p-1"}\n-->\n\nedited body\n';
|
||||
const { git, calls } = makeGit({
|
||||
lastPushed: 'base-sha',
|
||||
changes: [{ status: 'M', path: 'Doc.md' }],
|
||||
});
|
||||
const fs = makeFs({ 'Doc.md': file });
|
||||
const { deps, logs, makeClient } = makeDeps(git, fs);
|
||||
|
||||
const res = await runPush(deps, { dryRun: true });
|
||||
|
||||
expect(res.mode).toBe('dry-run');
|
||||
expect(res.planned).toEqual({
|
||||
creates: 0,
|
||||
updates: 1,
|
||||
deletes: 0,
|
||||
renamesMoves: 0,
|
||||
skipped: 0,
|
||||
});
|
||||
// The client FACTORY was never invoked -> zero Docmost contact.
|
||||
expect(makeClient).not.toHaveBeenCalled();
|
||||
// No ref advance, no mirror ff.
|
||||
expect(calls.updateRef).toEqual([]);
|
||||
expect(calls.fastForwardBranch).toEqual([]);
|
||||
// A plan WAS logged (counts + the per-item update line).
|
||||
expect(logs.join('\n')).toMatch(/DRY-RUN/);
|
||||
expect(logs.join('\n')).toMatch(/update: p-1 \(Doc\.md\)/);
|
||||
// It still diffs the base against main and works on main.
|
||||
expect(calls.diffNameStatus).toEqual([{ from: LAST_PUSHED_REF, to: 'main' }]);
|
||||
expect(calls.checkout).toEqual(['main']);
|
||||
});
|
||||
|
||||
it('commits the working tree with the local provenance trailer before diffing', async () => {
|
||||
const { git, calls } = makeGit({ lastPushed: 'base-sha' });
|
||||
const fs = makeFs();
|
||||
const { deps } = makeDeps(git, fs);
|
||||
|
||||
await runPush(deps, { dryRun: true });
|
||||
|
||||
// The first commit is the human working-tree commit on main (SPEC §7.3).
|
||||
expect(calls.commit[0]).toBe('local: working-tree changes');
|
||||
expect(calls.stageAll).toBeGreaterThanOrEqual(1);
|
||||
const trailerArg = (git.commit as any).mock.calls[0][1];
|
||||
expect(trailerArg.trailers).toEqual(['Docmost-Sync-Source: local']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runPush — --apply is the ONLY write path', () => {
|
||||
it('builds the client, calls applyPushActions, records created pageIds, advances last-pushed', async () => {
|
||||
// A brand-new local file: meta has title + spaceId but NO pageId yet.
|
||||
const newFile = serializeDocmostMarkdownBody(
|
||||
{ version: 1, title: 'New', spaceId: 'sp-1' },
|
||||
'fresh body',
|
||||
);
|
||||
const { git, calls, setMainSha } = makeGit({
|
||||
lastPushed: 'base-sha',
|
||||
mainSha: 'main-1',
|
||||
changes: [{ status: 'A', path: 'New.md' }],
|
||||
});
|
||||
const fs = makeFs({ 'New.md': newFile });
|
||||
const client = makeClientFake({ createId: 'page-new' });
|
||||
const { deps, makeClient } = makeDeps(git, fs, client);
|
||||
// After the write-back commit, `main` moves to a new commit.
|
||||
(git.commit as any).mockImplementation(async (subject: string) => {
|
||||
calls.commit.push(subject);
|
||||
if (subject === 'local: record created pageIds') setMainSha('main-2');
|
||||
return true;
|
||||
});
|
||||
|
||||
const res = await runPush(deps, { dryRun: false });
|
||||
|
||||
expect(res.mode).toBe('apply');
|
||||
// The client factory WAS used and createPage ran (the write path).
|
||||
expect(makeClient).toHaveBeenCalledTimes(1);
|
||||
expect(client.createPage).toHaveBeenCalledTimes(1);
|
||||
expect(res.applied?.created).toBe(1);
|
||||
// The assigned pageId was written back into the file on disk.
|
||||
expect(res.applied?.writtenBack).toEqual([{ path: 'New.md', pageId: 'page-new' }]);
|
||||
expect(fs.store['New.md']).toMatch(/page-new/);
|
||||
// A "record created pageIds" commit persisted the write-back.
|
||||
expect(calls.commit).toContain('local: record created pageIds');
|
||||
// last-pushed was advanced — first by the applier (main-1), then re-advanced
|
||||
// to the write-back commit (main-2).
|
||||
const lastPushedAdvances = calls.updateRef.filter(
|
||||
(u) => u.ref === LAST_PUSHED_REF,
|
||||
);
|
||||
expect(lastPushedAdvances.map((u) => u.target)).toEqual(['main-1', 'main-2']);
|
||||
expect(res.divergentDocmost).toBe(false);
|
||||
expect(res.failures).toEqual([]);
|
||||
});
|
||||
|
||||
it('ESCALATES a divergent docmost mirror in the write-back branch too (SPEC §5, symmetric)', async () => {
|
||||
// A create -> the pageId is written back and a "record created pageIds"
|
||||
// commit is made, which triggers the write-back-branch ff. Here the applier's
|
||||
// MAIN push ff succeeds (ok) but the WRITE-BACK ff diverges — the write-back
|
||||
// branch must escalate identically to the main branch (set divergentDocmost,
|
||||
// log the same prominent WARNING), so main() exits 1.
|
||||
const newFile = serializeDocmostMarkdownBody(
|
||||
{ version: 1, title: 'New', spaceId: 'sp-1' },
|
||||
'fresh body',
|
||||
);
|
||||
const { git, calls, setMainSha } = makeGit({
|
||||
lastPushed: 'base-sha',
|
||||
mainSha: 'main-1',
|
||||
changes: [{ status: 'A', path: 'New.md' }],
|
||||
});
|
||||
const fs = makeFs({ 'New.md': newFile });
|
||||
const client = makeClientFake({ createId: 'page-new' });
|
||||
const { deps, logs } = makeDeps(git, fs, client);
|
||||
(git.commit as any).mockImplementation(async (subject: string) => {
|
||||
calls.commit.push(subject);
|
||||
if (subject === 'local: record created pageIds') setMainSha('main-2');
|
||||
return true;
|
||||
});
|
||||
// First ff (applier 7b, main push) is OK; second ff (write-back) DIVERGES.
|
||||
let ffCall = 0;
|
||||
(git.fastForwardBranch as any).mockImplementation(
|
||||
async (branch: string, toCommit: string) => {
|
||||
calls.fastForwardBranch.push({ branch, toCommit });
|
||||
ffCall++;
|
||||
return ffCall === 1
|
||||
? { ok: true }
|
||||
: { ok: false, reason: 'not-fast-forward' };
|
||||
},
|
||||
);
|
||||
|
||||
const res = await runPush(deps, { dryRun: false });
|
||||
|
||||
// The apply still happened, but the write-back divergence is escalated.
|
||||
expect(res.applied?.created).toBe(1);
|
||||
expect(res.divergentDocmost).toBe(true);
|
||||
// The SAME prominent WARNING (DIVERGED + §5) — not a soft warning.
|
||||
expect(logs.join('\n')).toMatch(/WARNING/);
|
||||
expect(logs.join('\n')).toMatch(/DIVERGED/);
|
||||
expect(logs.join('\n')).toMatch(/write-back/);
|
||||
});
|
||||
|
||||
it('an update goes through importPageMarkdown (collab path)', async () => {
|
||||
const file =
|
||||
'<!-- docmost:meta\n{"version":1,"pageId":"p-9"}\n-->\n\nbody\n';
|
||||
const { git } = makeGit({
|
||||
lastPushed: 'base-sha',
|
||||
changes: [{ status: 'M', path: 'Doc.md' }],
|
||||
});
|
||||
const fs = makeFs({ 'Doc.md': file });
|
||||
const client = makeClientFake();
|
||||
const { deps } = makeDeps(git, fs, client);
|
||||
|
||||
const res = await runPush(deps, { dryRun: false });
|
||||
|
||||
expect(client.importPageMarkdown).toHaveBeenCalledWith('p-9', file);
|
||||
expect(res.applied?.updated).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runPush — merge-in-progress aborts (SPEC §9/§12)', () => {
|
||||
it('stops with a clear message, no diff, no client, no apply', async () => {
|
||||
const { git, calls } = makeGit({ mergeInProgress: true });
|
||||
const fs = makeFs();
|
||||
const { deps, logs, makeClient } = makeDeps(git, fs);
|
||||
|
||||
const res = await runPush(deps, { dryRun: false });
|
||||
|
||||
expect(res.aborted).toBe('merge-in-progress');
|
||||
// Never diffed, never built a client, never checked out / committed.
|
||||
expect(calls.diffNameStatus).toEqual([]);
|
||||
expect(makeClient).not.toHaveBeenCalled();
|
||||
expect(calls.checkout).toEqual([]);
|
||||
expect(logs.join('\n')).toMatch(/unresolved merge/);
|
||||
expect(logs.join('\n')).toMatch(/SPEC §9/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runPush — divergent docmost escalation (SPEC §5)', () => {
|
||||
it('sets the escalation flag and logs a WARNING, but the apply still happened', async () => {
|
||||
const file =
|
||||
'<!-- docmost:meta\n{"version":1,"pageId":"p-1"}\n-->\n\nbody\n';
|
||||
const { git } = makeGit({
|
||||
lastPushed: 'base-sha',
|
||||
changes: [{ status: 'M', path: 'Doc.md' }],
|
||||
// The applier refuses to clobber a divergent mirror.
|
||||
ffResult: { ok: false, reason: 'not-fast-forward' },
|
||||
});
|
||||
const fs = makeFs({ 'Doc.md': file });
|
||||
const client = makeClientFake();
|
||||
const { deps, logs } = makeDeps(git, fs, client);
|
||||
|
||||
const res = await runPush(deps, { dryRun: false });
|
||||
|
||||
// The apply STILL happened (the page was updated)...
|
||||
expect(res.applied?.updated).toBe(1);
|
||||
expect(client.importPageMarkdown).toHaveBeenCalledTimes(1);
|
||||
// ...but the divergence is escalated, not silent.
|
||||
expect(res.divergentDocmost).toBe(true);
|
||||
expect(logs.join('\n')).toMatch(/WARNING/);
|
||||
expect(logs.join('\n')).toMatch(/DIVERGED/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runPush — base selection (last-pushed else docmost)', () => {
|
||||
it('uses refs/docmost/last-pushed when it resolves', async () => {
|
||||
const { git, calls } = makeGit({ lastPushed: 'lp-sha' });
|
||||
const fs = makeFs();
|
||||
const { deps } = makeDeps(git, fs);
|
||||
|
||||
const res = await runPush(deps, { dryRun: true });
|
||||
|
||||
expect(res.base).toEqual({
|
||||
ref: LAST_PUSHED_REF,
|
||||
source: 'last-pushed',
|
||||
sha: 'lp-sha',
|
||||
});
|
||||
expect(calls.diffNameStatus[0].from).toBe(LAST_PUSHED_REF);
|
||||
});
|
||||
|
||||
it('falls back to the docmost branch when last-pushed is missing', async () => {
|
||||
const { git, calls } = makeGit({
|
||||
lastPushed: null, // last-pushed does not resolve -> fall back.
|
||||
docmostSha: 'doc-sha',
|
||||
});
|
||||
const fs = makeFs();
|
||||
const { deps } = makeDeps(git, fs);
|
||||
|
||||
const res = await runPush(deps, { dryRun: true });
|
||||
|
||||
expect(res.base).toEqual({
|
||||
ref: DOCMOST_BRANCH,
|
||||
source: 'docmost',
|
||||
sha: 'doc-sha',
|
||||
});
|
||||
// The diff is taken against the docmost mirror branch.
|
||||
expect(calls.diffNameStatus[0].from).toBe(DOCMOST_BRANCH);
|
||||
});
|
||||
});
|
||||
76
packages/git-sync/test/settings.test.ts
Normal file
76
packages/git-sync/test/settings.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseSettings } from '../src/engine/settings';
|
||||
|
||||
// A minimal valid environment with every required variable set. Tests clone and
|
||||
// mutate this object so process.env is never touched (hermetic).
|
||||
const baseEnv = {
|
||||
DOCMOST_API_URL: 'https://docmost.example.com',
|
||||
DOCMOST_EMAIL: 'you@example.com',
|
||||
DOCMOST_PASSWORD: 'secret',
|
||||
DOCMOST_SPACE_ID: 'space-123',
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
describe('parseSettings', () => {
|
||||
it('maps a full valid env to the camelCase Settings object', () => {
|
||||
const settings = parseSettings({
|
||||
...baseEnv,
|
||||
VAULT_PATH: 'data/custom-vault',
|
||||
GIT_REMOTE: 'git@github.com:you/vault.git',
|
||||
POLL_INTERVAL_MS: '5000',
|
||||
DEBOUNCE_MS: '1000',
|
||||
LOG_LEVEL: 'debug',
|
||||
});
|
||||
|
||||
expect(settings).toEqual({
|
||||
docmostApiUrl: 'https://docmost.example.com',
|
||||
docmostEmail: 'you@example.com',
|
||||
docmostPassword: 'secret',
|
||||
docmostSpaceId: 'space-123',
|
||||
vaultPath: 'data/custom-vault',
|
||||
gitRemote: 'git@github.com:you/vault.git',
|
||||
pollIntervalMs: 5000,
|
||||
debounceMs: 1000,
|
||||
logLevel: 'debug',
|
||||
});
|
||||
});
|
||||
|
||||
it('applies defaults when optional vars are omitted', () => {
|
||||
const settings = parseSettings({ ...baseEnv });
|
||||
|
||||
expect(settings.vaultPath).toBe('data/vault');
|
||||
expect(settings.pollIntervalMs).toBe(15000);
|
||||
expect(settings.debounceMs).toBe(2000);
|
||||
expect(settings.logLevel).toBe('info');
|
||||
expect(settings.gitRemote).toBeUndefined();
|
||||
});
|
||||
|
||||
it('coerces numeric strings to numbers', () => {
|
||||
const settings = parseSettings({ ...baseEnv, POLL_INTERVAL_MS: '3000' });
|
||||
|
||||
expect(settings.pollIntervalMs).toBe(3000);
|
||||
expect(typeof settings.pollIntervalMs).toBe('number');
|
||||
});
|
||||
|
||||
it('throws when a required var is missing', () => {
|
||||
const { DOCMOST_API_URL: _omit, ...rest } = baseEnv;
|
||||
void _omit;
|
||||
expect(() => parseSettings(rest as NodeJS.ProcessEnv)).toThrow();
|
||||
});
|
||||
|
||||
it('throws on an invalid LOG_LEVEL', () => {
|
||||
expect(() =>
|
||||
parseSettings({ ...baseEnv, LOG_LEVEL: 'verbose' }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('throws on a non-numeric POLL_INTERVAL_MS', () => {
|
||||
expect(() =>
|
||||
parseSettings({ ...baseEnv, POLL_INTERVAL_MS: 'soon' }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('treats an empty GIT_REMOTE as undefined', () => {
|
||||
const settings = parseSettings({ ...baseEnv, GIT_REMOTE: '' });
|
||||
expect(settings.gitRemote).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user