From 9c6283aa8e03c8a267409bdbaf41db66a98c5318 Mon Sep 17 00:00:00 2001 From: vvzvlad Date: Wed, 17 Jun 2026 02:32:15 +0300 Subject: [PATCH] =?UTF-8?q?feat(sync):=20FS->Docmost=20push=20#1=20?= =?UTF-8?q?=E2=80=94=20diff/ref=20primitives=20+=20pure=20planner=20+=20ap?= =?UTF-8?q?ply=20(fakes)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of the push direction (SPEC §6), mirroring pull: VaultGit primitives + pure planner + thin injectable apply, exercised via fakes (no live destructive run). - git.ts: diffNameStatus (--name-status -M -z, NUL-parsed, rename-aware), revParse/readRef/updateRef (refs/docmost/last-pushed), showFileAtRef (recover a deleted file's pre-image pageId) - push.ts computePushActions (pure): A/M/D/R -> create/update/delete/renamesMoves; delete only when pageId is recovered from the pre-image, else skipped (§8 guard — no spurious Docmost delete) - push.ts applyPushActions (fakes): update via importPageMarkdown (collab/Yjs path, §2 — never a raw jsonb overwrite); create via createPage then write the assigned pageId back into the file meta (body preserved); delete via deletePage (soft, §8); renamesMoves deferred; advances last-pushed - tests (+26): diffNameStatus A/M/D/rename, ref round-trip, showFileAtRef; pure classification incl. §8 no-pageid skip; apply with fakes (collab-path update, pageid write-back, soft-delete, deferred moves) - 683 -> 709 green; build clean; corpus STABLE Deferred (next increment): move/rename apply, loop-guard (§10), watcher/debounce, remote push, live main wiring, empty-spaceId create guard, per-page error isolation. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/git.ts | 132 +++++++++++ src/push.ts | 381 ++++++++++++++++++++++++++++++ test/apply-push-actions.test.ts | 274 +++++++++++++++++++++ test/compute-push-actions.test.ts | 195 +++++++++++++++ test/git.test.ts | 149 ++++++++++++ 5 files changed, 1131 insertions(+) create mode 100644 src/push.ts create mode 100644 test/apply-push-actions.test.ts create mode 100644 test/compute-push-actions.test.ts diff --git a/src/git.ts b/src/git.ts index c13912a..4dbed1e 100644 --- a/src/git.ts +++ b/src/git.ts @@ -32,6 +32,22 @@ export const BOT_AUTHOR_EMAIL = "docmost-sync@local"; /** Default branch the vault repo is initialized on. */ export const DEFAULT_BRANCH = "main"; +/** + * One row of `git diff --name-status` (SPEC §6 "ФС → Docmost"). `status` is the + * single-letter change code (`-M` rename detection on), `path` is the (new) file + * path; for a rename/copy (`R`/`C`) `oldPath` is the source and `path` is the + * destination, with `score` carrying git's similarity index (0–100). + */ +export interface DiffEntry { + status: "A" | "M" | "D" | "R" | "C"; + /** New (destination) path. For A/M/D it is the only path. */ + path: string; + /** Source path — present only for R/C. */ + oldPath?: string; + /** Rename/copy similarity score (0–100) — present only for R/C. */ + score?: number; +} + /** Result of a `merge`: whether it succeeded cleanly or left conflict markers. */ export interface MergeResult { /** True when the merge applied cleanly (fast-forward or clean 3-way). */ @@ -419,6 +435,122 @@ export class VaultGit { } return r.stdout.split("\0").filter((p) => p.length > 0); } + + /** + * Diff two refs with `--name-status -M -z` and parse the NUL-delimited output + * (SPEC §6: the FS→Docmost push direction diffs `main` against + * `refs/docmost/last-pushed`). Rename detection is ON (`-M`), so a moved/renamed + * file is reported as a single `R` row with both its old and new path instead + * of a delete+add pair — that distinction is what lets the push planner tell a + * move from a delete+create (SPEC §8 "Move vs delete"). + * + * `-z` makes git emit NUL-delimited RAW UTF-8 records (the Russian wiki has + * Cyrillic file names) with NO quoting/escaping. The record shape differs by + * status: + * - A/M/D: `status\0path\0` + * - R/C: `Rnnn\0oldPath\0newPath\0` (nnn = similarity score, e.g. `R100`) + * We read the RAW stdout (not the trimming `run()` helper, which would mangle + * the NUL bytes), split on `\0`, drop the trailing empty entry, and walk the + * tokens pulling 1 or 2 path tokens per status. Paths are returned verbatim. + */ + async diffNameStatus( + fromRef: string, + toRef: string, + ): Promise { + const r = await this.runRaw([ + "diff", + "--name-status", + "-M", + "-z", + fromRef, + toRef, + ]); + if (r.code !== 0) { + const detail = (r.stderr || r.stdout || "").trim(); + throw new Error(`git diff --name-status failed: ${detail}`); + } + // Tokens alternate: ... With `-z`, + // each token (status code AND each path) is its own NUL-delimited field. + const tokens = r.stdout.split("\0").filter((t) => t.length > 0); + const entries: DiffEntry[] = []; + let i = 0; + while (i < tokens.length) { + const raw = tokens[i++]; + // The status token is e.g. `A`, `M`, `D`, or `R100` / `C075`. The leading + // letter is the change kind; any trailing digits are the similarity score. + const letter = raw[0] as DiffEntry["status"]; + if (letter === "R" || letter === "C") { + const score = Number.parseInt(raw.slice(1), 10); + const oldPath = tokens[i++]; + const path = tokens[i++]; + if (oldPath === undefined || path === undefined) break; // malformed tail + entries.push({ + status: letter, + path, + oldPath, + ...(Number.isFinite(score) ? { score } : {}), + }); + } else if (letter === "A" || letter === "M" || letter === "D") { + const path = tokens[i++]; + if (path === undefined) break; // malformed tail + entries.push({ status: letter, path }); + } else { + // Unknown/other status (e.g. T type-change, U unmerged) — consume one + // path token defensively so the walk stays aligned, but do not emit it + // (the push planner only handles A/M/D/R/C). + i++; + } + } + return entries; + } + + /** + * Resolve a ref/commit-ish to its full SHA, or `null` if it does not exist. + * `rev-parse --verify --quiet` exits non-zero (and prints nothing) for an + * unknown ref, so a non-zero exit maps cleanly to `null`. Used to read + * `refs/docmost/last-pushed` (SPEC §5) — which is absent before the first push. + */ + async revParse(ref: string): Promise { + const r = await this.runRaw(["rev-parse", "--verify", "--quiet", ref]); + if (r.code !== 0) return null; + const sha = r.stdout.trim(); + return sha.length > 0 ? sha : null; + } + + /** + * Read a ref to its SHA, or `null` if unset. Thin alias over `revParse`, + * named for the push direction's marker `refs/docmost/last-pushed` (SPEC §5: + * "что из `main` уже отражено в Docmost"). + */ + async readRef(ref: string): Promise { + return this.revParse(ref); + } + + /** + * Point `ref` at `target` (`git update-ref `). Used to advance + * `refs/docmost/last-pushed` to the just-pushed `main` commit after a push + * (SPEC §6 step 3 / §5). `target` may be a SHA or any commit-ish git accepts. + */ + async updateRef(ref: string, target: string): Promise { + await this.run(["update-ref", ref, target]); + } + + /** + * Read a file's content at a specific ref (`git show :`), or `null` + * if the path does not exist there. Used by the push direction to read the + * PRE-IMAGE of a DELETED file (e.g. at `refs/docmost/last-pushed`) so its + * `docmost:meta` — and therefore its `pageId` — can be recovered to translate + * the deletion into a `delete_page` (SPEC §6/§8: only TRACKED files, i.e. ones + * that had a pageId, are deleted in Docmost). A non-zero exit (path absent at + * that ref) maps to `null` rather than throwing. + */ + async showFileAtRef(ref: string, path: string): Promise { + // `git show :` requires the path relative to the repo root; pass + // it verbatim (forward-slash, matching `listTrackedFiles` / diff output). + const r = await this.runRaw(["show", `${ref}:${path}`]); + if (r.code !== 0) return null; + return r.stdout; + } } /** diff --git a/src/push.ts b/src/push.ts new file mode 100644 index 0000000..618aa96 --- /dev/null +++ b/src/push.ts @@ -0,0 +1,381 @@ +/** + * Push cycle — vault -> Docmost (SPEC §6 "ФС → Docmost"), FIRST increment. + * + * This module mirrors the structure of `src/pull.ts`: a set of VaultGit diff/ref + * primitives (in `src/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 (RECORDED ONLY here; see the TODO below). + * + * SCOPE OF THIS INCREMENT — what is intentionally NOT here yet (next increment), + * left as explicit TODO markers: + * - TODO(next-increment): move/rename APPLY — resolving move-vs-rename and the + * new parentPageId, then calling `move_page` / `rename_page` (SPEC §6/§8). + * `computePushActions` already CLASSIFIES R into `renamesMoves`, and + * `applyPushActions` returns them as `deferred` without any client call. + * - TODO(next-increment): loop-guard (SPEC §10) — record the `updatedAt` from + * each write response + provenance trailer so the next pull does not pull our + * own write back; suppress self-writes by body hash. + * - TODO(next-increment): FS-watcher + debounce (SPEC §7.1) that commits on + * `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): fast-forward the `docmost` mirror branch after a push + * (SPEC §6 step 3) — only `refs/docmost/last-pushed` is advanced here. + * - 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. + */ +import type { DocmostClient } from "docmost-client"; +import { + parseDocmostMarkdown, + serializeDocmostMarkdownBody, + type DocmostMdMeta, +} from "docmost-client"; +import type { DiffEntry, VaultGit } from "./git.js"; + +// Re-export so callers/tests can import the diff row shape from either module. +export type { DiffEntry } from "./git.js"; + +/** A page to CREATE in Docmost (new local file, meta has no pageId yet). */ +export interface CreateAction { + /** Vault-relative path of the new file. */ + path: string; +} + +/** A page whose CONTENT changed (meta carries the existing pageId). */ +export interface UpdateAction { + pageId: string; + /** Vault-relative path of the changed file. */ + path: string; +} + +/** A page to soft-delete in Docmost (Trash, SPEC §8). */ +export interface DeleteAction { + pageId: string; +} + +/** A renamed/moved page (same pageId, new path). Resolution DEFERRED. */ +export interface RenameMoveAction { + pageId: string; + oldPath: string; + newPath: string; +} + +/** The classified set of push actions (PURE output of `computePushActions`). */ +export interface PushActions { + creates: CreateAction[]; + updates: UpdateAction[]; + deletes: DeleteAction[]; + renamesMoves: RenameMoveAction[]; + /** + * Diff rows that could NOT be classified into an action, with a reason — e.g. + * a deleted file whose PRE-IMAGE meta carried no recoverable pageId (the + * untracked-file guard, SPEC §8: only files that were tracked with a pageId + * are deleted in Docmost). Carried so the caller can log them. + */ + skipped: { path: string; status: DiffEntry["status"]; reason: string }[]; +} + +/** + * Which tree a `metaAt` lookup reads the file's `docmost:meta` from: + * - `current`: the current `main` tree (the live file content) — used for + * A/M/R, where the file still exists. + * - `prev`: the last-pushed PRE-IMAGE (e.g. `refs/docmost/last-pushed:`) + * — used for D, where the file is gone from `main` but its pageId must be + * recovered from the version Docmost last knew (SPEC §6/§8). + */ +export type MetaSide = "current" | "prev"; + +/** Input to the PURE planner. `metaAt` is injected (no IO inside the planner). */ +export interface PushActionsInput { + /** Diff rows of `main` vs `refs/docmost/last-pushed` (SPEC §6 step 2). */ + changes: DiffEntry[]; + /** + * Resolve a file's `docmost:meta` at a given side, or `null` if the file is + * absent there / has no parseable meta. PURE injection: the real `main` reads + * the working tree (current) or `git show :` (prev); tests + * pass a plain lookup. + */ + metaAt: (path: string, side: MetaSide) => DocmostMdMeta | null; +} + +/** + * 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 NO pageId -> CREATE (a brand-new local file; the + * page does not exist in Docmost yet). + * - current meta HAS a pageId -> UPDATE (a restored/copied file whose + * page already exists; we push its content rather than create a dup). + * - `M` (modified): current meta has a pageId -> UPDATE content. (If a modified + * file somehow lost its pageId it is skipped — there is nothing to target.) + * - `D` (deleted): recover the pageId from the PRE-IMAGE meta (`metaAt(path, + * 'prev')`) -> DELETE. If no pageId can be recovered, SKIP with a reason + * (untracked-file guard, SPEC §8: never delete an untracked page). + * - `R` (renamed/moved): same pageId (from current meta), path changed -> + * RENAME/MOVE. Resolution of move-vs-rename + the new parentPageId is + * DEFERRED to the next increment; here we only record oldPath/newPath/ + * pageId. If the renamed file has no recoverable pageId it is SKIPPED. + * (`C` copy is treated the same as `R` for recording purposes.) + */ +export function computePushActions(input: PushActionsInput): PushActions { + const { changes, metaAt } = input; + const actions: PushActions = { + 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 { + // Brand-new local file -> create the page, then write the assigned + // pageId back into its meta (done in `applyPushActions`). + actions.creates.push({ path: change.path }); + } + 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). */ +export const LAST_PUSHED_REF = "refs/docmost/last-pushed"; + +/** + * Injectable IO for `applyPushActions`. The real `main` (NEXT increment) wires + * these to the live client, `node:fs/promises`, and the vault git wrapper; this + * increment drives them only through FAKES in tests (no live destructive run). + * - `client`: the create/update/delete subset of `DocmostClient`. + * - `readFile`/`writeFile`: read a changed file's body / write a file back + * (by vault-relative path; the applier does not resolve absolute paths so + * fakes stay trivial). + * - `git`: only `updateRef` is used here (advance `refs/docmost/last-pushed`). + */ +export interface ApplyPushDeps { + client: Pick< + DocmostClient, + "importPageMarkdown" | "createPage" | "deletePage" + >; + /** Read a changed file's full text by its vault-relative path. */ + readFile: (path: string) => Promise; + /** Write a file's full text by its vault-relative path. */ + writeFile: (path: string, text: string) => Promise; + git: Pick; +} + +/** A file whose meta was rewritten with a freshly-assigned pageId (post-create). */ +export interface WrittenBackPage { + path: string; + pageId: string; +} + +/** Structured outcome of `applyPushActions` (counts + write-backs + deferred). */ +export interface ApplyPushResult { + created: number; + updated: number; + deleted: number; + /** + * Files whose `docmost:meta` was rewritten with the pageId Docmost assigned on + * create — these now need a FOLLOW-UP commit (the meta on disk changed). The + * commit itself is the caller's job (NEXT increment); recorded here so it is + * not lost. + */ + writtenBack: WrittenBackPage[]; + /** Rename/move actions NOT executed this increment (apply is deferred). */ + deferred: RenameMoveAction[]; + /** Diff rows the planner could not classify (carried through for logging). */ + skipped: PushActions["skipped"]; + /** Whether `refs/docmost/last-pushed` was advanced (only when `pushedCommit`). */ + lastPushedAdvanced: boolean; +} + +/** + * 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: NOT executed — returned as `deferred` (NEXT increment). + * + * After applying, if a `pushedCommit` is given, advance + * `refs/docmost/last-pushed` to it (SPEC §6 step 3). Fast-forwarding the + * `docmost` branch and the loop-guard are DEFERRED (see the module TODO list). + * + * @param pushedCommit The `main` commit just reflected into Docmost (SHA or + * commit-ish). When omitted, the ref is NOT advanced (e.g. a dry plan). + */ +export async function applyPushActions( + deps: ApplyPushDeps, + actions: PushActions, + pushedCommit?: string, +): Promise { + const { client, git } = deps; + + let created = 0; + let updated = 0; + let deleted = 0; + const writtenBack: WrittenBackPage[] = []; + + // 1. UPDATES — collab/Yjs write path (SPEC §2/§15.6), never a raw overwrite. + for (const u of actions.updates) { + const fullMarkdown = await deps.readFile(u.path); + await client.importPageMarkdown(u.pageId, fullMarkdown); + updated++; + } + + // 2. CREATES — create the page, then write the assigned pageId back to meta so + // the file becomes tracked (SPEC §4 "записать присвоенный pageId обратно"). + for (const c of actions.creates) { + const text = await deps.readFile(c.path); + const { meta, body } = parseDocmostMarkdown(text); + // Derive create args from the file's current meta. A new local file may have + // a partial meta (e.g. title/spaceId only); spaceId is required by Docmost. + 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: string | undefined = result?.data?.id; + if (assignedPageId) { + // Re-serialize the file with the pageId written into meta, body preserved. + const newMeta: DocmostMdMeta = { + version: meta?.version ?? 1, + ...meta, + pageId: assignedPageId, + }; + const rewritten = serializeDocmostMarkdownBody(newMeta, body); + await deps.writeFile(c.path, rewritten); + writtenBack.push({ path: c.path, pageId: assignedPageId }); + } + created++; + } + + // 3. DELETES — soft-delete to Trash (SPEC §8), obratimo. + for (const d of actions.deletes) { + await client.deletePage(d.pageId); + deleted++; + } + + // 4. RENAME/MOVE — DEFERRED (NEXT increment): no client call. Returned as + // `deferred` so the caller can see what still needs the move/rename apply. + + // 5. Advance `refs/docmost/last-pushed` to the pushed `main` commit (SPEC §6 + // step 3 / §5). TODO(next-increment): fast-forward the `docmost` mirror + // branch (Docmost already contains these changes) and record the `updatedAt` + // from each write response for the loop-guard (SPEC §10). + let lastPushedAdvanced = false; + if (pushedCommit) { + await git.updateRef(LAST_PUSHED_REF, pushedCommit); + lastPushedAdvanced = true; + } + + return { + created, + updated, + deleted, + writtenBack, + deferred: actions.renamesMoves, + skipped: actions.skipped, + lastPushedAdvanced, + }; +} diff --git a/test/apply-push-actions.test.ts b/test/apply-push-actions.test.ts new file mode 100644 index 0000000..aa9536e --- /dev/null +++ b/test/apply-push-actions.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { applyPushActions, LAST_PUSHED_REF } from '../src/push.js'; +import type { ApplyPushDeps, PushActions } from '../src/push.js'; +import { + parseDocmostMarkdown, + serializeDocmostMarkdownBody, +} from '../packages/docmost-client/src/lib/markdown-document.js'; + +// FS→Docmost push, FIRST increment (SPEC §6). `applyPushActions` is the THIN IO +// half: create/update/delete via FAKES that record every call — no real network, +// git, or fs. Asserts: update uses importPageMarkdown (collab path, SPEC +// §2/§15.6); create writes the assigned pageId BACK into the file meta; delete +// soft-deletes; rename/move is returned as `deferred` with NO client call; the +// last-pushed ref is advanced. + +/** A recording client fake; createPage returns a configurable assigned id. */ +function makeClient(opts?: { createId?: string }) { + const client = { + importPageMarkdown: vi.fn(async (_pageId: string, _md: string) => ({ + success: true, + })), + createPage: vi.fn( + async ( + title: string, + _content: string, + _spaceId: string, + _parentPageId?: string, + ) => ({ + // Mirrors the real `createPage` shape: `{ data: { id, ... }, success }`. + data: { id: opts?.createId ?? 'assigned-id', title }, + success: true, + }), + ), + deletePage: vi.fn(async (_pageId: string) => ({ success: true })), + }; + return client; +} + +/** A recording git fake (only updateRef is used by the push applier). */ +function makeGit() { + const updateRefCalls: { ref: string; target: string }[] = []; + const git = { + updateRef: vi.fn(async (ref: string, target: string) => { + updateRefCalls.push({ ref, target }); + }), + }; + return { git, updateRefCalls }; +} + +/** A recording fs fake over a path->text store. */ +function makeFs(initial: Record = {}) { + const store: Record = { ...initial }; + const writes: { path: string; text: string }[] = []; + const reads: string[] = []; + const fs = { + 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 }); + }), + }; + return { fs, store, writes, reads }; +} + +function deps(client: any, git: any, fs: ReturnType): ApplyPushDeps { + return { + client, + git, + readFile: fs.fs.readFile, + writeFile: fs.fs.writeFile, + }; +} + +function actions(partial: Partial): PushActions { + return { + creates: [], + updates: [], + deletes: [], + renamesMoves: [], + skipped: [], + ...partial, + }; +} + +beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('applyPushActions — update (collab path, SPEC §2/§15.6)', () => { + it('reads the file body and calls importPageMarkdown with it', async () => { + const fileBody = + '\n\nupdated body\n'; + const client = makeClient(); + const { git } = makeGit(); + const fs = makeFs({ 'Doc.md': fileBody }); + + const res = await applyPushActions( + deps(client, git, fs), + actions({ updates: [{ pageId: 'p-1', path: 'Doc.md' }] }), + ); + + expect(res.updated).toBe(1); + // The collab/Yjs write path is used — NOT a raw jsonb overwrite. + expect(client.importPageMarkdown).toHaveBeenCalledTimes(1); + expect(client.importPageMarkdown).toHaveBeenCalledWith('p-1', fileBody); + // No raw-overwrite path exists on the injected client surface at all. + expect((client as any).updatePageJson).toBeUndefined(); + expect(client.createPage).not.toHaveBeenCalled(); + expect(client.deletePage).not.toHaveBeenCalled(); + }); +}); + +describe('applyPushActions — create (assigned pageId written back to meta)', () => { + it('createPage is called and the new pageId is serialized back into the file', async () => { + // A brand-new local file: meta has title/spaceId but NO pageId yet. + const original = serializeDocmostMarkdownBody( + { version: 1, title: 'My New Page', spaceId: 'sp-7', parentPageId: 'parent-9' }, + '# My New Page\n\nbody text', + ); + const client = makeClient({ createId: 'page-new-42' }); + const { git } = makeGit(); + const fs = makeFs({ 'New.md': original }); + + const res = await applyPushActions( + deps(client, git, fs), + actions({ creates: [{ path: 'New.md' }] }), + ); + + expect(res.created).toBe(1); + // createPage was called with title/body/spaceId/parentPageId from meta. + expect(client.createPage).toHaveBeenCalledTimes(1); + const [title, content, spaceId, parentPageId] = + client.createPage.mock.calls[0]; + expect(title).toBe('My New Page'); + expect(spaceId).toBe('sp-7'); + expect(parentPageId).toBe('parent-9'); + expect(content).toContain('body text'); + + // The file was rewritten with the assigned pageId in meta... + expect(fs.writes.map((w) => w.path)).toEqual(['New.md']); + const rewritten = fs.store['New.md']; + const parsed = parseDocmostMarkdown(rewritten); + expect(parsed.meta?.pageId).toBe('page-new-42'); + // ...preserving the rest of the meta and the body. + expect(parsed.meta?.title).toBe('My New Page'); + expect(parsed.meta?.spaceId).toBe('sp-7'); + expect(parsed.body).toContain('body text'); + + // The write-back is recorded so a follow-up commit can be made (NEXT inc). + expect(res.writtenBack).toEqual([{ path: 'New.md', pageId: 'page-new-42' }]); + }); +}); + +describe('applyPushActions — delete (soft-delete to Trash, SPEC §8)', () => { + it('calls deletePage(pageId)', async () => { + const client = makeClient(); + const { git } = makeGit(); + const fs = makeFs(); + + const res = await applyPushActions( + deps(client, git, fs), + actions({ deletes: [{ pageId: 'p-del' }] }), + ); + + expect(res.deleted).toBe(1); + expect(client.deletePage).toHaveBeenCalledTimes(1); + expect(client.deletePage).toHaveBeenCalledWith('p-del'); + // No body read needed for a delete. + expect(fs.reads).toEqual([]); + }); +}); + +describe('applyPushActions — rename/move is DEFERRED (NEXT increment)', () => { + it('returns renames/moves as `deferred` with NO client call', async () => { + const client = makeClient(); + const { git } = makeGit(); + const fs = makeFs(); + + const rm = { pageId: 'p-mv', oldPath: 'Old.md', newPath: 'New.md' }; + const res = await applyPushActions( + deps(client, git, fs), + actions({ renamesMoves: [rm] }), + ); + + expect(res.deferred).toEqual([rm]); + // NOTHING was pushed for the move this increment. + expect(client.importPageMarkdown).not.toHaveBeenCalled(); + expect(client.createPage).not.toHaveBeenCalled(); + expect(client.deletePage).not.toHaveBeenCalled(); + }); +}); + +describe('applyPushActions — last-pushed ref advance (SPEC §6 step 3)', () => { + it('advances refs/docmost/last-pushed to the pushed commit', async () => { + const client = makeClient(); + const { git, updateRefCalls } = makeGit(); + const fs = makeFs(); + + const res = await applyPushActions( + deps(client, git, fs), + actions({ deletes: [{ pageId: 'p' }] }), + 'commit-sha-abc', + ); + + expect(res.lastPushedAdvanced).toBe(true); + expect(updateRefCalls).toEqual([ + { ref: LAST_PUSHED_REF, target: 'commit-sha-abc' }, + ]); + }); + + it('does NOT advance the ref when no pushed commit is given', async () => { + const client = makeClient(); + const { git, updateRefCalls } = makeGit(); + const fs = makeFs(); + + const res = await applyPushActions( + deps(client, git, fs), + actions({ updates: [] }), + ); + + expect(res.lastPushedAdvanced).toBe(false); + expect(updateRefCalls).toEqual([]); + expect(git.updateRef).not.toHaveBeenCalled(); + }); +}); + +describe('applyPushActions — mixed batch + skipped passthrough', () => { + it('applies update + create + delete and carries skipped rows through', async () => { + const updFile = + '\n\nupd\n'; + const newFile = serializeDocmostMarkdownBody( + { version: 1, title: 'N', spaceId: 'sp' }, + 'fresh body', + ); + const client = makeClient({ createId: 'created-1' }); + const { git, updateRefCalls } = makeGit(); + const fs = makeFs({ 'U.md': updFile, 'N.md': newFile }); + + const skipped = [ + { path: 'Stray.md', status: 'D' as const, reason: 'no recoverable pageId' }, + ]; + const res = await applyPushActions( + deps(client, git, fs), + actions({ + updates: [{ pageId: 'u-1', path: 'U.md' }], + creates: [{ path: 'N.md' }], + deletes: [{ pageId: 'd-1' }], + skipped, + }), + 'sha-9', + ); + + expect(res).toMatchObject({ + created: 1, + updated: 1, + deleted: 1, + lastPushedAdvanced: true, + }); + expect(res.writtenBack).toEqual([{ path: 'N.md', pageId: 'created-1' }]); + expect(res.skipped).toEqual(skipped); + expect(updateRefCalls).toEqual([{ ref: LAST_PUSHED_REF, target: 'sha-9' }]); + expect(client.importPageMarkdown).toHaveBeenCalledWith('u-1', updFile); + expect(client.deletePage).toHaveBeenCalledWith('d-1'); + }); +}); diff --git a/test/compute-push-actions.test.ts b/test/compute-push-actions.test.ts new file mode 100644 index 0000000..9ead7db --- /dev/null +++ b/test/compute-push-actions.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from 'vitest'; +import { computePushActions } from '../src/push.js'; +import type { DiffEntry, MetaSide } from '../src/push.js'; +import type { DocmostMdMeta } from '../packages/docmost-client/src/lib/markdown-document.js'; + +// FS→Docmost push, FIRST increment (SPEC §6). `computePushActions` is the PURE +// half: it classifies each `git diff --name-status` row into a Docmost action by +// `pageId` identity (SPEC §4/§8), with NO IO — the `metaAt` resolver is injected. +// These tests cover every classification incl. edges. + +/** Build a `metaAt` resolver from a `path|side -> meta` table. */ +function metaTable( + table: Record, +): (path: string, side: MetaSide) => DocmostMdMeta | null { + return (path, side) => { + const key = `${path}|${side}`; + return key in table ? table[key] : null; + }; +} + +function meta(partial: Partial): DocmostMdMeta { + return { version: 1, ...partial }; +} + +describe('computePushActions — A (added)', () => { + it('added file with NO pageId -> create', () => { + const changes: DiffEntry[] = [{ status: 'A', path: 'New.md' }]; + const metaAt = metaTable({ + 'New.md|current': meta({ title: 'New', spaceId: 'sp1' }), + }); + const actions = computePushActions({ changes, metaAt }); + expect(actions.creates).toEqual([{ path: 'New.md' }]); + expect(actions.updates).toEqual([]); + expect(actions.deletes).toEqual([]); + expect(actions.renamesMoves).toEqual([]); + expect(actions.skipped).toEqual([]); + }); + + it('added file with NO meta at all -> create (treated as new)', () => { + const changes: DiffEntry[] = [{ status: 'A', path: 'Plain.md' }]; + const actions = computePushActions({ changes, metaAt: metaTable({}) }); + expect(actions.creates).toEqual([{ path: 'Plain.md' }]); + }); + + it('added file WITH a pageId (restored/copied) -> update (page exists)', () => { + const changes: DiffEntry[] = [{ status: 'A', path: 'Restored.md' }]; + const metaAt = metaTable({ + 'Restored.md|current': meta({ pageId: 'p-restored', title: 'R' }), + }); + const actions = computePushActions({ changes, metaAt }); + // The page already exists -> push content as an UPDATE, never a duplicate. + expect(actions.updates).toEqual([ + { pageId: 'p-restored', path: 'Restored.md' }, + ]); + expect(actions.creates).toEqual([]); + }); +}); + +describe('computePushActions — M (modified)', () => { + it('modified file with a pageId -> update content', () => { + const changes: DiffEntry[] = [{ status: 'M', path: 'Doc.md' }]; + const metaAt = metaTable({ + 'Doc.md|current': meta({ pageId: 'p-doc' }), + }); + const actions = computePushActions({ changes, metaAt }); + expect(actions.updates).toEqual([{ pageId: 'p-doc', path: 'Doc.md' }]); + expect(actions.skipped).toEqual([]); + }); + + it('modified file with NO pageId -> skipped (no target to update)', () => { + const changes: DiffEntry[] = [{ status: 'M', path: 'Untracked.md' }]; + const actions = computePushActions({ changes, metaAt: metaTable({}) }); + expect(actions.updates).toEqual([]); + expect(actions.skipped).toEqual([ + { + path: 'Untracked.md', + status: 'M', + reason: 'modified file has no pageId in meta', + }, + ]); + }); +}); + +describe('computePushActions — D (deleted)', () => { + it('deleted file recovers pageId from the PRE-IMAGE meta -> delete', () => { + const changes: DiffEntry[] = [{ status: 'D', path: 'Gone.md' }]; + // The file is gone from `current`; its pageId lives in the `prev` pre-image. + const metaAt = metaTable({ + 'Gone.md|prev': meta({ pageId: 'p-gone' }), + }); + const actions = computePushActions({ changes, metaAt }); + expect(actions.deletes).toEqual([{ pageId: 'p-gone' }]); + expect(actions.skipped).toEqual([]); + }); + + it('deleted file with NO recoverable pageId -> skipped (untracked guard §8)', () => { + const changes: DiffEntry[] = [{ status: 'D', path: 'Stray.md' }]; + // No pre-image pageId -> the untracked-file guard skips it (never deletes a + // page that was never tracked, SPEC §8). + const actions = computePushActions({ changes, metaAt: metaTable({}) }); + expect(actions.deletes).toEqual([]); + expect(actions.skipped).toEqual([ + { + path: 'Stray.md', + status: 'D', + reason: 'deleted file has no recoverable pageId (pre-image meta)', + }, + ]); + }); + + it('uses the PREV side, not current, to recover the deleted pageId', () => { + const changes: DiffEntry[] = [{ status: 'D', path: 'Gone.md' }]; + // A stale `current` meta must NOT be used; only the pre-image counts. + const metaAt = metaTable({ + 'Gone.md|current': meta({ pageId: 'WRONG' }), + 'Gone.md|prev': meta({ pageId: 'p-correct' }), + }); + const actions = computePushActions({ changes, metaAt }); + expect(actions.deletes).toEqual([{ pageId: 'p-correct' }]); + }); +}); + +describe('computePushActions — R/C (renamed/moved)', () => { + it('renamed file -> renamesMoves (record only; resolution deferred)', () => { + const changes: DiffEntry[] = [ + { status: 'R', path: 'New/Path.md', oldPath: 'Old/Path.md', score: 100 }, + ]; + const metaAt = metaTable({ + 'New/Path.md|current': meta({ pageId: 'p-moved' }), + }); + const actions = computePushActions({ changes, metaAt }); + expect(actions.renamesMoves).toEqual([ + { pageId: 'p-moved', oldPath: 'Old/Path.md', newPath: 'New/Path.md' }, + ]); + // It is NOT also recorded as a create/update/delete. + expect(actions.creates).toEqual([]); + expect(actions.updates).toEqual([]); + expect(actions.deletes).toEqual([]); + }); + + it('copy (C) is recorded like a rename for the deferred apply', () => { + const changes: DiffEntry[] = [ + { status: 'C', path: 'Copy.md', oldPath: 'Src.md', score: 90 }, + ]; + const metaAt = metaTable({ + 'Copy.md|current': meta({ pageId: 'p-copy' }), + }); + const actions = computePushActions({ changes, metaAt }); + expect(actions.renamesMoves).toEqual([ + { pageId: 'p-copy', oldPath: 'Src.md', newPath: 'Copy.md' }, + ]); + }); + + it('renamed file with NO pageId -> skipped', () => { + const changes: DiffEntry[] = [ + { status: 'R', path: 'New.md', oldPath: 'Old.md', score: 100 }, + ]; + const actions = computePushActions({ changes, metaAt: metaTable({}) }); + expect(actions.renamesMoves).toEqual([]); + expect(actions.skipped).toEqual([ + { path: 'New.md', status: 'R', reason: 'renamed/moved file has no pageId in meta' }, + ]); + }); +}); + +describe('computePushActions — mixed batch', () => { + it('classifies a realistic mixed diff in one pass', () => { + const changes: DiffEntry[] = [ + { status: 'A', path: 'Fresh.md' }, // create + { status: 'A', path: 'Restored.md' }, // update (has pageId) + { status: 'M', path: 'Edited.md' }, // update + { status: 'D', path: 'Removed.md' }, // delete + { status: 'R', path: 'Dst.md', oldPath: 'Srcc.md', score: 100 }, // move + ]; + const metaAt = metaTable({ + 'Fresh.md|current': meta({ title: 'Fresh', spaceId: 'sp' }), + 'Restored.md|current': meta({ pageId: 'p-rest' }), + 'Edited.md|current': meta({ pageId: 'p-edit' }), + 'Removed.md|prev': meta({ pageId: 'p-rm' }), + 'Dst.md|current': meta({ pageId: 'p-mv' }), + }); + const actions = computePushActions({ changes, metaAt }); + + expect(actions.creates).toEqual([{ path: 'Fresh.md' }]); + expect(actions.updates).toEqual([ + { pageId: 'p-rest', path: 'Restored.md' }, + { pageId: 'p-edit', path: 'Edited.md' }, + ]); + expect(actions.deletes).toEqual([{ pageId: 'p-rm' }]); + expect(actions.renamesMoves).toEqual([ + { pageId: 'p-mv', oldPath: 'Srcc.md', newPath: 'Dst.md' }, + ]); + expect(actions.skipped).toEqual([]); + }); +}); diff --git a/test/git.test.ts b/test/git.test.ts index 50ed88c..7450dbe 100644 --- a/test/git.test.ts +++ b/test/git.test.ts @@ -473,4 +473,153 @@ describe('VaultGit (integration; temp repo)', () => { // not even exist yet). await expect(git.assertGitAvailable()).resolves.toBeUndefined(); }); + + // --- Push-direction primitives (SPEC §6 "ФС → Docmost", FIRST increment) --- + + it('diffNameStatus parses A / M / D rows between two commits', async () => { + if (!available) return; + const vault = await freshDir(); + const git = new VaultGit(vault); + await git.ensureRepo(); + + // Commit 1: two files (keep.md will be modified, gone.md will be deleted). + await writeFile(join(vault, 'keep.md'), 'v1\n', 'utf8'); + await writeFile(join(vault, 'gone.md'), 'old\n', 'utf8'); + await git.stageAll(); + await git.commit('base', { authorName: BOT_AUTHOR_NAME, authorEmail: BOT_AUTHOR_EMAIL }); + const base = await git.revParse('HEAD'); + expect(base).toBeTruthy(); + + // Commit 2: modify keep.md, add fresh.md, delete gone.md. + await writeFile(join(vault, 'keep.md'), 'v2\n', 'utf8'); + await writeFile(join(vault, 'fresh.md'), 'new\n', 'utf8'); + await rm(join(vault, 'gone.md')); + await git.stageAll(); + await git.commit('change', { authorName: BOT_AUTHOR_NAME, authorEmail: BOT_AUTHOR_EMAIL }); + + const entries = await git.diffNameStatus(base!, 'HEAD'); + // Sort for deterministic assertion regardless of git's row order. + const byPath = new Map(entries.map((e) => [e.path, e])); + expect(byPath.get('keep.md')).toEqual({ status: 'M', path: 'keep.md' }); + expect(byPath.get('fresh.md')).toEqual({ status: 'A', path: 'fresh.md' }); + expect(byPath.get('gone.md')).toEqual({ status: 'D', path: 'gone.md' }); + expect(entries.length).toBe(3); + }); + + it('diffNameStatus parses a real rename (R) with old + new path', async () => { + if (!available) return; + const vault = await freshDir(); + const git = new VaultGit(vault); + await git.ensureRepo(); + + // A file with enough content that git's -M rename detection ties the rename + // to the same blob (identical content -> R100). + const body = 'line a\nline b\nline c\nline d\n'; + await writeFile(join(vault, 'old-name.md'), body, 'utf8'); + await git.stageAll(); + await git.commit('add', { authorName: BOT_AUTHOR_NAME, authorEmail: BOT_AUTHOR_EMAIL }); + const base = await git.revParse('HEAD'); + + // Rename it (same content) so -M detects a rename, not delete+add. + await rm(join(vault, 'old-name.md')); + await writeFile(join(vault, 'new-name.md'), body, 'utf8'); + await git.stageAll(); + await git.commit('rename', { authorName: BOT_AUTHOR_NAME, authorEmail: BOT_AUTHOR_EMAIL }); + + const entries = await git.diffNameStatus(base!, 'HEAD'); + expect(entries.length).toBe(1); + const r = entries[0]; + expect(r.status).toBe('R'); + expect(r.oldPath).toBe('old-name.md'); + expect(r.path).toBe('new-name.md'); + // Identical content -> a 100% similarity score. + expect(r.score).toBe(100); + }); + + it('diffNameStatus returns RAW UTF-8 Cyrillic paths (no quoting)', async () => { + if (!available) return; + const vault = await freshDir(); + const git = new VaultGit(vault); + await git.ensureRepo(); + + const base = await git.revParse('HEAD'); + await writeFile(join(vault, 'Статья.md'), 'тело\n', 'utf8'); + await git.stageAll(); + await git.commit('add cyrillic', { authorName: BOT_AUTHOR_NAME, authorEmail: BOT_AUTHOR_EMAIL }); + + const entries = await git.diffNameStatus(base!, 'HEAD'); + expect(entries).toEqual([{ status: 'A', path: 'Статья.md' }]); + }); + + it('revParse / readRef resolve a ref to a SHA, null when missing', async () => { + if (!available) return; + const vault = await freshDir(); + const git = new VaultGit(vault); + await git.ensureRepo(); + + const head = await git.revParse('HEAD'); + expect(head).toMatch(/^[0-9a-f]{40}$/); + // A non-existent ref resolves to null (not a throw). + expect(await git.revParse('refs/docmost/last-pushed')).toBeNull(); + expect(await git.readRef('refs/docmost/last-pushed')).toBeNull(); + }); + + it('updateRef / readRef round-trip a custom ref', async () => { + if (!available) return; + const vault = await freshDir(); + const git = new VaultGit(vault); + await git.ensureRepo(); + + const head = await git.revParse('HEAD'); + expect(await git.readRef('refs/docmost/last-pushed')).toBeNull(); + + await git.updateRef('refs/docmost/last-pushed', head!); + // It now resolves to the same SHA as HEAD. + expect(await git.readRef('refs/docmost/last-pushed')).toBe(head); + expect(await git.revParse('refs/docmost/last-pushed')).toBe(head); + }); + + it('showFileAtRef returns a committed file content and null for a missing path', async () => { + if (!available) return; + const vault = await freshDir(); + const git = new VaultGit(vault); + await git.ensureRepo(); + + const content = 'hello at ref\nsecond line\n'; + await writeFile(join(vault, 'doc.md'), content, 'utf8'); + await git.stageAll(); + await git.commit('add doc', { authorName: BOT_AUTHOR_NAME, authorEmail: BOT_AUTHOR_EMAIL }); + + // The committed file is readable at HEAD verbatim. + expect(await git.showFileAtRef('HEAD', 'doc.md')).toBe(content); + // A path that does not exist at that ref maps to null (not a throw). + expect(await git.showFileAtRef('HEAD', 'nope.md')).toBeNull(); + }); + + it('showFileAtRef reads a DELETED file pre-image at an earlier ref', async () => { + if (!available) return; + const vault = await freshDir(); + const git = new VaultGit(vault); + await git.ensureRepo(); + + // Commit a tracked page, capture the ref, then delete it. + const meta = + '\n\nbody\n'; + await writeFile(join(vault, 'tracked.md'), meta, 'utf8'); + await git.stageAll(); + await git.commit('add tracked', { authorName: BOT_AUTHOR_NAME, authorEmail: BOT_AUTHOR_EMAIL }); + const beforeDelete = await git.revParse('HEAD'); + + await rm(join(vault, 'tracked.md')); + await git.stageAll(); + await git.commit('delete tracked', { authorName: BOT_AUTHOR_NAME, authorEmail: BOT_AUTHOR_EMAIL }); + + // The pre-image (pageId) is recoverable at the earlier ref even though the + // file is gone from HEAD — this is how the push direction recovers the + // pageId of a deleted file (SPEC §6/§8). + expect(await git.showFileAtRef('HEAD', 'tracked.md')).toBeNull(); + const preImage = await git.showFileAtRef(beforeDelete!, 'tracked.md'); + expect(preImage).toBe(meta); + expect(preImage).toContain('page-123'); + }); });