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:
vvzvlad
2026-06-17 02:32:15 +03:00
parent 480f4c3747
commit 9c6283aa8e
5 changed files with 1131 additions and 0 deletions

View File

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