Files
gitmost/packages/git-sync/test/compute-pull-actions.test.ts
claude code agent 227 2918517e10 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>
2026-06-26 21:08:28 +03:00

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' }]);
});
});