feat(sync): FS->Docmost push #1 — diff/ref primitives + pure planner + apply (fakes)
First slice of the push direction (SPEC §6), mirroring pull: VaultGit primitives + pure planner + thin injectable apply, exercised via fakes (no live destructive run). - git.ts: diffNameStatus (--name-status -M -z, NUL-parsed, rename-aware), revParse/readRef/updateRef (refs/docmost/last-pushed), showFileAtRef (recover a deleted file's pre-image pageId) - push.ts computePushActions (pure): A/M/D/R -> create/update/delete/renamesMoves; delete only when pageId is recovered from the pre-image, else skipped (§8 guard — no spurious Docmost delete) - push.ts applyPushActions (fakes): update via importPageMarkdown (collab/Yjs path, §2 — never a raw jsonb overwrite); create via createPage then write the assigned pageId back into the file meta (body preserved); delete via deletePage (soft, §8); renamesMoves deferred; advances last-pushed - tests (+26): diffNameStatus A/M/D/rename, ref round-trip, showFileAtRef; pure classification incl. §8 no-pageid skip; apply with fakes (collab-path update, pageid write-back, soft-delete, deferred moves) - 683 -> 709 green; build clean; corpus STABLE Deferred (next increment): move/rename apply, loop-guard (§10), watcher/debounce, remote push, live main wiring, empty-spaceId create guard, per-page error isolation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
274
test/apply-push-actions.test.ts
Normal file
274
test/apply-push-actions.test.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { applyPushActions, LAST_PUSHED_REF } from '../src/push.js';
|
||||
import type { ApplyPushDeps, PushActions } from '../src/push.js';
|
||||
import {
|
||||
parseDocmostMarkdown,
|
||||
serializeDocmostMarkdownBody,
|
||||
} from '../packages/docmost-client/src/lib/markdown-document.js';
|
||||
|
||||
// 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 })),
|
||||
};
|
||||
return client;
|
||||
}
|
||||
|
||||
/** A recording git fake (only updateRef is used by the push applier). */
|
||||
function makeGit() {
|
||||
const updateRefCalls: { ref: string; target: string }[] = [];
|
||||
const git = {
|
||||
updateRef: vi.fn(async (ref: string, target: string) => {
|
||||
updateRefCalls.push({ ref, target });
|
||||
}),
|
||||
};
|
||||
return { git, updateRefCalls };
|
||||
}
|
||||
|
||||
/** 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([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPushActions — rename/move is DEFERRED (NEXT increment)', () => {
|
||||
it('returns renames/moves as `deferred` with NO client call', async () => {
|
||||
const client = makeClient();
|
||||
const { git } = makeGit();
|
||||
const fs = makeFs();
|
||||
|
||||
const rm = { pageId: 'p-mv', oldPath: 'Old.md', newPath: 'New.md' };
|
||||
const res = await applyPushActions(
|
||||
deps(client, git, fs),
|
||||
actions({ renamesMoves: [rm] }),
|
||||
);
|
||||
|
||||
expect(res.deferred).toEqual([rm]);
|
||||
// NOTHING was pushed for the move this increment.
|
||||
expect(client.importPageMarkdown).not.toHaveBeenCalled();
|
||||
expect(client.createPage).not.toHaveBeenCalled();
|
||||
expect(client.deletePage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPushActions — last-pushed ref advance (SPEC §6 step 3)', () => {
|
||||
it('advances refs/docmost/last-pushed to the pushed commit', async () => {
|
||||
const client = makeClient();
|
||||
const { git, updateRefCalls } = 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' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does NOT advance the 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(git.updateRef).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
195
test/compute-push-actions.test.ts
Normal file
195
test/compute-push-actions.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { computePushActions } from '../src/push.js';
|
||||
import type { DiffEntry, MetaSide } from '../src/push.js';
|
||||
import type { DocmostMdMeta } from '../packages/docmost-client/src/lib/markdown-document.js';
|
||||
|
||||
// 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 -> create (treated as new)', () => {
|
||||
const changes: DiffEntry[] = [{ status: 'A', path: 'Plain.md' }];
|
||||
const actions = computePushActions({ changes, metaAt: metaTable({}) });
|
||||
expect(actions.creates).toEqual([{ path: 'Plain.md' }]);
|
||||
});
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
149
test/git.test.ts
149
test/git.test.ts
@@ -473,4 +473,153 @@ describe('VaultGit (integration; temp repo)', () => {
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user