feat(git-sync): phase 3 — PUSH reads native gitmost_id + derives title/parent from path

PUSH now consumes the native-Obsidian format end-to-end:
- identity from the gitmost_id frontmatter (parsePageFile), not docmost:meta;
- title from the FILENAME, parentPageId from the enclosing folder's folder-note
  (parentFolderFile is now FOLDER-NOTE aware: a child's parent is dir/dir.md, and
  a folder-note's own parent is one level up), spaceId from the run (every vault
  file belongs to the vault's space);
- CREATE derives title/parent/space from path + run and writes the assigned
  pageId back as gitmost_id frontmatter (serializePageFile);
- UPDATE pushes the STRIPPED body (current + 3-way-merge base), so the frontmatter
  never leaks into Docmost content; the loop-guard hashes the body.

The PURE delete-sensitive classifier (computePushActions/classifyRenameMoves) is
UNCHANGED — only the injected IO resolvers (metaAt, parent, create write-back)
switched source. nativeMeta always carries the run spaceId, so the legacy
'create-without-spaceId' skip no longer fires through runPush.

Tests rewritten to native fixtures + folder-note parent paths; the noop case is
now a child under a renamed parent folder (filename=title, so a path-only-noop
needs an ancestor rename). parentFolderFile tests cover leaf/folder-note/nested/
dotted. 612 engine tests green; engine rebuilt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-24 05:04:31 +03:00
parent 2e08a93d5b
commit 8e9bdeb0f9
4 changed files with 287 additions and 252 deletions

View File

@@ -8,7 +8,7 @@ import { firstDivergence } from '../src/engine/roundtrip-helpers';
import { applyPullActions } from '../src/engine/pull';
import type { PullActions, ApplyPullActionsDeps } from '../src/engine/pull';
import type { DeletionDecision } from '../src/engine/reconcile';
import { serializeDocmostMarkdownBody } from '../src/lib/index';
import { serializePageFile, parsePageFile } from '../src/lib/page-file';
// Engine-layer coverage gaps flagged by the PR #119 reviewers (test-strategy
// report, Module 2 `src/engine`). Each block targets a specific under-covered
@@ -17,13 +17,15 @@ import { serializeDocmostMarkdownBody } from '../src/lib/index';
// --- 1. push.ts:parentFolderFile — move<->rename classification lynchpin -----
//
// `parentFolderFile(path)` returns the parent FOLDER's `.md` file for a vault-
// relative path (the enclosing folder one level up, SPEC §5 path-as-truth), or
// `null` for a root-level path with no enclosing folder. It is the lynchpin of
// the move-vs-rename classifier, so it is tested directly here (it was only
// covered indirectly before): root-level, deep nesting, and — critically —
// names CONTAINING DOTS (the lastIndexOf('/') split must not be confused by a
// dot in a segment; only the LAST slash matters).
// `parentFolderFile(path)` returns the parent PAGE's file for a vault-relative
// path (SPEC §5 path-as-truth), or `null` for a root-level page. In the native-
// Obsidian FOLDER-NOTE layout the parent page that owns a folder is its folder-
// note `<dir>/<base>.md` (NOT `<dir>.md`). For a file that IS its folder's
// folder-note, the parent is ONE LEVEL UP (the grandparent folder's note, or
// ROOT at the top). It is the lynchpin of the move-vs-rename classifier, so it
// is tested directly: root-level, a leaf in a folder, a folder-note itself,
// deep nesting, and — critically — names CONTAINING DOTS (only the LAST slash
// splits the path).
describe('parentFolderFile (push.ts)', () => {
it('returns null for a root-level path (no enclosing folder)', () => {
expect(parentFolderFile('Child.md')).toBeNull();
@@ -31,24 +33,33 @@ describe('parentFolderFile (push.ts)', () => {
expect(parentFolderFile('README.md')).toBeNull();
});
it('returns the immediate enclosing folder file for a one-level path', () => {
expect(parentFolderFile('Space/Child.md')).toBe('Space.md');
it('returns the enclosing folder-note for a LEAF inside a folder', () => {
// The parent page owns folder `Space/`, so its file is the folder-note
// `Space/Space.md` — NOT `Space.md`.
expect(parentFolderFile('Space/Child.md')).toBe('Space/Space.md');
});
it('returns the DEEPEST enclosing folder file for a deeply nested path', () => {
// Only the last slash matters: the parent is the immediate folder, turned
// into its `<folder>.md` page file (NOT the space root).
it('returns the grandparent folder-note for a FOLDER-NOTE itself', () => {
// `Space/Sub/Sub.md` IS the folder-note of `Space/Sub`; its parent is the
// folder-note one level up, `Space/Space.md`.
expect(parentFolderFile('Space/Sub/Sub.md')).toBe('Space/Space.md');
// A top-level folder-note `Space/Space.md` has the ROOT as its parent.
expect(parentFolderFile('Space/Space.md')).toBeNull();
});
it('returns the DEEPEST enclosing folder-note for a deeply nested leaf', () => {
// Only the last slash matters: the parent is the immediate folder's note.
expect(parentFolderFile('Space/Parent/Sub/Child.md')).toBe(
'Space/Parent/Sub.md',
'Space/Parent/Sub/Sub.md',
);
});
it('handles names CONTAINING DOTS without splitting on the dot', () => {
// A dot in a folder/file segment must not be mistaken for the path split.
// The split is purely on the LAST '/', so the `.md` is appended to the whole
// parent dir verbatim (dots and all).
expect(parentFolderFile('Space/v1.2.3/Child.md')).toBe('Space/v1.2.3.md');
expect(parentFolderFile('a.b/c.d.md')).toBe('a.b.md');
// The split is purely on the LAST '/', so the folder-note is the dotted
// folder name repeated inside it (dots and all).
expect(parentFolderFile('Space/v1.2.3/Child.md')).toBe('Space/v1.2.3/v1.2.3.md');
expect(parentFolderFile('a.b/c.d.md')).toBe('a.b/a.b.md');
// A dotted root-level name still has no enclosing folder.
expect(parentFolderFile('v1.2.3.md')).toBeNull();
});
@@ -271,10 +282,7 @@ describe('applyPushActions (push.ts) — move prefetch isolation', () => {
// The update file exists and is readable; the move's NEW-path tree reads
// throw (simulating an unreadable/missing parent folder file at `current`).
const store: Record<string, string> = {
'Up.md': serializeDocmostMarkdownBody(
{ version: 1, pageId: 'u1', title: 'U', spaceId: 'sp' } as any,
'body',
),
'Up.md': serializePageFile('u1', 'body'),
};
const deps: ApplyPushDeps = {
client,
@@ -284,6 +292,7 @@ describe('applyPushActions (push.ts) — move prefetch isolation', () => {
throw new Error(`unreadable ${p}`);
}),
writeFile: vi.fn(async () => {}),
spaceId: 'sp',
};
const actions: PushActions = {
creates: [],
@@ -302,7 +311,7 @@ describe('applyPushActions (push.ts) — move prefetch isolation', () => {
expect(res.deleted).toBe(1);
expect(client.importPageMarkdown).toHaveBeenCalledWith(
'u1',
store['Up.md'],
parsePageFile(store['Up.md']).body,
null,
);
expect(client.deletePage).toHaveBeenCalledWith('d1');