import { parsePageFile, serializePageFile } from "../lib/page-file.js"; import { DEFAULT_BRANCH } from "./git.js"; import { bodyHash } from "./loop-guard.js"; /** * PURE classifier for the `renamesMoves` produced by `computePushActions` * (push #3, SPEC §5/§6/§8). Resolves each `{pageId, oldPath, newPath}` into the * Docmost op(s) it needs, with NO IO (both resolvers are injected). * * SPEC §5 — the file PATH is the source of truth for tree position, NOT the * (possibly stale) `meta.parentPageId`. So the NEW parent is resolved from * `newPath`'s enclosing folder, and the OLD parent from `oldPath`'s enclosing * folder, via `deps.resolveParentPageId`. The title comes from the meta. * * For each entry: * - `newParent = resolveParentPageId(newPath, 'current')`, * `oldParent = resolveParentPageId(oldPath, 'prev')`. * - `newTitle = metaAt(newPath,'current')?.title`, * `oldTitle = metaAt(oldPath,'prev')?.title`. * - include `move` iff `newParent !== oldParent` (a real reparent), * - include `rename` iff `newTitle` is a NON-EMPTY string AND differs from * `oldTitle` (a real title edit; an empty/absent new title is never a rename), * - if NEITHER applies -> `noop: true` (a cosmetic local-only file-path rename; * the page is its pageId, so Docmost is not touched). */ export function classifyRenameMoves(renamesMoves, deps) { return renamesMoves.map((rm) => { const newParent = deps.resolveParentPageId(rm.newPath, "current"); const oldParent = deps.resolveParentPageId(rm.oldPath, "prev"); const newTitle = deps.metaAt(rm.newPath, "current")?.title; const oldTitle = deps.metaAt(rm.oldPath, "prev")?.title; const out = { pageId: rm.pageId, oldPath: rm.oldPath, newPath: rm.newPath, }; // A reparent: the new path's resolved parent page differs from the old's. if (newParent !== oldParent) { out.move = { parentPageId: newParent }; } // A title edit: only when there is a real, non-empty new title that changed. if (typeof newTitle === "string" && newTitle.length > 0 && newTitle !== oldTitle) { out.rename = { title: newTitle }; } // Neither changed -> a purely LOCAL file-path rename; do NOT call Docmost. if (!out.move && !out.rename) { out.noop = true; } return out; }); } /** * PURE push planner (SPEC §4/§6/§8). Classifies each diff row into a Docmost * action by `pageId` identity, with NO IO (the `metaAt` resolver is injected). * * Classification rules: * - `A` (added): * - current meta HAS a pageId -> UPDATE (a restored/copied file whose * page already exists; we push its content rather than create a dup). * - current meta has NO pageId but HAS a non-empty spaceId -> CREATE (a * brand-new local file; the page does not exist in Docmost yet). * - current meta has NO pageId and NO usable spaceId -> SKIP with reason * `create-without-spaceId`: Docmost `create_page` REQUIRES a spaceId * (§16), and a new local file may carry only partial human meta. We * refuse to create rather than guess a space (SPEC §8 guard spirit). * - `M` (modified): current meta has a pageId -> UPDATE content. (If a modified * file somehow lost its pageId it is skipped — there is nothing to target.) * - `D` (deleted): recover the pageId from the PRE-IMAGE meta (`metaAt(path, * 'prev')`) -> DELETE. If no pageId can be recovered, SKIP with a reason * (untracked-file guard, SPEC §8: never delete an untracked page). * - `R` (renamed/moved): same pageId (from current meta), path changed -> * RENAME/MOVE. Resolution of move-vs-rename + the new parentPageId is * DEFERRED to the next increment; here we only record oldPath/newPath/ * pageId. If the renamed file has no recoverable pageId it is SKIPPED. * (`C` copy is treated the same as `R` for recording purposes.) */ export function computePushActions(input) { const { metaAt, currentPageIds } = input; // PAGE-FILE FILTER (design §"Адопция"): only `.md` files OUTSIDE any dot-folder // are Docmost pages. `.obsidian/*`, attachments, and other non-page files are // committed to the vault (no `.gitignore`) and so appear in the diff, but they // are NEVER pages — Obsidian owns them. Without this filter every ADDED such // file would be mis-classified as a CREATE (nativeMeta always supplies a // spaceId, so the old `create-without-spaceId` skip no longer screens them), // creating junk pages in Docmost and corrupting the file with a `gitmost_id` // frontmatter. Filter BEFORE any classification so non-page A/M/D/R are ignored. const changes = input.changes.filter((c) => isPageFile(c.path)); const actions = { creates: [], updates: [], deletes: [], renamesMoves: [], skipped: [], }; // GHOST-MOVE coalescing (⭐ data-loss guard). git's rename detection (`-M`) // can miss a move when the two files are too dissimilar — which is exactly the // case for the tiny meta-only files a layout RESHUFFLE produces (e.g. // several untitled pages sharing the `_` fallback name; retitling one frees the // bare `_` and another page's file relocates `_ ~slug.md` -> `_.md`). git then // reports the move as a DELETE of the old path + an ADD of the new one. Taken // literally that soft-deletes a page that merely MOVED — a live page vanishing // into Trash. Identity is the pageId, not git's heuristic: a pageId that is // BOTH deleted (pre-image) and added (current) is one page that relocated, so // we classify it as a rename/move and NEVER as a delete. // A pageId can land at its new path two ways: as an ADD (the path was free) or // as a MODIFY (the path was occupied by ANOTHER page that left — the reshuffle // case, where `_.md`'s occupant changes pageId). Both are "the page survives at // a new path", so the surviving side is the CURRENT-meta pageId of A *and* M. const deletedPath = new Map(); const survivingPath = new Map(); for (const change of changes) { if (change.status === "D") { const pid = metaAt(change.path, "prev")?.pageId; if (pid) deletedPath.set(pid, change.path); } else if (change.status === "A" || change.status === "M") { const pid = metaAt(change.path, "current")?.pageId; if (pid) survivingPath.set(pid, change.path); } } const ghostMove = new Map(); for (const [pid, oldPath] of deletedPath) { const newPath = survivingPath.get(pid); if (newPath && newPath !== oldPath) { ghostMove.set(pid, { oldPath, newPath }); } } for (const change of changes) { switch (change.status) { case "A": { const meta = metaAt(change.path, "current"); const pageId = meta?.pageId; if (pageId && ghostMove.has(pageId)) { // Half of a git-undetected move (a matching DELETE exists): record it // as a rename/move (like a real `R`), NOT an update — the `D` side is // suppressed so the page is never soft-deleted. actions.renamesMoves.push({ pageId, oldPath: ghostMove.get(pageId).oldPath, newPath: change.path, }); } else if (pageId) { // Added but already carries a pageId (restored/copied file): the page // exists in Docmost, so push content as an UPDATE — never a duplicate. actions.updates.push({ pageId, path: change.path }); } else if (meta?.spaceId) { // Brand-new local file with a target space -> create the page, then // write the assigned pageId back into its meta (in `applyPushActions`). // `meta.spaceId` is truthy here, so empty-string is also rejected. actions.creates.push({ path: change.path }); } else { // A create needs a spaceId (Docmost `create_page` requires it, §16). A // new file with partial meta and no usable spaceId is SKIPPED rather // than created into a guessed space (SPEC §8 guard spirit). actions.skipped.push({ path: change.path, status: "A", reason: "create-without-spaceId", }); } break; } case "M": { const meta = metaAt(change.path, "current"); const pageId = meta?.pageId; if (pageId && ghostMove.has(pageId)) { // This path's occupant changed pageId: the previous page left and THIS // page relocated here (a reshuffle). Its old file was DELETED elsewhere // — coalesce into a rename/move so the page is never trashed. actions.renamesMoves.push({ pageId, oldPath: ghostMove.get(pageId).oldPath, newPath: change.path, }); } else if (pageId) { actions.updates.push({ pageId, path: change.path }); } else { // A modified file with no pageId has no Docmost target to update. actions.skipped.push({ path: change.path, status: "M", reason: "modified file has no pageId in meta", }); } break; } case "D": { // The file is gone from `main`; recover its pageId from the PRE-IMAGE // (the version last pushed to Docmost) so we delete the RIGHT page. const prevMeta = metaAt(change.path, "prev"); const pageId = prevMeta?.pageId; if (pageId && ghostMove.has(pageId)) { // The same pageId was re-ADDED at a new path: this is a git-undetected // MOVE, handled by the `A` branch above. Suppress the delete so a moved // page is never trashed (⭐ data-loss guard). actions.skipped.push({ path: change.path, status: "D", reason: "ghost-move (re-added at a new path) — not a deletion", }); } else if (pageId && currentPageIds?.has(pageId)) { // The pageId still EXISTS elsewhere in the current tree: the file moved // (a layout reshuffle whose matching add was in an earlier cycle, so it // is not in this diff). A live page must never be trashed because its // FILENAME changed — identity is the pageId (⭐ data-loss guard). actions.skipped.push({ path: change.path, status: "D", reason: "pageId still present in the tree (moved) — not a deletion", }); } else if (pageId) { actions.deletes.push({ pageId }); } else { // Untracked-file guard (SPEC §8): a file with no recoverable pageId was // never a Docmost page — do NOT translate its removal into a delete. actions.skipped.push({ path: change.path, status: "D", reason: "deleted file has no recoverable pageId (pre-image meta)", }); } break; } case "R": case "C": { // Same page, new path. Identity comes from the CURRENT (post-rename) meta // since the file still exists. RESOLUTION (move vs rename, parentPageId) // is deferred — record oldPath/newPath/pageId only. const meta = metaAt(change.path, "current"); const pageId = meta?.pageId; const oldPath = change.oldPath ?? change.path; if (pageId) { actions.renamesMoves.push({ pageId, oldPath, newPath: change.path, }); } else { actions.skipped.push({ path: change.path, status: change.status, reason: "renamed/moved file has no pageId in meta", }); } break; } default: { // Unreachable for A/M/D/R/C; defensive for any future status. actions.skipped.push({ path: change.path, status: change.status, reason: `unhandled diff status ${change.status}`, }); } } } return actions; } // --- thin apply (create/update/delete), fakes-only in this increment --------- /** The marker the push direction advances after a successful push (SPEC §5/§6). */ export const LAST_PUSHED_REF = "refs/docmost/last-pushed"; /** * The mirror branch fast-forwarded after a clean push (SPEC §5/§6 step 3). It * reflects "what Docmost currently contains"; advancing it to the pushed `main` * commit closes the loop so the next pull diffs empty for the pushed pages. */ export const DOCMOST_BRANCH = "docmost"; /** * THIN IO applier for the COMMON push cases (create/update/delete). Exercised * via FAKES only in this increment — there is no live wiring. * * - UPDATE: read the file body, then `client.importPageMarkdown(pageId, body)`. * This is the collab/Yjs write path (SPEC §2/§15.6) — NEVER a raw jsonb * overwrite. The full self-contained markdown (meta + body) is sent as-is; * `importPageMarkdown` parses the meta/body itself. * - CREATE: derive title/spaceId/parentPageId from the file's current meta, * `client.createPage(...)`, take the assigned pageId from the result, and * write it BACK as the file's `gitmost_id` frontmatter (re-serialized via * `serializePageFile`, body preserved) so the file becomes * tracked. The write-back is recorded in `writtenBack` (a follow-up commit * is needed — NEXT increment). * - DELETE: `client.deletePage(pageId)` — soft-delete to Trash (SPEC §8). * - RENAME/MOVE (push #3, SPEC §5/§6/§16): classify each `renamesMoves` entry * with `classifyRenameMoves` (resolvers read the parent FOLDER's `.md` for * the parent pageId — path-as-truth — and the meta for the title), then: * - `move` -> `client.movePage(pageId, parentPageId, position?)` (reparent; * `position` is UNDEFINED for now — the client supplies a default), * - `rename` -> `client.renamePage(pageId, title)` (title-only), * - BOTH -> move (reparent) THEN rename (title), in that order, * - `noop` -> NO client call; recorded in `noops` (a cosmetic local-only * file-path rename: the page is its pageId, the path is local, SPEC §5). * * FAIL-SAFE / per-page isolation (SPEC §12 resumability). Each page's operation * is wrapped in its own try/catch: a single failing page is recorded in * `failures[]` (with its kind + pageId/path + error) and the batch CONTINUES — * one bad page must never block the rest. Crucially, the refs are advanced ONLY * when `failures.length === 0`: a PARTIAL push must NOT advance * `refs/docmost/last-pushed` or the `docmost` mirror, so a re-run retries the * whole batch cleanly (the already-applied pages are idempotent re-applies). * * LOOP-CLOSE (SPEC §6 step 3 / §10). After a fully-successful push, when a * `pushedCommit` is supplied: * - advance `refs/docmost/last-pushed` to it (what of `main` is in Docmost), AND * - fast-forward the `docmost` mirror branch to it via * `git.fastForwardBranch('docmost', pushedCommit)` — so the mirror reflects * what Docmost now contains and the NEXT pull diffs EMPTY for these pages * (it does not re-pull our own write). The ff is REFUSED (not forced) if * `docmost` is not an ancestor of the pushed commit; the result is surfaced * in `docmostFastForward`. On ANY failure, NEITHER ref is advanced. * * LOOP-GUARD DATA (SPEC §10). For every page successfully updated/created the * result carries a `pushed` record `{ pageId, updatedAt?, bodyHash }` — the body * hash of what was pushed plus the write's `updatedAt` (when the client returned * one). A future pull-side poll-suppression consults this so it does not re-pull * our own write; producing it is in scope here, consuming it is deferred. * * @param pushedCommit The `main` commit just reflected into Docmost (SHA or * commit-ish). When omitted, NEITHER ref is advanced (e.g. a dry plan). */ export async function applyPushActions(deps, actions, pushedCommit) { const { client, git } = deps; let created = 0; let updated = 0; let deleted = 0; let moved = 0; let renamed = 0; const writtenBack = []; const pushed = []; const failures = []; const noops = []; // 1. UPDATES — collab/Yjs write path (SPEC §2/§15.6), never a raw overwrite. // Each update is isolated: a thrown page is recorded and the batch goes on. for (const u of actions.updates) { try { // Push the CLEAN body only (no `gitmost_id` frontmatter): the frontmatter // is engine metadata, never page content. The server converts the markdown // it receives verbatim, so stripping here keeps the id out of Docmost. const body = parsePageFile(await deps.readFile(u.path)).body; // The last-synced version of this file (pre-image) is the common ancestor // for a 3-way merge against the live page, so concurrent human edits are // not clobbered (review #5). Null when the file is new at last-pushed. Its // body is stripped the SAME way so the merge compares body-to-body. const baseFull = await deps.git.showFileAtRef(LAST_PUSHED_REF, u.path); const baseMarkdown = baseFull === null ? null : parsePageFile(baseFull).body; const result = await client.importPageMarkdown(u.pageId, body, baseMarkdown); updated++; // §10 loop-guard data: hash the BODY we pushed + capture `updatedAt`. pushed.push({ pageId: u.pageId, ...extractUpdatedAt(result), bodyHash: bodyHash(body), }); } catch (err) { failures.push({ kind: "update", pageId: u.pageId, path: u.path, error: errMessage(err), }); } } // 2. CREATES — create the page, then write the assigned pageId back to meta so // the file becomes tracked (SPEC §4 "записать присвоенный pageId обратно"). // Isolated per page like updates. for (const c of actions.creates) { try { const text = await deps.readFile(c.path); const { body } = parsePageFile(text); // Derive create args from the PATH (native-Obsidian, SPEC §5): title from // the filename, parent from the enclosing folder's folder-note, space from // the run (the vault's space). `parentPageId: null` -> created at ROOT. const title = titleFromPath(c.path); const parentPageId = (await resolveParentPageIdViaTree(deps, c.path, "current")) ?? undefined; const result = await client.createPage(title, body, deps.spaceId, parentPageId); // `createPage` returns `{ data: { id, ... }, success }`; the assigned // pageId is at `result.data.id`. const assignedPageId = result?.data?.id; if (assignedPageId) { // Write the assigned pageId back as the `gitmost_id` frontmatter, body // preserved — the file becomes engine-tracked (SPEC §4). const rewritten = serializePageFile(assignedPageId, body); await deps.writeFile(c.path, rewritten); writtenBack.push({ path: c.path, pageId: assignedPageId }); // §10 loop-guard data for the created page (hash the pushed BODY). pushed.push({ pageId: assignedPageId, ...extractUpdatedAt(result), bodyHash: bodyHash(body), }); } created++; } catch (err) { failures.push({ kind: "create", path: c.path, error: errMessage(err) }); } } // 3. DELETES — soft-delete to Trash (SPEC §8), reversible. Isolated per page. for (const d of actions.deletes) { try { await client.deletePage(d.pageId); deleted++; } catch (err) { failures.push({ kind: "delete", pageId: d.pageId, error: errMessage(err), }); } } // 4. RENAME/MOVE (push #3, SPEC §5/§6/§16). Classify each entry against the // tree-backed resolvers (the NEW parent comes from the new path's enclosing // folder `.md`, the OLD parent from the old path's at last-pushed — PATH is // the truth, not stale `meta.parentPageId`; the title from the meta), then // apply only the real ops. Each page is isolated like the cases above: a // thrown op is recorded in `failures` and the batch continues. ORDER for a // page that needs both: reparent (move) FIRST, then retitle (rename). if (actions.renamesMoves.length > 0) { // The classifier is PURE over sync resolvers; the tree reads are async, so // prefetch every (path, side) lookup it will make into plain tables first. const parentTable = new Map(); const metaTable = new Map(); // A tree read (readFile / git.showFileAtRef) throwing must isolate THAT page // into `failures`, NOT abort the whole batch (§12 resumability). The helpers // already swallow their own errors, but this per-entry try/catch keeps the // batch-isolation invariant holding regardless of future changes to them. const prefetchFailed = new Set(); for (const rm of actions.renamesMoves) { // newParent + newTitle from the CURRENT tree; oldParent + oldTitle from the // last-pushed pre-image (`prev`). Keyed by `path|side` so duplicates fold. try { parentTable.set(`${rm.newPath}|current`, await resolveParentPageIdViaTree(deps, rm.newPath, "current")); parentTable.set(`${rm.oldPath}|prev`, await resolveParentPageIdViaTree(deps, rm.oldPath, "prev")); metaTable.set(`${rm.newPath}|current`, await metaAtViaTree(deps, rm.newPath, "current", deps.spaceId)); metaTable.set(`${rm.oldPath}|prev`, await metaAtViaTree(deps, rm.oldPath, "prev", deps.spaceId)); } catch (err) { prefetchFailed.add(rm.pageId); failures.push({ kind: "move", pageId: rm.pageId, path: rm.newPath, error: errMessage(err), }); } } const classified = classifyRenameMoves(actions.renamesMoves.filter((rm) => !prefetchFailed.has(rm.pageId)), { metaAt: (path, side) => metaTable.get(`${path}|${side}`) ?? null, resolveParentPageId: (path, side) => parentTable.get(`${path}|${side}`) ?? null, }); for (const c of classified) { if (c.noop) { // Cosmetic local-only file-path rename — no Docmost op (SPEC §5). noops.push({ pageId: c.pageId, oldPath: c.oldPath, newPath: c.newPath, reason: "path-only-rename", }); continue; } // Track which op is in flight so a failure is attributed to the op that // ACTUALLY threw: for a page needing both, a move that succeeds then a // rename that throws must be recorded as `rename`, not `move`. let failingKind = c.move ? "move" : "rename"; try { // Reparent FIRST so the page is in its new tree position, THEN retitle. if (c.move) { failingKind = "move"; // TODO(next): compute a fractional-index position between siblings // (SPEC §16). `position` is UNDEFINED here; the client supplies a valid // default. Pass `parentPageId: null` for a move to the space ROOT. await client.movePage(c.pageId, c.move.parentPageId); moved++; } if (c.rename) { failingKind = "rename"; await client.renamePage(c.pageId, c.rename.title); renamed++; } } catch (err) { // Isolate the failed page: the op that ACTUALLY threw is recorded so a // re-run can retry. A move that threw before its rename leaves `rename` // for the next run (idempotent re-apply); refs are NOT advanced (below). failures.push({ kind: failingKind, pageId: c.pageId, path: c.newPath, error: errMessage(err), }); } } } // 5. Advance the refs ONLY on a CLEAN push (no failures) AND when a pushed // commit is supplied. A partial push must advance NEITHER ref, so a re-run // retries the whole batch (SPEC §12). The loop-close (SPEC §6 step 3 / §10): // advance `refs/docmost/last-pushed` AND fast-forward the `docmost` mirror, // so Docmost's new content is mirrored and the next pull diffs empty. let lastPushedAdvanced = false; let docmostFastForward = null; if (pushedCommit && failures.length === 0) { await git.updateRef(LAST_PUSHED_REF, pushedCommit); lastPushedAdvanced = true; // Fast-forward the mirror (refused, not forced, on a non-fast-forward — the // caller logs the reason). Surfaced in the result. docmostFastForward = await git.fastForwardBranch(DOCMOST_BRANCH, pushedCommit); } return { created, updated, deleted, moved, renamed, writtenBack, pushed, failures, noops, skipped: actions.skipped, lastPushedAdvanced, docmostFastForward, }; } /** Stringify a thrown value into a stable error message. */ function errMessage(err) { return err instanceof Error ? err.message : String(err); } /** * SPEC §5 path-as-truth: the parent FOLDER's `.md` file for a vault-relative * (forward-slash) path. `buildVaultLayout` puts a page with children at * `<...>/Title.md` and nests its children under `<...>/Title/`, so for * `newPath =