"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.LOCAL_SOURCE_TRAILER = exports.LOCAL_AUTHOR_EMAIL = exports.LOCAL_AUTHOR_NAME = exports.DOCMOST_BRANCH = exports.LAST_PUSHED_REF = void 0; exports.classifyRenameMoves = classifyRenameMoves; exports.computePushActions = computePushActions; exports.applyPushActions = applyPushActions; exports.parentFolderFile = parentFolderFile; exports.runPush = runPush; exports.parseArgs = parseArgs; /** * Push cycle — vault -> Docmost (SPEC §6 "ФС → Docmost"), FIRST increment. * * This module mirrors the structure of `./pull.ts`: a set of VaultGit diff/ref * primitives (in `./git.ts`), a PURE planner (`computePushActions`) that turns * a git diff into a classified action set with NO IO, and a THIN injectable * applier (`applyPushActions`) exercised in tests via fakes only. * * Direction is vault -> Docmost. The diff is `main` against * `refs/docmost/last-pushed` (SPEC §6 step 2); each `A`/`M`/`D`/`R` row is * translated into a Docmost mutation by `pageId` identity (SPEC §4): * - A without pageId -> create_page (then write the assigned pageId back). * - A with pageId -> update (restored/copied file; the page already exists). * - M -> update content (collab/Yjs path, SPEC §2/§15.6). * - D -> delete_page (pageId recovered from the PRE-IMAGE meta). * - R -> rename/move (CLASSIFIED here, APPLIED in push #3). * * MOVE/RENAME APPLY (push #3) — DONE here. `classifyRenameMoves` (PURE) resolves * each `renamesMoves` entry into the Docmost op(s) it needs, comparing the PATH- * derived parent (SPEC §5: the file path is the source of truth for tree * position, NOT stale `meta.parentPageId`) and the meta title; `applyPushActions` * then calls `move_page` / `rename_page` (both for a reparent+retitle), or * records a NO-OP for a cosmetic local-only file-path rename. * * VENDORED into gitmost (plan §2.1/§3.1): the client seam is the native * `GitSyncClient` (`Pick`), not the upstream REST * `DocmostClient`; the upstream CLI `main()` entry point is dropped (the gitmost * server drives the engine in-process). Engine LOGIC is byte-identical. */ const index_1 = require("../lib/index"); const git_1 = require("./git"); const loop_guard_1 = require("./loop-guard"); /** * 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). */ 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.) */ function computePushActions(input) { const { changes, metaAt } = input; const actions = { creates: [], updates: [], deletes: [], renamesMoves: [], skipped: [], }; for (const change of changes) { switch (change.status) { case "A": { const meta = metaAt(change.path, "current"); const pageId = meta?.pageId; 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) { 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) { 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). */ exports.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. */ exports.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 into the file's `docmost:meta` (re-serialized via * `serializeDocmostMarkdownBody`, 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). */ 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 { const fullMarkdown = await deps.readFile(u.path); const result = await client.importPageMarkdown(u.pageId, fullMarkdown); updated++; // §10 loop-guard data: hash the body we pushed + capture `updatedAt`. pushed.push({ pageId: u.pageId, ...extractUpdatedAt(result), bodyHash: (0, loop_guard_1.bodyHash)(fullMarkdown), }); } 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 { meta, body } = (0, index_1.parseDocmostMarkdown)(text); // Derive create args from the file's current meta. A new local file may // have partial meta (e.g. title/spaceId only); spaceId is required by // Docmost (the planner already guards a create against a missing spaceId). const title = meta?.title ?? ""; const spaceId = meta?.spaceId ?? ""; const parentPageId = meta?.parentPageId ?? undefined; const result = await client.createPage(title, body, spaceId, parentPageId); // `createPage` returns `{ data: { id, ... }, success }`; the assigned // pageId is at `result.data.id`. const assignedPageId = result?.data?.id; if (assignedPageId) { // Re-serialize the file with the pageId in meta, body preserved. const newMeta = { version: meta?.version ?? 1, ...meta, pageId: assignedPageId, }; const rewritten = (0, index_1.serializeDocmostMarkdownBody)(newMeta, 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: (0, loop_guard_1.bodyHash)(text), }); } 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")); metaTable.set(`${rm.oldPath}|prev`, await metaAtViaTree(deps, rm.oldPath, "prev")); } 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(exports.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(exports.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 = /Child.md` the parent page's file is `.md` (the enclosing * folder, one level up). A path with NO enclosing folder (`Child.md`, at the * space root) has no parent folder file -> `null` (the parent is ROOT). */ function parentFolderFile(path) { const slash = path.lastIndexOf("/"); if (slash < 0) return null; // root-level file: no enclosing folder. return `${path.slice(0, slash)}.md`; } /** * Build the `resolveParentPageId(path, side)` resolver `classifyRenameMoves` * needs, reading the PARENT FOLDER's `.md` (SPEC §5 path-as-truth): * - `current` -> `deps.readFile(.md)` (the live working tree), * - `prev` -> `git.showFileAtRef('refs/docmost/last-pushed', .md)` (the * last-pushed pre-image), * then parse its `docmost:meta` and return that page's pageId. A root-level path * (no enclosing folder), a missing/unreadable parent file, or a parent file with * no parseable pageId all resolve to `null` (parent is ROOT / unknown -> * `parentPageId: null`, SPEC §16 "parentPageId: null -> в корень"). * * The IO is async, so this returns an ASYNC resolver; the call sites prefetch the * parent pageIds (the classifier itself stays pure/sync over a plain table). */ async function resolveParentPageIdViaTree(deps, path, side) { const parentFile = parentFolderFile(path); if (parentFile === null) return null; // root-level: parent is ROOT. let text; try { text = side === "current" ? await deps.readFile(parentFile) : await deps.git.showFileAtRef(exports.LAST_PUSHED_REF, parentFile); } catch { // Parent folder file missing/unreadable at that side -> treat as ROOT. return null; } if (text === null) return null; // showFileAtRef returns null when absent. try { const { meta } = (0, index_1.parseDocmostMarkdown)(text); return meta?.pageId ?? null; } catch { // Unparseable parent meta -> no resolvable parent pageId. return null; } } /** * Resolve the file `docmost:meta` at a side for the rename/move classifier (the * title comes from here). Mirrors `resolveParentPageIdViaTree`'s IO sides: * `current` reads the working tree, `prev` reads `refs/docmost/last-pushed`. * Returns `null` on a missing/unreadable/unparseable file. */ async function metaAtViaTree(deps, path, side) { let text; try { text = side === "current" ? await deps.readFile(path) : await deps.git.showFileAtRef(exports.LAST_PUSHED_REF, path); } catch { return null; } if (text === null) return null; try { return (0, index_1.parseDocmostMarkdown)(text).meta ?? null; } catch { return null; } } /** * Pull an `updatedAt` out of a create/update client result, if present. The * shape is `{ data: { updatedAt? }, ... }` (createPage) or a flatter object; * absent in the simple fakes, so the field is omitted rather than `undefined`. */ function extractUpdatedAt(result) { const r = result; const raw = r?.data?.updatedAt ?? r?.updatedAt; return typeof raw === "string" ? { updatedAt: raw } : {}; } // --- runnable push orchestration (`runPush`) --------------------------------- // // `runPush` is the FS->Docmost twin of `pull.ts`'s `main`: it wires the VaultGit // diff/ref primitives + the PURE `computePushActions` planner + the THIN // `applyPushActions` applier into one runnable cycle. SAFE BY DEFAULT — the // engine's FIRST write path to Docmost defaults to DRY-RUN (plan only, NO // Docmost writes, NO ref advance); an explicit `--apply` is the ONLY path that // builds a client and mutates Docmost. // // Every external effect is injected (`PushDeps`) so the whole orchestration is // driven by FAKES in tests — no live Docmost, git, fs, or network. /** * The human ("local") git identity used for engine-made commits on `main` in the * push direction (SPEC §7.3). The provenance is carried by the trailer (below), * which the loop-guard keys on; the identity is for history readability only. * When the vault repo already has a configured `user.name`/`user.email`, git * uses that for the working-tree commit; this is the fallback the daemon stamps. */ exports.LOCAL_AUTHOR_NAME = "Local"; exports.LOCAL_AUTHOR_EMAIL = "local@local"; /** The provenance trailer marking a `main`-side (human/local) commit (SPEC §7.3). */ exports.LOCAL_SOURCE_TRAILER = "Docmost-Sync-Source: local"; /** * Run one FS->Docmost push cycle (SPEC §6 "ФС → Docmost"), DRY-RUN BY DEFAULT. * * Steps (mirrors `pull.ts`): * 1. Preflight git: `assertGitAvailable` + `ensureRepo`; ABORT (clear message + * non-zero-ish result) if a merge is in progress — never push on top of an * unresolved conflict (SPEC §9/§12). Conflict markers must NEVER reach * Docmost (SPEC §9). * 2. Checkout `main` (the human-facing branch the push reads from). * 3. Commit the human's pending working-tree changes on `main` with the * `local` provenance trailer (SPEC §7.3). A no-op when nothing changed. * 4. Pick the diff BASE: `refs/docmost/last-pushed` if it resolves, else the * `docmost` mirror branch (what Docmost currently has). Resolve `main`. * 5. `diffNameStatus(base, main)` -> changes; build the `metaAt(path, side)` * resolver (current = working tree, prev = `git show :`); run * the PURE `computePushActions`. * 6. DRY-RUN (default): LOG the full plan and RETURN — NO client, NO Docmost * calls, NO ref advance. * 7. `--apply`: build the client, run `applyPushActions(..., pushedCommit=main)`, * then (a) if any pageIds were written back (creates), commit them on `main` * with the `local` trailer and RE-advance `refs/docmost/last-pushed` to the * new commit so the recorded pageIds are persisted in what Docmost mirrors; * (b) ESCALATE a divergent-`docmost` ff refusal (SPEC §5) with a prominent * WARNING and a non-zero-ish flag. Then log a one-line summary. */ async function runPush(deps, opts) { const { git, settings, log } = deps; const dryRun = opts.dryRun; // 1. Preflight git. Fail fast (actionable message via main().catch) if the git // binary is missing — the vault state store relies on it. await git.assertGitAvailable(); await git.ensureRepo(); // 1b. Refuse to push on top of an unresolved merge (SPEC §9/§12). A previous // conflicting pull leaves the vault mid-merge; pushing now could leak // conflict markers into Docmost (SPEC §9, the cardinal invariant). Detect // it BEFORE any checkout/diff and stop with a clear, actionable message so // re-runs converge once the human resolves (or aborts) the merge. if (await git.isMergeInProgress()) { log(`push: vault has an unresolved merge at ${settings.vaultPath} — resolve ` + `it (or 'git merge --abort') and re-run. Nothing was pushed to Docmost ` + `(conflict markers must never reach Docmost, SPEC §9).`); return { mode: dryRun ? "dry-run" : "apply", aborted: "merge-in-progress" }; } // 2. Work on `main` — the human-facing branch the push diffs FROM. await git.checkout(git_1.DEFAULT_BRANCH); // 3. Commit the human's pending working-tree changes on `main` with the `local` // provenance trailer (SPEC §7.3). A no-op commit when nothing changed is // fine (`commit` returns false). The loop-guard keys on the trailer. // Even on a "plan only" dry-run this commits the working tree (it is the // only way to diff `base..main`, acceptable §6.1 behavior) — so make that // LOCAL git mutation VISIBLE, never silent: a created commit is local-only // and nothing is sent to Docmost. await git.stageAll(); const committedWorkingTree = await git.commit("local: working-tree changes", { authorName: exports.LOCAL_AUTHOR_NAME, authorEmail: exports.LOCAL_AUTHOR_EMAIL, trailers: [exports.LOCAL_SOURCE_TRAILER], }); if (committedWorkingTree) { const sha = await git.revParse(git_1.DEFAULT_BRANCH); log(`push: committed local working-tree changes on main` + (sha ? ` as ${sha.slice(0, 8)}` : "") + ` (local git only — nothing sent to Docmost).`); } else { log("push: working tree clean (no local changes to push)."); } // 4. Pick the diff BASE (SPEC §5/§6): `refs/docmost/last-pushed` if it resolves // (the marker of what `main` is already in Docmost), else fall back to the // `docmost` mirror branch (the mirror of what Docmost currently has) — which // is what exists before the first push ever advanced last-pushed. let base; const lastPushedSha = await git.readRef(exports.LAST_PUSHED_REF); if (lastPushedSha) { base = { ref: exports.LAST_PUSHED_REF, source: "last-pushed", sha: lastPushedSha }; } else { base = { ref: exports.DOCMOST_BRANCH, source: "docmost", sha: await git.revParse(exports.DOCMOST_BRANCH), }; } const pushedCommit = await git.revParse(git_1.DEFAULT_BRANCH); if (!pushedCommit) { // `main` has no commit — `ensureRepo` always makes an initial one, so this is // defensive. Nothing to diff. log("push: `main` has no commit to push — nothing to do."); return { mode: dryRun ? "dry-run" : "apply", base }; } // 5. Diff the base against `main` and build the `metaAt` resolver (PURE planner // input). `current` reads the live working tree; `prev` reads the base ref's // pre-image via `git show :` (so a DELETE recovers its pageId). const changes = await git.diffNameStatus(base.ref, git_1.DEFAULT_BRANCH); // Synchronous resolver over PREFETCHED meta tables: `computePushActions` is // PURE/sync, but the file/ref reads are async — so we prefetch every (path, // side) the diff will ask for into a table first, then resolve from it. const metaTable = new Map(); for (const change of changes) { // `current`: A/M/R/C still have the file on `main`. `prev`: D needs the // pre-image; R/C also benefit (old title). Prefetch both sides per path. const currentPath = change.path; const prevPath = change.oldPath ?? change.path; if (!metaTable.has(`${currentPath}|current`)) { metaTable.set(`${currentPath}|current`, await readMetaCurrent(deps, currentPath)); } if (!metaTable.has(`${prevPath}|prev`)) { metaTable.set(`${prevPath}|prev`, await readMetaPrev(deps, base.ref, prevPath)); } } const metaAt = (path, side) => metaTable.get(`${path}|${side}`) ?? null; const actions = computePushActions({ changes, metaAt }); const planned = { creates: actions.creates.length, updates: actions.updates.length, deletes: actions.deletes.length, renamesMoves: actions.renamesMoves.length, skipped: actions.skipped.length, }; // 6. DRY-RUN (default): log the full plan and RETURN — build NO client, make // ZERO Docmost calls, advance NO refs. This is the SAFE default. logPlan(log, base, pushedCommit, actions, planned, dryRun); if (dryRun) { return { mode: "dry-run", base, pushedCommit, planned }; } // 7. --apply: build the REAL client and execute. This is the ONLY write path. const client = deps.makeClient(settings); const applied = await applyPushActions({ client, // Pass the WHOLE `git` object (it satisfies the applier's // `Pick` deps surface). Passing bare method references // (`git.updateRef`, …) would lose their `this` binding, so on a REAL // `VaultGit` they would throw `this.runRaw is not a function`. Hand over // the object so the methods keep their receiver — exactly as `pull.ts` // does for `applyPullActions`. git, readFile: deps.readFile, writeFile: deps.writeFile, }, actions, pushedCommit); // 7a. Persist freshly-assigned pageIds (creates) back into git. `applyPushActions` // rewrote those files on disk; commit them on `main` with the `local` trailer // so the new pageIds are recorded, then RE-advance `refs/docmost/last-pushed` // to the new commit so what Docmost mirrors and what last-pushed points at // stay in lock-step (the write-back commit is part of `main` now). // Track a divergent-`docmost` mirror across BOTH ff sites (the applier's main // push ff in 7b, and the write-back ff here). A divergent mirror is a §5 // invariant breach in EITHER branch and must escalate identically (exit 1). let divergentDocmost = false; if (applied.writtenBack.length > 0) { await git.stageAll(); const recorded = await git.commit("local: record created pageIds", { authorName: exports.LOCAL_AUTHOR_NAME, authorEmail: exports.LOCAL_AUTHOR_EMAIL, trailers: [exports.LOCAL_SOURCE_TRAILER], }); if (recorded) { const newCommit = await git.revParse(git_1.DEFAULT_BRANCH); // Only re-advance when the original push was CLEAN (last-pushed was already // advanced by the applier); a partial push left the refs untouched and a // re-run retries the whole batch, so we must not move them either. if (newCommit && applied.lastPushedAdvanced) { await git.updateRef(exports.LAST_PUSHED_REF, newCommit); const ff = await git.fastForwardBranch(exports.DOCMOST_BRANCH, newCommit); if (!ff.ok) { // SYMMETRIC with the main escalation (7b): a divergent mirror in the // write-back branch is the SAME §5 invariant breach and must escalate // (exit 1), not just log a soft warning. divergentDocmost = true; log(`push: WARNING — the 'docmost' mirror branch DIVERGED and was NOT ` + `fast-forwarded to the pageId write-back commit ` + `(${ff.reason ?? "not-fast-forward"}). The §5 invariant ('docmost' ` + `mirrors what Docmost contains) is broken: reconcile 'docmost' ` + `against the live Docmost tree before the next cycle.`); } } } } // 7b. ESCALATE a divergent-`docmost` fast-forward refusal (SPEC §5 invariant // broken). The applier already refused to clobber a divergent mirror; make // it LOUD (not silent) so the operator notices, and fold it into the exit. if (applied.docmostFastForward && !applied.docmostFastForward.ok) { divergentDocmost = true; log(`push: WARNING — the 'docmost' mirror branch DIVERGED and was NOT ` + `fast-forwarded (${applied.docmostFastForward.reason ?? "not-fast-forward"}). ` + `The §5 invariant ('docmost' mirrors what Docmost contains) is broken: ` + `reconcile 'docmost' against the live Docmost tree before the next cycle.`); } // 7c. One-line summary (mirrors pull.ts's summary line). log(`push complete: ${applied.created} created, ${applied.updated} updated, ` + `${applied.deleted} deleted, ${applied.moved} moved, ${applied.renamed} ` + `renamed, ${applied.noops.length} no-op(s), ${applied.skipped.length} ` + `skipped, ${applied.failures.length} failure(s)` + (divergentDocmost ? " [DIVERGENT docmost mirror]" : "")); return { mode: "apply", base, pushedCommit, planned, applied, divergentDocmost, failures: applied.failures, }; } /** Parse a file's `docmost:meta` from the live working tree (`current` side). */ async function readMetaCurrent(deps, path) { let text; try { text = await deps.readFile(path); } catch { return null; // absent on disk (e.g. a D row's path) -> no current meta. } try { return (0, index_1.parseDocmostMarkdown)(text).meta ?? null; } catch { return null; // unparseable meta -> not engine-tracked. } } /** Parse a file's `docmost:meta` from the base ref's pre-image (`prev` side). */ async function readMetaPrev(deps, baseRef, path) { let text; try { text = await deps.git.showFileAtRef(baseRef, path); } catch { return null; } if (text === null) return null; // path absent at the base ref. try { return (0, index_1.parseDocmostMarkdown)(text).meta ?? null; } catch { return null; } } /** Emit the full plan (counts + per-item) to the injected logger. */ function logPlan(log, base, pushedCommit, actions, planned, dryRun) { log(`push plan (${dryRun ? "DRY-RUN — no Docmost writes" : "APPLY"}): base=` + `${base.ref} (${base.source}${base.sha ? ` ${base.sha.slice(0, 8)}` : ""}) ` + `-> main ${pushedCommit.slice(0, 8)}`); log(`push plan counts: ${planned.creates} create, ${planned.updates} update, ` + `${planned.deletes} delete, ${planned.renamesMoves} rename/move, ` + `${planned.skipped} skipped`); for (const c of actions.creates) log(` create: ${c.path}`); for (const u of actions.updates) log(` update: ${u.pageId} (${u.path})`); for (const d of actions.deletes) log(` delete: ${d.pageId}`); for (const rm of actions.renamesMoves) log(` rename/move: ${rm.oldPath} -> ${rm.newPath} (${rm.pageId})`); for (const s of actions.skipped) log(` skipped [${s.status}] ${s.path}: ${s.reason}`); } /** * Parse the `push` CLI flags. SAFE BY DEFAULT: without `--apply` the run is a * DRY-RUN (plan only). Exported so the flag handling is unit-testable. */ function parseArgs(argv) { return { apply: argv.includes("--apply") }; }