Resolve the code-review findings from comment #1571 on PR #119. Engine (packages/git-sync): - Idempotent CREATE on retry: before createPage, look the page up in the live Docmost tree by (parentPageId, title) and ADOPT it instead of duplicating when a prior cycle created it but failed to persist the pageId back to disk. Only trust a COMPLETE tree for the lookup; fall back to createPage otherwise. Covered by new tests incl. a complete=false regression-lock. - Route applyPullActions diagnostics through an injected logger instead of bare console (thread log from the cycle). - Add a timeout to the git execFile chokepoint (runRaw) so a hung git subprocess cannot wedge a sync cycle. - Translate remaining Russian code comments to English. - Remove dead standalone-CLI code (parseArgs/PushParsedArgs, parseSettings/envSchema, loadSettingsOrExit + config-errors.ts) and the matching index exports/specs; keep the Settings type. - Fix the dangling docs link in package.json. - Add a schema-surface snapshot guard so any drift in the vendored document schema is a loud, must-review CI failure (+ provenance header). Server (apps/server): - Add a configurable watchdog timeout to the spawned git http-backend so a stalled push cannot hold the per-space lock forever (GIT_SYNC_BACKEND_TIMEOUT_MS). - Close the in-process TOCTOU window in SpaceLockService.withSpaceLock by reserving the slot synchronously before acquire. - Add tests: removePage git-sync provenance (both branches), ensureServable force-push-protection git configs, and the phase-B+ datasource methods. Docs / build: - AGENTS.md: list git-sync as the fifth workspace package and note the three schema mirrors; fix the dangling git-sync-plan.md backlog link. - pnpm-lock.yaml: add the missing @docmost/git-sync workspace link so pnpm install --frozen-lockfile (CI default) succeeds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
822 lines
31 KiB
TypeScript
822 lines
31 KiB
TypeScript
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 { parsePageFile, serializePageFile } from '../src/lib/page-file';
|
|
|
|
// The Docmost space this vault mirrors (native files carry no spaceId; the run
|
|
// supplies it). A CREATE targets this space.
|
|
const SPACE_ID = 'sp-test';
|
|
|
|
// 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 = {
|
|
// Empty live tree by default -> creates take the normal createPage path; the
|
|
// retry-adopt lookup only fires when a (parentPageId, title) node matches.
|
|
listSpaceTree: vi.fn(async () => ({
|
|
pages: [] as { id: string; parentPageId?: string | null; title?: string }[],
|
|
complete: true,
|
|
})),
|
|
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,
|
|
spaceId: SPACE_ID,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A native page file: `gitmost_id` frontmatter + a clean body. The TITLE is NOT
|
|
* stored — it is derived from the filename — so this helper takes only a pageId.
|
|
* Used to seed both the working tree (fs) and the prev tree (showFileAtRef).
|
|
*/
|
|
function fileFor(pageId: string, body = 'body'): string {
|
|
return serializePageFile(pageId, body);
|
|
}
|
|
|
|
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 and calls importPageMarkdown with the STRIPPED body', async () => {
|
|
const fileBody = fileFor('p-1', 'updated body');
|
|
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. The pushed
|
|
// content is the CLEAN body (no gitmost_id frontmatter leaks to Docmost).
|
|
expect(client.importPageMarkdown).toHaveBeenCalledTimes(1);
|
|
expect(client.importPageMarkdown).toHaveBeenCalledWith('p-1', 'updated body', null);
|
|
// 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();
|
|
});
|
|
|
|
it('forwards the last-pushed base body (3-way merge ancestor) when present', async () => {
|
|
const client = makeClient();
|
|
// The pre-image (refs/docmost/last-pushed) carries the base version; both
|
|
// sides are stripped to their clean body for a body-to-body 3-way merge.
|
|
const { git } = makeGit({ prevTree: { 'Doc.md': fileFor('p-1', 'base body') } });
|
|
const fs = makeFs({ 'Doc.md': fileFor('p-1', 'updated body') });
|
|
|
|
await applyPushActions(
|
|
deps(client, git, fs),
|
|
actions({ updates: [{ pageId: 'p-1', path: 'Doc.md' }] }),
|
|
);
|
|
|
|
// importPageMarkdown receives the stripped base so the server 3-way merges it.
|
|
expect(client.importPageMarkdown).toHaveBeenCalledWith(
|
|
'p-1',
|
|
'updated body',
|
|
'base body',
|
|
);
|
|
expect(git.showFileAtRef).toHaveBeenCalledWith(LAST_PUSHED_REF, 'Doc.md');
|
|
});
|
|
});
|
|
|
|
describe('applyPushActions — create (assigned pageId written back to meta)', () => {
|
|
it('createPage gets title/parent from the PATH and writes the pageId back', async () => {
|
|
// A brand-new local file with NO frontmatter (a hand-written Obsidian note)
|
|
// under a parent folder. title = filename, parent = the folder's folder-note,
|
|
// space = the run's space — all DERIVED, none stored in the file.
|
|
const client = makeClient({ createId: 'page-new-42' });
|
|
const { git } = makeGit();
|
|
const fs = makeFs({
|
|
'Parent/My New Page.md': '# My New Page\n\nbody text\n',
|
|
// The enclosing folder's folder-note identifies the parent page.
|
|
'Parent/Parent.md': fileFor('parent-9'),
|
|
});
|
|
|
|
const res = await applyPushActions(
|
|
deps(client, git, fs),
|
|
actions({ creates: [{ path: 'Parent/My New Page.md' }] }),
|
|
);
|
|
|
|
expect(res.created).toBe(1);
|
|
expect(client.createPage).toHaveBeenCalledTimes(1);
|
|
const [title, content, spaceId, parentPageId] =
|
|
client.createPage.mock.calls[0];
|
|
expect(title).toBe('My New Page'); // from the filename
|
|
expect(spaceId).toBe(SPACE_ID); // from the run
|
|
expect(parentPageId).toBe('parent-9'); // from the folder's folder-note
|
|
expect(content).toContain('body text');
|
|
|
|
// The file was rewritten with the assigned pageId as gitmost_id frontmatter,
|
|
// body preserved, NO docmost:meta.
|
|
expect(fs.writes.map((w) => w.path)).toEqual(['Parent/My New Page.md']);
|
|
const rewritten = fs.store['Parent/My New Page.md'];
|
|
expect(rewritten.startsWith('---\ngitmost_id: page-new-42\n---')).toBe(true);
|
|
expect(rewritten).not.toContain('docmost:meta');
|
|
const parsed = parsePageFile(rewritten);
|
|
expect(parsed.id).toBe('page-new-42');
|
|
expect(parsed.body).toContain('body text');
|
|
|
|
// The write-back is recorded so a follow-up commit can be made.
|
|
expect(res.writtenBack).toEqual([
|
|
{ path: 'Parent/My New Page.md', pageId: 'page-new-42' },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('applyPushActions — create RETRY-ADOPT idempotency (#1)', () => {
|
|
// Create is NOT atomic with the pageId write-back: if a prior cycle created the
|
|
// page in Docmost but died before persisting the id back, the file is re-seen as
|
|
// a CREATE. The applier must ADOPT the existing page (write the id back + push the
|
|
// body as an idempotent UPDATE) instead of calling createPage again (which would
|
|
// duplicate the page). The live page is matched by (parentPageId, title).
|
|
it('ADOPTS an existing page (no createPage) when the live tree already has a match', async () => {
|
|
const client = makeClient({ createId: 'should-not-be-used' });
|
|
// The live Docmost tree already has the page this create targets:
|
|
// title "My New Page" under the parent folder's page `parent-9`.
|
|
client.listSpaceTree.mockResolvedValue({
|
|
pages: [
|
|
{ id: 'parent-9', parentPageId: null, title: 'Parent' },
|
|
{ id: 'already-created-7', parentPageId: 'parent-9', title: 'My New Page' },
|
|
],
|
|
complete: true,
|
|
});
|
|
const { git } = makeGit();
|
|
const fs = makeFs({
|
|
'Parent/My New Page.md': '# My New Page\n\nbody text\n',
|
|
'Parent/Parent.md': fileFor('parent-9'),
|
|
});
|
|
|
|
const res = await applyPushActions(
|
|
deps(client, git, fs),
|
|
actions({ creates: [{ path: 'Parent/My New Page.md' }] }),
|
|
);
|
|
|
|
expect(res.created).toBe(1);
|
|
// CRITICAL: createPage was NOT called — no duplicate page in Docmost.
|
|
expect(client.createPage).not.toHaveBeenCalled();
|
|
// The body was pushed as an UPDATE targeting the EXISTING id (idempotent).
|
|
expect(client.importPageMarkdown).toHaveBeenCalledTimes(1);
|
|
expect(client.importPageMarkdown).toHaveBeenCalledWith(
|
|
'already-created-7',
|
|
expect.stringContaining('body text'),
|
|
null,
|
|
);
|
|
|
|
// The file was rewritten with the EXISTING id as gitmost_id (now tracked).
|
|
expect(fs.writes.map((w) => w.path)).toEqual(['Parent/My New Page.md']);
|
|
const rewritten = fs.store['Parent/My New Page.md'];
|
|
expect(parsePageFile(rewritten).id).toBe('already-created-7');
|
|
expect(res.writtenBack).toEqual([
|
|
{ path: 'Parent/My New Page.md', pageId: 'already-created-7' },
|
|
]);
|
|
});
|
|
|
|
it('does NOT adopt from an INCOMPLETE tree even when a node matches (falls back to createPage)', async () => {
|
|
// Defensive guard: retry-adopt is only safe from a COMPLETE live tree. A
|
|
// TRUNCATED tree (complete:false) could miss an already-created page and let
|
|
// us duplicate it — the very thing adopt prevents. So on an incomplete tree
|
|
// the map is NOT built and we MUST fall back to the normal createPage path,
|
|
// even though this particular tree happens to carry a matching node.
|
|
const client = makeClient({ createId: 'page-new-55' });
|
|
// A node that WOULD match the create's (parentPageId 'parent-9', title
|
|
// 'My New Page') — but the tree is flagged incomplete, so it must be ignored.
|
|
client.listSpaceTree.mockResolvedValue({
|
|
pages: [
|
|
{ id: 'parent-9', parentPageId: null, title: 'Parent' },
|
|
{ id: 'already-created-7', parentPageId: 'parent-9', title: 'My New Page' },
|
|
],
|
|
complete: false,
|
|
});
|
|
const { git } = makeGit();
|
|
const fs = makeFs({
|
|
'Parent/My New Page.md': '# My New Page\n\nbody text\n',
|
|
'Parent/Parent.md': fileFor('parent-9'),
|
|
});
|
|
|
|
const res = await applyPushActions(
|
|
deps(client, git, fs),
|
|
actions({ creates: [{ path: 'Parent/My New Page.md' }] }),
|
|
);
|
|
|
|
expect(res.created).toBe(1);
|
|
// CRITICAL: createPage ran (normal path) — adopt was suppressed by complete:false.
|
|
expect(client.createPage).toHaveBeenCalledTimes(1);
|
|
// No adopt-UPDATE happened: the matching node was NOT trusted.
|
|
expect(client.importPageMarkdown).not.toHaveBeenCalled();
|
|
// The file carries the NEWLY assigned id, not the would-be adopted one.
|
|
expect(parsePageFile(fs.store['Parent/My New Page.md']).id).toBe('page-new-55');
|
|
expect(res.writtenBack).toEqual([
|
|
{ path: 'Parent/My New Page.md', pageId: 'page-new-55' },
|
|
]);
|
|
});
|
|
|
|
it('a NORMAL create (empty live tree) STILL calls createPage', async () => {
|
|
// No matching live node -> the happy path: createPage runs, no adopt.
|
|
const client = makeClient({ createId: 'page-new-99' });
|
|
// makeClient's listSpaceTree returns an empty tree by default.
|
|
const { git } = makeGit();
|
|
const fs = makeFs({
|
|
'Parent/My New Page.md': '# My New Page\n\nbody text\n',
|
|
'Parent/Parent.md': fileFor('parent-9'),
|
|
});
|
|
|
|
const res = await applyPushActions(
|
|
deps(client, git, fs),
|
|
actions({ creates: [{ path: 'Parent/My New Page.md' }] }),
|
|
);
|
|
|
|
expect(res.created).toBe(1);
|
|
expect(client.createPage).toHaveBeenCalledTimes(1);
|
|
// No adopt-UPDATE happened (importPageMarkdown is the update path).
|
|
expect(client.importPageMarkdown).not.toHaveBeenCalled();
|
|
expect(parsePageFile(fs.store['Parent/My New Page.md']).id).toBe('page-new-99');
|
|
});
|
|
|
|
it('a thrown adopt is isolated as a `create` failure (per-page isolation, SPEC §12)', async () => {
|
|
const client = makeClient({ createId: 'unused' });
|
|
client.listSpaceTree.mockResolvedValue({
|
|
pages: [{ id: 'existing-1', parentPageId: null, title: 'Doc' }],
|
|
complete: true,
|
|
});
|
|
// The adopt pushes the body as an UPDATE; make that throw.
|
|
client.importPageMarkdown.mockRejectedValue(new Error('adopt boom'));
|
|
const { git, updateRefCalls } = makeGit();
|
|
const fs = makeFs({ 'Doc.md': '# Doc\n\nbody\n' });
|
|
|
|
const res = await applyPushActions(
|
|
deps(client, git, fs),
|
|
actions({ creates: [{ path: 'Doc.md' }] }),
|
|
'sha-adopt-fail',
|
|
);
|
|
|
|
expect(res.created).toBe(0);
|
|
expect(client.createPage).not.toHaveBeenCalled();
|
|
expect(res.failures).toEqual([
|
|
{ kind: 'create', path: 'Doc.md', error: 'adopt boom' },
|
|
]);
|
|
// A failure means the refs are NOT advanced (re-run retries cleanly, §12).
|
|
expect(res.lastPushedAdvanced).toBe(false);
|
|
expect(updateRefCalls).toEqual([]);
|
|
});
|
|
});
|
|
|
|
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.
|
|
|
|
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 owns folder `Parent/`, so its file is the FOLDER-NOTE
|
|
// `Parent/Parent.md`, whose gitmost_id is 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': fileFor('p-mv') },
|
|
});
|
|
const fs = makeFs({
|
|
// Current tree: the moved file + its new parent folder's folder-note.
|
|
'Parent/Doc.md': fileFor('p-mv'),
|
|
'Parent/Parent.md': fileFor('parent-id'),
|
|
});
|
|
|
|
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 folder-note is `Parent/Parent.md` (parent-id).
|
|
prevTree: {
|
|
'Parent/Doc.md': fileFor('p-mv'),
|
|
'Parent/Parent.md': fileFor('parent-id'),
|
|
},
|
|
});
|
|
// Current: the file is now at the root -> no enclosing folder -> parent ROOT.
|
|
const fs = makeFs({ 'Doc.md': fileFor('p-mv') });
|
|
|
|
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-from-filename) and NOT movePage', async () => {
|
|
// Same enclosing folder on both sides (parent unchanged), the FILENAME (=
|
|
// title) changed Old -> New -> a pure rename to the new filename's title.
|
|
const client = makeClient();
|
|
const { git } = makeGit({
|
|
prevTree: {
|
|
'Folder/Old.md': fileFor('p-rn'),
|
|
'Folder/Folder.md': fileFor('folder-id'),
|
|
},
|
|
});
|
|
const fs = makeFs({
|
|
'Folder/New.md': fileFor('p-rn'),
|
|
'Folder/Folder.md': fileFor('folder-id'),
|
|
});
|
|
|
|
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);
|
|
// The title is the NEW filename (no extension), not a stored meta title.
|
|
expect(client.renamePage).toHaveBeenCalledWith('p-rn', 'New');
|
|
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), filename `Old`.
|
|
prevTree: { 'Old.md': fileFor('p-x') },
|
|
});
|
|
const fs = makeFs({
|
|
// Current: under a new folder (folder-note np-id) AND renamed to `New`.
|
|
'NewParent/New.md': fileFor('p-x'),
|
|
'NewParent/NewParent.md': fileFor('np-id'),
|
|
});
|
|
|
|
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 (parent folder renamed; NO Docmost call; SPEC §5)', () => {
|
|
it('calls NEITHER movePage NOR renamePage and records the noop', async () => {
|
|
// The PARENT folder was renamed Old/ -> New/ (a retitle of the parent page,
|
|
// whose folder-note kept the SAME gitmost_id). For this CHILD, neither its
|
|
// own title (`Child`) nor its parent PAGE (same id `parent-P`) changed — only
|
|
// an ancestor's name did. The page is its pageId; Docmost is untouched.
|
|
const client = makeClient();
|
|
const { git } = makeGit({
|
|
prevTree: {
|
|
'Old/Child.md': fileFor('p-noop'),
|
|
'Old/Old.md': fileFor('parent-P'),
|
|
},
|
|
});
|
|
const fs = makeFs({
|
|
'New/Child.md': fileFor('p-noop'),
|
|
'New/New.md': fileFor('parent-P'),
|
|
});
|
|
|
|
const res = await applyPushActions(
|
|
deps(client, git, fs),
|
|
actions({
|
|
renamesMoves: [
|
|
{ pageId: 'p-noop', oldPath: 'Old/Child.md', newPath: 'New/Child.md' },
|
|
],
|
|
}),
|
|
);
|
|
|
|
expect(res.moved).toBe(0);
|
|
expect(res.renamed).toBe(0);
|
|
// ZERO Docmost calls — only the ancestor folder name changed.
|
|
expect(client.movePage).not.toHaveBeenCalled();
|
|
expect(client.renamePage).not.toHaveBeenCalled();
|
|
expect(res.noops).toEqual([
|
|
{
|
|
pageId: 'p-noop',
|
|
oldPath: 'Old/Child.md',
|
|
newPath: 'New/Child.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': fileFor('p-mv') },
|
|
});
|
|
const fs = makeFs({
|
|
'Parent/Doc.md': fileFor('p-mv'),
|
|
'Parent/Parent.md': fileFor('parent-id'),
|
|
});
|
|
|
|
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', null);
|
|
expect(client.importPageMarkdown).toHaveBeenNthCalledWith(3, 'p-c', 'c body', null);
|
|
|
|
// 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 = fileFor('p-1', 'updated body');
|
|
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 BODY (frontmatter stripped).
|
|
expect(res.pushed[0].bodyHash).toBe(bodyHash('updated body'));
|
|
expect(res.pushed[0].bodyHash).toMatch(/^[0-9a-f]{64}$/);
|
|
});
|
|
|
|
it('omits updatedAt when the client result does not expose one', async () => {
|
|
// A hand-written file with no frontmatter; its body is the whole text.
|
|
const newFile = '# N\n\nfresh body\n';
|
|
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 pushed BODY (parsePageFile strips nothing here — no
|
|
// frontmatter — so it is the trimmed file text).
|
|
expect(res.pushed[0].bodyHash).toBe(bodyHash(parsePageFile(newFile).body));
|
|
});
|
|
});
|
|
|
|
describe('applyPushActions — mixed batch + skipped passthrough', () => {
|
|
it('applies update + create + delete and carries skipped rows through', async () => {
|
|
const updFile = fileFor('u-1', 'upd');
|
|
const newFile = '# N\n\nfresh body\n';
|
|
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' }]);
|
|
// The update pushes the STRIPPED body ('upd'), not the frontmatter file.
|
|
expect(client.importPageMarkdown).toHaveBeenCalledWith('u-1', 'upd', null);
|
|
expect(client.deletePage).toHaveBeenCalledWith('d-1');
|
|
});
|
|
});
|