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:
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