From 5826255a2fe6284b69e0c2347c8e49c747031848 Mon Sep 17 00:00:00 2001 From: vvzvlad Date: Sun, 21 Jun 2026 02:30:46 +0300 Subject: [PATCH] feat(sync): runnable FS->Docmost push (dry-run default, --apply writes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the push cycle (SPEC §6) into a runnable command; SAFE BY DEFAULT. - runPush + main(): dry-run by default (plan only, ZERO Docmost writes, no ref advance); --apply is the ONLY path that builds a client and mutates Docmost - orchestration mirrors pull.ts: assertGitAvailable -> ensureRepo -> merge-in-progress guard (§9/§12) -> checkout main -> commit local working tree (Docmost-Sync-Source: local, §7.3) -> base = refs/docmost/last-pushed else docmost -> diffNameStatus(base, main) -> computePushActions -> (apply) -> write-back created pageIds + advance refs; divergent-docmost escalates (exit 1) - npm run push (dry-run) / npm run push -- --apply (writes; needs creds) - fix (review Blocker): pass the WHOLE VaultGit to applyPushActions (bare method refs lost `this` -> --apply crashed on real git); regression test exercises the --apply path against a REAL VaultGit temp repo + fake client (proven to catch it) - symmetric divergent-docmost escalation in both ff branches; dry-run logs the local commit explicitly; SPEC §6 notes the dry-run/local-commit behavior - 737 -> 747 green (x2 stable); build clean; corpus STABLE Deferred (daemon increment): FS-watcher/debounce (§7.1), git-remote push (§7.2), continuous poll loop, pull-side §10 record consumption, fractional-index position. --- SPEC.md | 5 + package.json | 3 +- src/push.ts | 484 +++++++++++++++++++++++++++++++++- test/run-push-realgit.test.ts | 142 ++++++++++ test/run-push.test.ts | 398 ++++++++++++++++++++++++++++ 5 files changed, 1025 insertions(+), 7 deletions(-) create mode 100644 test/run-push-realgit.test.ts create mode 100644 test/run-push.test.ts diff --git a/SPEC.md b/SPEC.md index 061113e..bf0aa74 100644 --- a/SPEC.md +++ b/SPEC.md @@ -142,6 +142,11 @@ rename-detection, тумбстоны удалений и перенос межд 3. Двигаем `refs/docmost/last-pushed`; фастфорвардим `docmost` (Docmost это уже содержит), записываем полученный `updatedAt` (§10). +> `npm run push` — это **dry-run** (план без записи в Docmost), если не передан +> `--apply`. В любом режиме он коммитит ожидающие локальные правки на `main` +> (это нужно, чтобы посчитать diff `base..main`); такой коммит локальный и в +> Docmost ничего не отправляет. + --- ## 7. Политика «push в репу после каждого изменения» diff --git a/package.json b/package.json index 4356bf2..7acc39b 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "test:watch": "vitest", "coverage": "vitest run --coverage --coverage.provider=v8 --coverage.include='src/**/*.ts' --coverage.include='packages/docmost-client/src/**/*.ts'", "roundtrip": "node build/roundtrip.js", - "pull": "node build/pull.js" + "pull": "node build/pull.js", + "push": "node build/push.js" }, "dependencies": { "docmost-client": "*", diff --git a/src/push.ts b/src/push.ts index cc70351..31e9776 100644 --- a/src/push.ts +++ b/src/push.ts @@ -36,19 +36,31 @@ * `main` and triggers a push. * - TODO(next-increment): `git push` to the git remote (SPEC §6 step 1/§7.2, * pull-rebase-push with retry). - * - TODO(next-increment): a runnable live `main()` wired to a real Docmost. - * There is deliberately NO CLI entrypoint in this file: nothing here can run - * a destructive write against a real Docmost. `applyPushActions` is reached - * only through tests with fakes. + * - TODO(next-increment): a continuous poll loop and the pull-side consumption + * of the §10 loop-guard `pushed` record (the git-native ff-`docmost` loop + * close below already breaks the content loop). + * + * RUNNABLE — `runPush` + `main()` (this increment). The push orchestration is now + * wired into a runnable command, SAFE BY DEFAULT: a DRY-RUN (plan only, NO + * Docmost writes, NO ref advance) is the default; `--apply` is the ONLY path that + * builds a `DocmostClient` and mutates Docmost. Every external effect is injected + * (`PushDeps`), so the whole cycle is still exercised by FAKES in tests. + * Run via: npm run push (DRY-RUN — plan only) + * npm run push -- --apply (writes to Docmost; needs real creds) */ -import type { DocmostClient } from "docmost-client"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { DocmostClient } from "docmost-client"; import { parseDocmostMarkdown, serializeDocmostMarkdownBody, type DocmostMdMeta, } from "docmost-client"; -import type { DiffEntry, VaultGit } from "./git.js"; +import type { DiffEntry } from "./git.js"; +import { VaultGit, DEFAULT_BRANCH } from "./git.js"; import { bodyHash } from "./loop-guard.js"; +import { loadSettings, type Settings } from "./settings.js"; // Re-export so callers/tests can import the diff row shape from either module. export type { DiffEntry } from "./git.js"; @@ -871,3 +883,463 @@ function extractUpdatedAt(result: unknown): { updatedAt?: string } { 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. + */ +export const LOCAL_AUTHOR_NAME = "Local"; +export const LOCAL_AUTHOR_EMAIL = "local@local"; + +/** The provenance trailer marking a `main`-side (human/local) commit (SPEC §7.3). */ +export const LOCAL_SOURCE_TRAILER = "Docmost-Sync-Source: local"; + +/** + * Injectable deps for `runPush` (mirrors `pull.ts`'s wiring; everything that + * touches the outside world is here so tests pass fakes). `makeClient` is a + * FACTORY, not a client — a dry-run must build NO client at all (it is never + * called), and only `--apply` invokes it. + */ +export interface PushDeps { + settings: Settings; + git: Pick< + VaultGit, + | "assertGitAvailable" + | "ensureRepo" + | "isMergeInProgress" + | "checkout" + | "stageAll" + | "commit" + | "readRef" + | "revParse" + | "diffNameStatus" + | "showFileAtRef" + | "updateRef" + | "fastForwardBranch" + >; + /** Build a real `DocmostClient` — called ONLY on `--apply`, never on dry-run. */ + makeClient: (settings: Settings) => ApplyPushDeps["client"]; + /** Read a file's full text by its vault-relative (forward-slash) path. */ + readFile: (path: string) => Promise; + /** Write a file's full text by its vault-relative path. */ + writeFile: (path: string, text: string) => Promise; + /** Structured logger (defaults to console in `main`; a recorder in tests). */ + log: (line: string) => void; +} + +/** The structured outcome of a `runPush` cycle (returned + summarized). */ +export interface PushRunResult { + /** Which path ran: `dry-run` (plan only) or `apply` (Docmost mutated). */ + mode: "dry-run" | "apply"; + /** Why the cycle stopped before planning, if it did (e.g. a left-over merge). */ + aborted?: "merge-in-progress"; + /** The diff base the plan was computed against (`last-pushed` else `docmost`). */ + base?: { ref: string; source: "last-pushed" | "docmost"; sha: string | null }; + /** The `main` commit the plan targets (the would-be pushed commit). */ + pushedCommit?: string; + /** Planned action counts from the PURE planner (present once a plan was built). */ + planned?: { + creates: number; + updates: number; + deletes: number; + renamesMoves: number; + skipped: number; + }; + /** The applier's structured result — ONLY present on the `--apply` path. */ + applied?: ApplyPushResult; + /** + * True when `applyPushActions` REFUSED to fast-forward a divergent `docmost` + * mirror (SPEC §5 invariant broken). Escalated (logged prominently) and folded + * into the CLI's non-zero exit. + */ + divergentDocmost?: boolean; + /** Per-page failures from the applier (empty/absent on a clean run). */ + failures?: PushFailure[]; +} + +/** + * 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. + */ +export async function runPush( + deps: PushDeps, + opts: { dryRun: boolean }, +): Promise { + 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(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: LOCAL_AUTHOR_NAME, + authorEmail: LOCAL_AUTHOR_EMAIL, + trailers: [LOCAL_SOURCE_TRAILER], + }); + if (committedWorkingTree) { + const sha = await git.revParse(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: { ref: string; source: "last-pushed" | "docmost"; sha: string | null }; + const lastPushedSha = await git.readRef(LAST_PUSHED_REF); + if (lastPushedSha) { + base = { ref: LAST_PUSHED_REF, source: "last-pushed", sha: lastPushedSha }; + } else { + base = { + ref: DOCMOST_BRANCH, + source: "docmost", + sha: await git.revParse(DOCMOST_BRANCH), + }; + } + const pushedCommit = await git.revParse(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, 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: string, side: MetaSide): DocmostMdMeta | null => + 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: LOCAL_AUTHOR_NAME, + authorEmail: LOCAL_AUTHOR_EMAIL, + trailers: [LOCAL_SOURCE_TRAILER], + }); + if (recorded) { + const newCommit = await git.revParse(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(LAST_PUSHED_REF, newCommit); + const ff = await git.fastForwardBranch(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: Pick, + path: string, +): Promise { + let text: string; + try { + text = await deps.readFile(path); + } catch { + return null; // absent on disk (e.g. a D row's path) -> no current meta. + } + try { + return 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: Pick, + baseRef: string, + path: string, +): Promise { + let text: string | null; + try { + text = await deps.git.showFileAtRef(baseRef, path); + } catch { + return null; + } + if (text === null) return null; // path absent at the base ref. + try { + return parseDocmostMarkdown(text).meta ?? null; + } catch { + return null; + } +} + +/** Emit the full plan (counts + per-item) to the injected logger. */ +function logPlan( + log: (line: string) => void, + base: { ref: string; source: string; sha: string | null }, + pushedCommit: string, + actions: PushActions, + planned: PushRunResult["planned"], + dryRun: boolean, +): void { + 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}`); +} + +// --- CLI entrypoint ---------------------------------------------------------- + +/** Parsed `push` CLI flags. DRY-RUN is the default; `--apply` opts into writes. */ +export interface PushParsedArgs { + /** True when `--apply` was passed (the ONLY path that writes to Docmost). */ + apply: boolean; +} + +/** + * 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. + */ +export function parseArgs(argv: string[]): PushParsedArgs { + return { apply: argv.includes("--apply") }; +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + const settings = loadSettings(); + const git = new VaultGit(settings.vaultPath); + + const log = (line: string): void => { + console.log(line); + }; + + const result = await runPush( + { + settings, + git, + // The client is built ONLY when `runPush` reaches the `--apply` path; a + // dry-run never calls this factory, so it never authenticates to Docmost. + makeClient: (s) => + new DocmostClient(s.docmostApiUrl, s.docmostEmail, s.docmostPassword), + readFile: (path) => readFile(join(settings.vaultPath, ...path.split("/")), "utf8"), + writeFile: async (path, text) => { + await writeFile(join(settings.vaultPath, ...path.split("/")), text, "utf8"); + }, + log, + }, + { dryRun: !args.apply }, + ); + + if (result.aborted) { + process.exitCode = 1; + return; + } + if (result.mode === "dry-run") { + log("push: DRY-RUN — re-run with --apply to write these changes to Docmost."); + } + // Non-zero on any per-page failure or a divergent-`docmost` escalation. + process.exitCode = + (result.failures?.length ?? 0) > 0 || result.divergentDocmost ? 1 : 0; +} + +// Only auto-run as the CLI entrypoint, not when imported by a unit test (so the +// import never triggers loadSettings() / git / network). Mirrors pull.ts. +const invokedDirectly = + typeof process.argv[1] === "string" && + import.meta.url === pathToFileURL(process.argv[1]).href; + +if (invokedDirectly) { + main().catch((err) => { + console.error("push failed:", err instanceof Error ? err.stack : err); + process.exit(1); + }); +} diff --git a/test/run-push-realgit.test.ts b/test/run-push-realgit.test.ts new file mode 100644 index 0000000..2d8739f --- /dev/null +++ b/test/run-push-realgit.test.ts @@ -0,0 +1,142 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { runPush, LAST_PUSHED_REF } from '../src/push.js'; +import type { PushDeps } from '../src/push.js'; +import { VaultGit } from '../src/git.js'; +import type { Settings } from '../src/settings.js'; +import { serializeDocmostMarkdownBody } from '../packages/docmost-client/src/lib/markdown-document.js'; + +const execFileAsync = promisify(execFile); + +// runPush `--apply` against a REAL VaultGit in a temp repo (NO Docmost — the +// client is faked). This guards the real-git BINDING contract that the plain- +// object git fakes in run-push.test.ts cannot catch: the applier's git deps +// (`updateRef`/`fastForwardBranch`/`showFileAtRef`) call `this.run`/`this.runRaw` +// internally, so they only work when their `this` receiver is preserved. Passing +// bare method references (`git.updateRef`, …) would throw `this.runRaw is not a +// function` here. Only the LOCAL temp git is mutated; nothing is sent to Docmost. + +/** True if a usable `git` binary is on PATH (skip the suite otherwise). */ +async function gitAvailable(): Promise { + try { + await execFileAsync('git', ['--version']); + return true; + } catch { + return false; + } +} + +/** A minimal valid Settings fixture (only fields runPush reads matter). */ +function makeSettings(vaultPath: string): Settings { + return { + docmostApiUrl: 'https://docmost.example.com', + docmostEmail: 'you@example.com', + docmostPassword: 'secret', + docmostSpaceId: 'space-1', + vaultPath, + pollIntervalMs: 15000, + debounceMs: 2000, + logLevel: 'info', + }; +} + +/** A recording client fake; createPage returns an assigned id + updatedAt. */ +function makeClientFake() { + return { + importPageMarkdown: vi.fn(async () => ({ + data: { updatedAt: '2026-06-20T00:00:00.000Z' }, + success: true, + })), + createPage: vi.fn(async (title: string) => ({ + data: { id: 'new-id', title, updatedAt: '2026-06-20T00:00:00.000Z' }, + success: true, + })), + deletePage: vi.fn(async () => ({ success: true })), + movePage: vi.fn(async () => ({ success: true })), + renamePage: vi.fn(async () => ({ success: true })), + }; +} + +describe('runPush --apply against a REAL VaultGit (binding contract)', () => { + let available = false; + let dir: string; + + beforeAll(async () => { + available = await gitAvailable(); + }); + + afterEach(async () => { + if (dir) { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('writes through real git: createPage runs, last-pushed advances, no throw', async () => { + if (!available) return; // skip gracefully when git is unavailable + + // Temp vault repo under the OS tmpdir (mirrors test/git.test.ts setup). + dir = await mkdtemp(join(tmpdir(), 'docmost-push-realgit-')); + const vault = dir; + const git = new VaultGit(vault); + await git.ensureRepo(); + // The `docmost` mirror branches off `main` at the initial commit; this is + // also the diff base (last-pushed is unset, so runPush falls back to it). + await git.ensureBranch('docmost', 'main'); + + // A brand-new local file with meta carrying title + spaceId but NO pageId, + // committed on `main` AHEAD of the base -> computePushActions yields a CREATE. + const newFile = serializeDocmostMarkdownBody( + { version: 1, title: 'New', spaceId: 'sp-1' }, + 'fresh body', + ); + await writeFile(join(vault, 'New.md'), newFile, 'utf8'); + await git.stageAll(); + await git.commit('add New.md', { + authorName: 'Human', + authorEmail: 'human@local', + }); + + // last-pushed must be UNSET so the run actually advances it for the first time. + expect(await git.revParse(LAST_PUSHED_REF)).toBeNull(); + + const client = makeClientFake(); + const logs: string[] = []; + const deps: PushDeps = { + settings: makeSettings(vault), + // The WHOLE real VaultGit — its methods must keep their `this` binding. + git, + makeClient: () => client as any, + readFile: (path) => + import('node:fs/promises').then((fs) => + fs.readFile(join(vault, ...path.split('/')), 'utf8'), + ), + writeFile: async (path, text) => { + const fs = await import('node:fs/promises'); + await fs.writeFile(join(vault, ...path.split('/')), text, 'utf8'); + }, + log: (line) => logs.push(line), + }; + + // The run must NOT throw — this is what FAILS before Fix 1 (the bare-method + // git deps would throw `this.runRaw is not a function` on the real VaultGit). + const res = await runPush(deps, { dryRun: false }); + + expect(res.mode).toBe('apply'); + expect(res.failures).toEqual([]); + // The FAKE client was actually called (the write path ran). + expect(client.createPage).toHaveBeenCalledTimes(1); + expect(res.applied?.created).toBe(1); + // The assigned pageId was written back to disk + committed. + expect(res.applied?.writtenBack).toEqual([{ path: 'New.md', pageId: 'new-id' }]); + + // CRITICALLY: refs/docmost/last-pushed ACTUALLY advanced in the real repo — + // it now resolves to a real commit (proving updateRef ran with binding). + const lastPushed = await git.revParse(LAST_PUSHED_REF); + expect(lastPushed).toMatch(/^[0-9a-f]{40}$/); + expect(res.divergentDocmost).toBe(false); + }); +}); diff --git a/test/run-push.test.ts b/test/run-push.test.ts new file mode 100644 index 0000000..ae42d0c --- /dev/null +++ b/test/run-push.test.ts @@ -0,0 +1,398 @@ +import { describe, expect, it, vi } from 'vitest'; +import { runPush, LAST_PUSHED_REF, DOCMOST_BRANCH } from '../src/push.js'; +import type { PushDeps } from '../src/push.js'; +import type { Settings } from '../src/settings.js'; +import { serializeDocmostMarkdownBody } from '../packages/docmost-client/src/lib/markdown-document.js'; + +// runPush orchestration (SPEC §6 "ФС → Docmost"), DRY-RUN BY DEFAULT. Driven by +// FAKES only — no live Docmost, git, fs, or network. Asserts the SAFE-BY-DEFAULT +// contract: a dry-run builds NO client, makes ZERO Docmost calls, advances NO +// refs; `--apply` is the ONLY path that writes. Also covers the merge-in-progress +// abort, the divergent-`docmost` escalation, and the base selection fallback. + +/** A minimal valid Settings fixture (only fields runPush reads matter). */ +function makeSettings(): Settings { + return { + docmostApiUrl: 'https://docmost.example.com', + docmostEmail: 'you@example.com', + docmostPassword: 'secret', + docmostSpaceId: 'space-1', + vaultPath: '/vault', + pollIntervalMs: 15000, + debounceMs: 2000, + logLevel: 'info', + }; +} + +/** + * A recording git fake covering exactly the `PushDeps['git']` surface. Options + * configure the diff rows, which refs resolve, and what the ff returns. + */ +function makeGit(opts?: { + mergeInProgress?: boolean; + lastPushed?: string | null; + docmostSha?: string | null; + mainSha?: string; + /** Diff rows returned by diffNameStatus(base, main). */ + changes?: { status: 'A' | 'M' | 'D' | 'R' | 'C'; path: string; oldPath?: string }[]; + /** Pre-image tree at the base ref (path -> text) for showFileAtRef. */ + prevTree?: Record; + ffResult?: { ok: boolean; reason?: string }; + /** When set, commit returns this per call (queue); defaults to always-true. */ + commitResults?: boolean[]; +}) { + const calls = { + assertGitAvailable: 0, + ensureRepo: 0, + checkout: [] as string[], + stageAll: 0, + commit: [] as string[], + updateRef: [] as { ref: string; target: string }[], + fastForwardBranch: [] as { branch: string; toCommit: string }[], + diffNameStatus: [] as { from: string; to: string }[], + }; + const prevTree = opts?.prevTree ?? {}; + const commitQueue = [...(opts?.commitResults ?? [])]; + let mainSha = opts?.mainSha ?? 'main-sha-1'; + + const git: PushDeps['git'] = { + assertGitAvailable: vi.fn(async () => { + calls.assertGitAvailable++; + }), + ensureRepo: vi.fn(async () => { + calls.ensureRepo++; + }), + isMergeInProgress: vi.fn(async () => opts?.mergeInProgress ?? false), + checkout: vi.fn(async (name: string) => { + calls.checkout.push(name); + }), + stageAll: vi.fn(async () => { + calls.stageAll++; + }), + commit: vi.fn(async (subject: string) => { + calls.commit.push(subject); + return commitQueue.length > 0 ? (commitQueue.shift() as boolean) : true; + }), + readRef: vi.fn(async (ref: string) => + ref === LAST_PUSHED_REF ? (opts?.lastPushed ?? null) : null, + ), + revParse: vi.fn(async (ref: string) => { + if (ref === DOCMOST_BRANCH) return opts?.docmostSha ?? null; + if (ref === 'main') return mainSha; + return null; + }), + diffNameStatus: vi.fn(async (from: string, to: string) => { + calls.diffNameStatus.push({ from, to }); + return opts?.changes ?? []; + }), + showFileAtRef: vi.fn(async (_ref: string, path: string) => + path in prevTree ? prevTree[path] : null, + ), + updateRef: vi.fn(async (ref: string, target: string) => { + calls.updateRef.push({ ref, target }); + }), + fastForwardBranch: vi.fn(async (branch: string, toCommit: string) => { + calls.fastForwardBranch.push({ branch, toCommit }); + return opts?.ffResult ?? { ok: true }; + }), + }; + return { + git, + calls, + /** Advance the fake `main` HEAD (so a write-back commit yields a new sha). */ + setMainSha: (sha: string) => { + mainSha = sha; + }, + }; +} + +/** A recording client fake; createPage returns a configurable assigned id. */ +function makeClientFake(opts?: { createId?: string }) { + return { + importPageMarkdown: vi.fn(async () => ({ success: true })), + createPage: vi.fn(async (title: string) => ({ + data: { id: opts?.createId ?? 'assigned-id', title }, + success: true, + })), + deletePage: vi.fn(async () => ({ success: true })), + movePage: vi.fn(async () => ({ success: true })), + renamePage: vi.fn(async () => ({ success: true })), + }; +} + +/** A recording fs fake over a path->text store. */ +function makeFs(initial: Record = {}) { + const store: Record = { ...initial }; + const reads: string[] = []; + const writes: { path: string; text: string }[] = []; + return { + store, + reads, + writes, + readFile: vi.fn(async (path: string) => { + reads.push(path); + if (!(path in store)) throw new Error(`no such file: ${path}`); + return store[path]; + }), + writeFile: vi.fn(async (path: string, text: string) => { + store[path] = text; + writes.push({ path, text }); + }), + }; +} + +/** Assemble PushDeps with a recording logger and a makeClient FACTORY spy. */ +function makeDeps( + git: PushDeps['git'], + fs: ReturnType, + client?: ReturnType, +) { + const logs: string[] = []; + const makeClient = vi.fn(() => (client ?? makeClientFake()) as any); + const deps: PushDeps = { + settings: makeSettings(), + git, + makeClient, + readFile: fs.readFile, + writeFile: fs.writeFile, + log: (line) => logs.push(line), + }; + return { deps, logs, makeClient }; +} + +describe('runPush — dry-run is the DEFAULT (safe)', () => { + it('logs a plan, builds NO client, makes ZERO Docmost calls, advances NO refs', async () => { + const file = + '\n\nedited body\n'; + const { git, calls } = makeGit({ + lastPushed: 'base-sha', + changes: [{ status: 'M', path: 'Doc.md' }], + }); + const fs = makeFs({ 'Doc.md': file }); + const { deps, logs, makeClient } = makeDeps(git, fs); + + const res = await runPush(deps, { dryRun: true }); + + expect(res.mode).toBe('dry-run'); + expect(res.planned).toEqual({ + creates: 0, + updates: 1, + deletes: 0, + renamesMoves: 0, + skipped: 0, + }); + // The client FACTORY was never invoked -> zero Docmost contact. + expect(makeClient).not.toHaveBeenCalled(); + // No ref advance, no mirror ff. + expect(calls.updateRef).toEqual([]); + expect(calls.fastForwardBranch).toEqual([]); + // A plan WAS logged (counts + the per-item update line). + expect(logs.join('\n')).toMatch(/DRY-RUN/); + expect(logs.join('\n')).toMatch(/update: p-1 \(Doc\.md\)/); + // It still diffs the base against main and works on main. + expect(calls.diffNameStatus).toEqual([{ from: LAST_PUSHED_REF, to: 'main' }]); + expect(calls.checkout).toEqual(['main']); + }); + + it('commits the working tree with the local provenance trailer before diffing', async () => { + const { git, calls } = makeGit({ lastPushed: 'base-sha' }); + const fs = makeFs(); + const { deps } = makeDeps(git, fs); + + await runPush(deps, { dryRun: true }); + + // The first commit is the human working-tree commit on main (SPEC §7.3). + expect(calls.commit[0]).toBe('local: working-tree changes'); + expect(calls.stageAll).toBeGreaterThanOrEqual(1); + const trailerArg = (git.commit as any).mock.calls[0][1]; + expect(trailerArg.trailers).toEqual(['Docmost-Sync-Source: local']); + }); +}); + +describe('runPush — --apply is the ONLY write path', () => { + it('builds the client, calls applyPushActions, records created pageIds, advances last-pushed', async () => { + // A brand-new local file: meta has title + spaceId but NO pageId yet. + const newFile = serializeDocmostMarkdownBody( + { version: 1, title: 'New', spaceId: 'sp-1' }, + 'fresh body', + ); + const { git, calls, setMainSha } = makeGit({ + lastPushed: 'base-sha', + mainSha: 'main-1', + changes: [{ status: 'A', path: 'New.md' }], + }); + const fs = makeFs({ 'New.md': newFile }); + const client = makeClientFake({ createId: 'page-new' }); + const { deps, makeClient } = makeDeps(git, fs, client); + // After the write-back commit, `main` moves to a new commit. + (git.commit as any).mockImplementation(async (subject: string) => { + calls.commit.push(subject); + if (subject === 'local: record created pageIds') setMainSha('main-2'); + return true; + }); + + const res = await runPush(deps, { dryRun: false }); + + expect(res.mode).toBe('apply'); + // The client factory WAS used and createPage ran (the write path). + expect(makeClient).toHaveBeenCalledTimes(1); + expect(client.createPage).toHaveBeenCalledTimes(1); + expect(res.applied?.created).toBe(1); + // The assigned pageId was written back into the file on disk. + expect(res.applied?.writtenBack).toEqual([{ path: 'New.md', pageId: 'page-new' }]); + expect(fs.store['New.md']).toMatch(/page-new/); + // A "record created pageIds" commit persisted the write-back. + expect(calls.commit).toContain('local: record created pageIds'); + // last-pushed was advanced — first by the applier (main-1), then re-advanced + // to the write-back commit (main-2). + const lastPushedAdvances = calls.updateRef.filter( + (u) => u.ref === LAST_PUSHED_REF, + ); + expect(lastPushedAdvances.map((u) => u.target)).toEqual(['main-1', 'main-2']); + expect(res.divergentDocmost).toBe(false); + expect(res.failures).toEqual([]); + }); + + it('ESCALATES a divergent docmost mirror in the write-back branch too (SPEC §5, symmetric)', async () => { + // A create -> the pageId is written back and a "record created pageIds" + // commit is made, which triggers the write-back-branch ff. Here the applier's + // MAIN push ff succeeds (ok) but the WRITE-BACK ff diverges — the write-back + // branch must escalate identically to the main branch (set divergentDocmost, + // log the same prominent WARNING), so main() exits 1. + const newFile = serializeDocmostMarkdownBody( + { version: 1, title: 'New', spaceId: 'sp-1' }, + 'fresh body', + ); + const { git, calls, setMainSha } = makeGit({ + lastPushed: 'base-sha', + mainSha: 'main-1', + changes: [{ status: 'A', path: 'New.md' }], + }); + const fs = makeFs({ 'New.md': newFile }); + const client = makeClientFake({ createId: 'page-new' }); + const { deps, logs } = makeDeps(git, fs, client); + (git.commit as any).mockImplementation(async (subject: string) => { + calls.commit.push(subject); + if (subject === 'local: record created pageIds') setMainSha('main-2'); + return true; + }); + // First ff (applier 7b, main push) is OK; second ff (write-back) DIVERGES. + let ffCall = 0; + (git.fastForwardBranch as any).mockImplementation( + async (branch: string, toCommit: string) => { + calls.fastForwardBranch.push({ branch, toCommit }); + ffCall++; + return ffCall === 1 + ? { ok: true } + : { ok: false, reason: 'not-fast-forward' }; + }, + ); + + const res = await runPush(deps, { dryRun: false }); + + // The apply still happened, but the write-back divergence is escalated. + expect(res.applied?.created).toBe(1); + expect(res.divergentDocmost).toBe(true); + // The SAME prominent WARNING (DIVERGED + §5) — not a soft warning. + expect(logs.join('\n')).toMatch(/WARNING/); + expect(logs.join('\n')).toMatch(/DIVERGED/); + expect(logs.join('\n')).toMatch(/write-back/); + }); + + it('an update goes through importPageMarkdown (collab path)', async () => { + const file = + '\n\nbody\n'; + const { git } = makeGit({ + lastPushed: 'base-sha', + changes: [{ status: 'M', path: 'Doc.md' }], + }); + const fs = makeFs({ 'Doc.md': file }); + const client = makeClientFake(); + const { deps } = makeDeps(git, fs, client); + + const res = await runPush(deps, { dryRun: false }); + + expect(client.importPageMarkdown).toHaveBeenCalledWith('p-9', file); + expect(res.applied?.updated).toBe(1); + }); +}); + +describe('runPush — merge-in-progress aborts (SPEC §9/§12)', () => { + it('stops with a clear message, no diff, no client, no apply', async () => { + const { git, calls } = makeGit({ mergeInProgress: true }); + const fs = makeFs(); + const { deps, logs, makeClient } = makeDeps(git, fs); + + const res = await runPush(deps, { dryRun: false }); + + expect(res.aborted).toBe('merge-in-progress'); + // Never diffed, never built a client, never checked out / committed. + expect(calls.diffNameStatus).toEqual([]); + expect(makeClient).not.toHaveBeenCalled(); + expect(calls.checkout).toEqual([]); + expect(logs.join('\n')).toMatch(/unresolved merge/); + expect(logs.join('\n')).toMatch(/SPEC §9/); + }); +}); + +describe('runPush — divergent docmost escalation (SPEC §5)', () => { + it('sets the escalation flag and logs a WARNING, but the apply still happened', async () => { + const file = + '\n\nbody\n'; + const { git } = makeGit({ + lastPushed: 'base-sha', + changes: [{ status: 'M', path: 'Doc.md' }], + // The applier refuses to clobber a divergent mirror. + ffResult: { ok: false, reason: 'not-fast-forward' }, + }); + const fs = makeFs({ 'Doc.md': file }); + const client = makeClientFake(); + const { deps, logs } = makeDeps(git, fs, client); + + const res = await runPush(deps, { dryRun: false }); + + // The apply STILL happened (the page was updated)... + expect(res.applied?.updated).toBe(1); + expect(client.importPageMarkdown).toHaveBeenCalledTimes(1); + // ...but the divergence is escalated, not silent. + expect(res.divergentDocmost).toBe(true); + expect(logs.join('\n')).toMatch(/WARNING/); + expect(logs.join('\n')).toMatch(/DIVERGED/); + }); +}); + +describe('runPush — base selection (last-pushed else docmost)', () => { + it('uses refs/docmost/last-pushed when it resolves', async () => { + const { git, calls } = makeGit({ lastPushed: 'lp-sha' }); + const fs = makeFs(); + const { deps } = makeDeps(git, fs); + + const res = await runPush(deps, { dryRun: true }); + + expect(res.base).toEqual({ + ref: LAST_PUSHED_REF, + source: 'last-pushed', + sha: 'lp-sha', + }); + expect(calls.diffNameStatus[0].from).toBe(LAST_PUSHED_REF); + }); + + it('falls back to the docmost branch when last-pushed is missing', async () => { + const { git, calls } = makeGit({ + lastPushed: null, // last-pushed does not resolve -> fall back. + docmostSha: 'doc-sha', + }); + const fs = makeFs(); + const { deps } = makeDeps(git, fs); + + const res = await runPush(deps, { dryRun: true }); + + expect(res.base).toEqual({ + ref: DOCMOST_BRANCH, + source: 'docmost', + sha: 'doc-sha', + }); + // The diff is taken against the docmost mirror branch. + expect(calls.diffNameStatus[0].from).toBe(DOCMOST_BRANCH); + }); +});