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>
196 lines
7.7 KiB
TypeScript
196 lines
7.7 KiB
TypeScript
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([]);
|
|
});
|
|
});
|