feat(sync): runnable FS->Docmost push (dry-run default, --apply writes)

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.
This commit is contained in:
vvzvlad
2026-06-21 02:30:46 +03:00
parent 2f7c0649bb
commit 5826255a2f
5 changed files with 1025 additions and 7 deletions

View File

@@ -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 в репу после каждого изменения»

View File

@@ -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": "*",

View File

@@ -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<string>;
/** Write a file's full text by its vault-relative path. */
writeFile: (path: string, text: string) => Promise<void>;
/** 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 <base>:<path>`); 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<PushRunResult> {
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 <base>:<path>` (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<string, DocmostMdMeta | null>();
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<VaultGit, ...>` 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<PushDeps, "readFile">,
path: string,
): Promise<DocmostMdMeta | null> {
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<PushDeps, "git">,
baseRef: string,
path: string,
): Promise<DocmostMdMeta | null> {
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<void> {
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);
});
}

View File

@@ -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<boolean> {
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);
});
});

398
test/run-push.test.ts Normal file
View File

@@ -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<string, string>;
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<string, string> = {}) {
const store: Record<string, string> = { ...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<typeof makeFs>,
client?: ReturnType<typeof makeClientFake>,
) {
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 =
'<!-- docmost:meta\n{"version":1,"pageId":"p-1"}\n-->\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 =
'<!-- docmost:meta\n{"version":1,"pageId":"p-9"}\n-->\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 =
'<!-- docmost:meta\n{"version":1,"pageId":"p-1"}\n-->\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);
});
});