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>
194 lines
6.8 KiB
TypeScript
194 lines
6.8 KiB
TypeScript
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' }]);
|
|
});
|
|
});
|