diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 955b0ac2..8982c1ef 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,5 +36,12 @@ jobs: - name: Build editor-ext run: pnpm --filter @docmost/editor-ext build + # git-sync is no longer committed in built form (build/ is gitignored), so + # CI must compile it: the server suite imports the package via its built + # build/index.js. The server pretest also builds it, but building here keeps + # it explicit and independent of pnpm lifecycle ordering. + - name: Build git-sync + run: pnpm --filter @docmost/git-sync build + - name: Run tests run: pnpm -r test diff --git a/.gitignore b/.gitignore index e814fb29..d8aa4b5b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,11 @@ data # compiled output /dist /node_modules +# workspace package node_modules (pnpm symlinks — never commit; they bake +# machine-local store paths) and the git-sync compiled output (built in CI/Docker +# via `pnpm build`, never committed, so src/ and prod can never silently diverge). +packages/*/node_modules/ +packages/git-sync/build/ # Logs logs diff --git a/apps/server/package.json b/apps/server/package.json index f090449d..3f107cab 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -23,7 +23,7 @@ "migration:reset": "tsx src/database/migrate.ts down-to NO_MIGRATIONS", "migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/database/types/db.d.ts", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", - "pretest": "pnpm --filter @docmost/editor-ext build", + "pretest": "pnpm --filter @docmost/editor-ext build && pnpm --filter @docmost/git-sync build", "test": "jest", "test:int": "jest --config test/jest-integration.json", "test:watch": "jest --watch", diff --git a/packages/git-sync/build/engine/client.types.d.ts b/packages/git-sync/build/engine/client.types.d.ts deleted file mode 100644 index dea018c7..00000000 --- a/packages/git-sync/build/engine/client.types.d.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * The client seam (plan §3.1). Upstream `pull.ts`/`push.ts` reached into the - * REST `DocmostClient` from the `docmost-client` package via `Pick` subsets. That package is NOT vendored here (the gitmost server writes - * NATIVELY — through repositories + collab `openDirectConnection`, plan §3.2/§3.3), - * so the engine must depend on a narrow STRUCTURAL interface instead. - * - * `GitSyncClient` is that interface: the native datasource (server side, a later - * step) implements it, and the vendored engine only ever uses `Pick` subsets of it. The signatures below MIRROR exactly the methods the - * vendored `pull.ts`/`push.ts` actually call (arg shapes + the fields the engine - * reads off each result) — verified against the upstream `DocmostClient` - * (packages/docmost-client/src/client.ts) so a real REST client is still - * structurally assignable, and so the native adapter has a precise contract. - */ -/** - * A page node as returned by `listSpaceTree` (the sidebar/tree walk, no body). - * The engine layout (`buildVaultLayout`) consumes `PageNode` from `./layout`, - * which only requires `id` (+ optional `title`/`slugId`/`parentPageId`); this - * lite shape documents the fields the tree walk surfaces. Upstream nodes also - * carry `position`, `icon`, `hasChildren` — kept open via the index signature. - */ -export interface GitSyncPageNodeLite { - id: string; - slugId?: string; - title?: string; - parentPageId?: string | null; - hasChildren?: boolean; - /** Upstream `listSpaceTree` nodes carry extra fields (position, icon, …). */ - [key: string]: unknown; -} -/** - * The structural client the engine depends on. Only `Pick` - * subsets are ever used: - * - pull reads: `getPageJson` (+ the tree walk's `listSpaceTree`), - * - push writes: `importPageMarkdown` / `createPage` / `deletePage` / - * `movePage` / `renamePage`, - * - continuous (phase B+): `listRecentSince` / `listTrash` / `restorePage`. - */ -export interface GitSyncClient { - /** - * Full tree of page nodes for the space (or the subtree rooted at - * `rootPageId`), each WITHOUT body content. `complete` is `false` when the - * walk was truncated / a fetch failed — the pull side suppresses absence - * deletions on an incomplete tree (SPEC §8). Native impl returns - * `complete: true` always (reads the DB, not a paginated REST endpoint). - */ - listSpaceTree(spaceId: string, rootPageId?: string): Promise<{ - pages: GitSyncPageNodeLite[]; - complete: boolean; - }>; - /** - * One page WITH its ProseMirror body content. `applyPullActions` reads - * `id`, `slugId`, `title`, `parentPageId`, `spaceId` (for the file meta) and - * `content` (to stabilize/serialize). `updatedAt` is carried for the - * poll-suppression loop-guard. - */ - getPageJson(pageId: string): Promise<{ - id: string; - slugId: string; - title: string; - parentPageId: string | null; - spaceId: string; - updatedAt: string; - content: unknown; - }>; - /** - * Replace a page's body from a self-contained markdown file (meta + body). - * The collab/Yjs write path (SPEC §2/§15.6) — never a raw jsonb overwrite. - * `applyPushActions` reads only an optional `updatedAt` off the result - * (via `extractUpdatedAt`, tolerant of extra fields). - */ - importPageMarkdown(pageId: string, fullMarkdown: string): Promise<{ - updatedAt?: string; - [key: string]: unknown; - }>; - /** - * Create a new page and return the assigned id at `data.id` - * (`applyPushActions` reads `result.data.id`, then writes it back into the - * file's meta). An optional top-level/`data.updatedAt` feeds the loop-guard. - */ - createPage(title: string, content: string, spaceId: string, parentPageId?: string): Promise<{ - data: { - id: string; - }; - updatedAt?: string; - [key: string]: unknown; - }>; - /** Soft-delete a page to Trash (SPEC §8). Result is not inspected. */ - deletePage(pageId: string): Promise; - /** - * Reparent a page (and optionally set its fractional-index `position`). The - * engine passes `position` UNDEFINED for now; the native impl computes a - * default between siblings (plan §3.2). Result is not inspected. - */ - movePage(pageId: string, parentPageId: string | null, position?: string): Promise; - /** Change a page's title only (no body touch). Result is not inspected. */ - renamePage(pageId: string, title: string): Promise; - /** - * Pages updated since `sinceIso` (the poll-safety reconciliation, SPEC §8). - * `spaceId` may be undefined (all spaces); `hardPageCap` bounds the walk. - */ - listRecentSince(spaceId: string | undefined, sinceIso: string | null, hardPageCap?: number): Promise; - /** List soft-deleted (trashed) pages for the space (deletion detection). */ - listTrash(spaceId: string): Promise; - /** Restore a soft-deleted page from Trash. Result is not inspected. */ - restorePage(pageId: string): Promise; -} diff --git a/packages/git-sync/build/engine/client.types.js b/packages/git-sync/build/engine/client.types.js deleted file mode 100644 index 53a06e3e..00000000 --- a/packages/git-sync/build/engine/client.types.js +++ /dev/null @@ -1,17 +0,0 @@ -"use strict"; -/** - * The client seam (plan §3.1). Upstream `pull.ts`/`push.ts` reached into the - * REST `DocmostClient` from the `docmost-client` package via `Pick` subsets. That package is NOT vendored here (the gitmost server writes - * NATIVELY — through repositories + collab `openDirectConnection`, plan §3.2/§3.3), - * so the engine must depend on a narrow STRUCTURAL interface instead. - * - * `GitSyncClient` is that interface: the native datasource (server side, a later - * step) implements it, and the vendored engine only ever uses `Pick` subsets of it. The signatures below MIRROR exactly the methods the - * vendored `pull.ts`/`push.ts` actually call (arg shapes + the fields the engine - * reads off each result) — verified against the upstream `DocmostClient` - * (packages/docmost-client/src/client.ts) so a real REST client is still - * structurally assignable, and so the native adapter has a precise contract. - */ -Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/packages/git-sync/build/engine/config-errors.d.ts b/packages/git-sync/build/engine/config-errors.d.ts deleted file mode 100644 index 3e710684..00000000 --- a/packages/git-sync/build/engine/config-errors.d.ts +++ /dev/null @@ -1 +0,0 @@ -export declare function loadSettingsOrExit(factory: () => T): T; diff --git a/packages/git-sync/build/engine/config-errors.js b/packages/git-sync/build/engine/config-errors.js deleted file mode 100644 index baa5cbee..00000000 --- a/packages/git-sync/build/engine/config-errors.js +++ /dev/null @@ -1,53 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.loadSettingsOrExit = loadSettingsOrExit; -const zod_1 = require("zod"); -// Turn a ZodError from settings validation into a clear, actionable startup -// message that names the offending env var(s), then exit(1) — no raw stack -// trace. Mirrors the Python new-project skeleton's load_settings_or_exit. -// A non-ZodError is left to propagate unchanged. -function loadSettingsOrExit(factory) { - try { - return factory(); - } - catch (err) { - if (!(err instanceof zod_1.ZodError)) - throw err; - const missing = []; - const invalid = []; - for (const issue of err.issues) { - const name = issue.path.length ? String(issue.path[0]) : '?'; - // A missing required variable surfaces as an `invalid_type` issue whose - // received value was `undefined`. zod 3 exposed `issue.received` directly; - // zod 4 dropped that field and instead folds it into the message - // ("expected string, received undefined"). Detect both shapes so the - // missing-vs-invalid split holds across zod majors. NOTE: an invalid (but - // present) value uses a different code (invalid_format / invalid_value) or - // an `invalid_type` message that reports a non-undefined received (e.g. - // "received NaN" from a coerced number), so neither is misread as missing. - const i = issue; - const isMissing = issue.code === 'invalid_type' && - (i.received === 'undefined' || - /received undefined/i.test(i.message ?? '')); - if (isMissing) - missing.push(name); - else - invalid.push(`${name}: ${issue.message}`); - } - const lines = ['Configuration error in environment / .env:']; - if (missing.length) { - lines.push(' Missing required variable(s):'); - for (const n of [...new Set(missing)]) - lines.push(` - ${n}`); - } - if (invalid.length) { - lines.push(' Invalid value(s):'); - for (const item of invalid) - lines.push(` - ${item}`); - } - lines.push(''); - lines.push('Set them in .env (see .env.example) and try again.'); - process.stderr.write(lines.join('\n') + '\n'); - process.exit(1); - } -} diff --git a/packages/git-sync/build/engine/git.d.ts b/packages/git-sync/build/engine/git.d.ts deleted file mode 100644 index 85cba296..00000000 --- a/packages/git-sync/build/engine/git.d.ts +++ /dev/null @@ -1,259 +0,0 @@ -/** Bot identity used for engine-authored vault commits (SPEC §7.3). */ -export declare const BOT_AUTHOR_NAME = "Docmost Sync"; -export declare const BOT_AUTHOR_EMAIL = "docmost-sync@local"; -/** Default branch the vault repo is initialized on. */ -export declare 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). */ - ok: boolean; - /** True when the merge stopped on conflicts (markers left in the worktree). */ - conflict: boolean; - /** Raw combined stdout+stderr, for logging/diagnostics. */ - output: string; -} -/** Options for an engine-authored commit (provenance, SPEC §7.3). */ -export interface CommitOptions { - authorName: string; - authorEmail: string; - /** - * Trailer lines appended to the commit message body (e.g. - * `Docmost-Sync-Source: docmost`). These are the machine-readable provenance - * the loop-guard keys on (SPEC §12, "commit-attribution"). - */ - trailers?: string[]; -} -/** - * A git wrapper bound to a single vault path. Construct once per vault; every - * method runs git with `cwd = vaultPath`. - */ -export declare class VaultGit { - private readonly vaultPath; - constructor(vaultPath: string); - /** - * Preflight: verify a runnable `git` binary is on PATH. The daemon shells out - * to system `git` for every vault operation, so a missing binary (e.g. a slim - * container image without git) must fail fast with an actionable message - * rather than a cryptic ENOENT deep inside the first real git call. Presence - * check only — we do NOT gate on a specific version. Runs `git --version` - * with NO `cwd` (the vault dir may not exist yet at preflight time). - */ - assertGitAvailable(): Promise; - /** - * Run a git command in the vault and return trimmed stdout. THIN wrapper over - * the single `runRaw` primitive: throws a clear, unified Error (including - * stderr/stdout) on a non-zero exit. - */ - private run; - /** - * The ONE primitive every git invocation in this module flows through. Builds - * the full argv (`--no-pager -c core.quotepath=false `), env, cwd, and - * maxBuffer, runs git, and NEVER throws — it returns the exit info so callers - * can treat a non-zero exit as either an error (`run`) or a meaningful state - * (e.g. a merge conflict, a porcelain diff that "fails" deliberately). - * - * - argv: ALWAYS prepends `--no-pager -c core.quotepath=false`, so git never - * blocks on a pager and always prints verbatim UTF-8 paths (no octal - * escaping/quoting). `quotepath=false` is the baseline for ALL path- - * printing commands (ls-files, diff --name-only, …). - * - cwd: `opts.cwd === null` -> do NOT set cwd (the preflight, where the - * vault dir may not exist); otherwise `opts.cwd ?? this.vaultPath`. - * - env: `vaultGitEnv(opts?.env)` (cwd-isolation + caller extras). - * - On a spawn/exec error we capture the error `message` too, so a failure - * before git could write to stderr (e.g. ENOENT) is NOT lost. - */ - private runRaw; - /** - * Ensure the vault directory exists and is an initialized git repo on `main` - * with an initial (empty) commit so branches exist. Idempotent: safe to call - * on every run. Sets a LOCAL bot identity for the vault repo if none is set - * (so engine commits never fall back to a global/unset identity). - */ - ensureRepo(): Promise; - /** True if `cwd` is inside a git work-tree (the vault is initialized). */ - private isRepo; - /** True if a LOCAL git config key is set in the vault repo. */ - private hasLocalConfig; - /** True if the repo has at least one commit (HEAD resolves). */ - private hasAnyCommit; - /** True if a branch with the given name exists. */ - branchExists(name: string): Promise; - /** - * Create `name` from `fromBranch` if it does not already exist. No-op (and no - * checkout) when the branch is already present. - */ - ensureBranch(name: string, fromBranch: string): Promise; - /** Name of the currently checked-out branch. */ - currentBranch(): Promise; - /** Check out an existing branch. */ - checkout(name: string): Promise; - /** Stage everything (adds, modifications, deletions). */ - stageAll(): Promise; - /** - * True if the vault is mid-merge (an unresolved merge from a previous run, - * SPEC §9 / §12). Detected via a `MERGE_HEAD` ref OR any unmerged - * (conflicted) index entries (`git ls-files -u`). The pull cycle checks this - * BEFORE any checkout so a left-over merge produces a clear, actionable - * message instead of a raw "you need to resolve your current index first" - * failure deep inside `checkout`. This is what makes re-runs converge - * (resumability, SPEC §12). - */ - isMergeInProgress(): Promise; - /** - * Commit the currently STAGED changes with an explicit author/committer - * identity and the given trailers appended to the message body (SPEC §7.3 - * provenance). Returns `true` if a commit was made, `false` if there was - * nothing to commit (graceful no-op). The caller is expected to have staged - * its changes first (e.g. via `stageAll`). - */ - commit(message: string, opts: CommitOptions): Promise; - /** - * Low-level commit used by both `commit` and `ensureRepo`'s initial commit. - * Builds the full message with appended trailers and sets author + committer - * identity via env vars (so the committer matches the author, not the repo - * default). - */ - private commitRaw; - /** - * Merge `fromBranch` into the current branch (`git merge --no-edit`). - * Fast-forwards when possible; performs a real 3-way merge otherwise. Conflict - * state is SURFACED (returned), NOT auto-resolved (SPEC §9): the conflict - * markers are left in the worktree for manual resolution by a later increment, - * and — critically — nothing is pushed to Docmost (we never write to Docmost - * anyway). - */ - merge(fromBranch: string): Promise; - /** True if the index has any unmerged (conflicted) paths. */ - private hasUnmergedPaths; - /** - * List tracked files on the current branch (paths relative to the vault - * root, forward-slash separated). An optional glob (a git pathspec) narrows - * the listing, e.g. `"*.md"`. - * - * The target wiki is RUSSIAN, so vault file names routinely contain Cyrillic - * (e.g. `Колонка.md`). With git's DEFAULT `core.quotepath=true`, `ls-files` - * returns non-ASCII paths octal-escaped and double-quoted (`"\320\232..."`), - * which `src/pull.ts` `readExisting` would then parse as garbage paths, - * breaking move/duplicate detection. We defeat that two ways at once: - * - `core.quotepath=false` disables the octal-escape/quoting. It is now the - * `runRaw` argv baseline (prepended to EVERY invocation), so we no longer - * pass it inline here. - * - `-z` emits NUL-delimited RAW UTF-8 paths (no quoting, no newline - * ambiguity), which we split on `\0`. - * We read the RAW stdout (NOT the trimming `run()` helper, which would mangle - * the NUL-delimited bytes) and split on `\0`, dropping empty entries. Paths - * are returned verbatim — git already emits forward slashes. - */ - listTrackedFiles(glob?: string): Promise; - /** - * 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. - */ - diffNameStatus(fromRef: string, toRef: string): Promise; - /** - * 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. - */ - revParse(ref: string): Promise; - /** - * 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"). - */ - readRef(ref: string): Promise; - /** - * 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. - */ - updateRef(ref: string, target: string): Promise; - /** - * Fast-forward `branch` to `toCommit` — but ONLY if it is a TRUE fast-forward, - * i.e. the current `branch` tip is an ancestor of `toCommit` (verified via - * `git merge-base --is-ancestor `). Used to advance the - * `docmost` mirror branch after a clean push (SPEC §6 step 3 / §10): once a - * push succeeds, Docmost already contains the pushed `main` content, so the - * mirror must reflect it — otherwise the NEXT pull would diff our own write - * back and re-pull it (loop-guard). - * - * SAFETY — never force, never clobber divergent history: - * - If `branch` IS an ancestor of `toCommit`, advance it with - * `git update-ref refs/heads/ `. The `docmost` branch is - * NOT checked out during a push (push works on `main`), so updating the ref - * directly is safe and avoids any working-tree touch. - * - If `branch` is NOT an ancestor (divergent / would-be non-fast-forward), - * do NOT move it — return `{ ok: false, reason: 'not-fast-forward' }` and - * let the caller log it. We must never overwrite a `docmost` history that - * has commits the push base does not contain. - * - * Returns `{ ok: true }` when the branch was advanced (or already at - * `toCommit`, a degenerate fast-forward), `{ ok: false, reason }` otherwise. - * A missing `branch` or `toCommit` also yields `{ ok: false }` with a reason. - */ - fastForwardBranch(branch: string, toCommit: string): Promise<{ - ok: boolean; - reason?: string; - }>; - /** - * 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. - */ - showFileAtRef(ref: string, path: string): Promise; -} -/** - * Build the environment for a vault git invocation (SPEC §12 cwd-isolation). - * Used by the single `runRaw` primitive every git command flows through, so - * these pins apply uniformly (including the `git --version` preflight). - * - * cwd-isolation is this module's central safety guarantee: every git command - * MUST operate on the vault repo at `cwd: vaultPath` and nothing else. An - * inherited `GIT_DIR` / `GIT_WORK_TREE` in `process.env` would silently - * redirect the operation away from `cwd` (e.g. to the source repo or another - * checkout), defeating that guarantee. So we always strip them, regardless of - * whatever else the caller adds (author/committer identity, etc.). - * - * Exported for unit testing. - */ -export declare function vaultGitEnv(extra?: Record): NodeJS.ProcessEnv; -/** - * Build a commit message body with trailer lines appended (SPEC §7.3). The - * trailers are separated from the subject by a blank line so `git interpret- - * trailers` / `git log --format=%(trailers)` parse them as trailers. - * Exported for unit testing. - */ -export declare function buildCommitMessage(subject: string, trailers?: string[]): string; diff --git a/packages/git-sync/build/engine/git.js b/packages/git-sync/build/engine/git.js deleted file mode 100644 index 35745cf9..00000000 --- a/packages/git-sync/build/engine/git.js +++ /dev/null @@ -1,577 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.VaultGit = exports.DEFAULT_BRANCH = exports.BOT_AUTHOR_EMAIL = exports.BOT_AUTHOR_NAME = void 0; -exports.vaultGitEnv = vaultGitEnv; -exports.buildCommitMessage = buildCommitMessage; -/** - * Thin async wrapper over the system `git` binary (SPEC §5: state store = git). - * - * IMPORTANT — VAULT-SCOPED: every operation here runs with `cwd = vaultPath`, - * which is the vault's OWN git repository (default `data/vault`), SEPARATE from - * the docmost-sync source repo. This module MUST NEVER run git against the - * source repo. `data/` is gitignored by the source repo, so a nested repo under - * `data/vault` is safe. The pull cycle is READ-ONLY toward Docmost; this module - * only touches the local vault git, never a git remote (push is deferred, see - * SPEC §7). - * - * Implementation notes: - * - We shell out via `node:child_process` `execFile` (promisified), passing - * ARGS AS AN ARRAY — no shell, so there is no command injection surface even - * if a page title / branch name contains shell metacharacters. - * - EVERY git invocation funnels through the single `runRaw` primitive, which - * ALWAYS prepends `--no-pager -c core.quotepath=false` to the argv (so git - * never blocks on a pager and always prints verbatim UTF-8 paths). There is - * no exception — even the `git --version` preflight goes through `runRaw`. - * - "nothing to commit" is treated as a graceful no-op, not an error. - */ -const node_child_process_1 = require("node:child_process"); -const promises_1 = require("node:fs/promises"); -const node_util_1 = require("node:util"); -const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile); -/** Bot identity used for engine-authored vault commits (SPEC §7.3). */ -exports.BOT_AUTHOR_NAME = "Docmost Sync"; -exports.BOT_AUTHOR_EMAIL = "docmost-sync@local"; -/** Default branch the vault repo is initialized on. */ -exports.DEFAULT_BRANCH = "main"; -/** - * A git wrapper bound to a single vault path. Construct once per vault; every - * method runs git with `cwd = vaultPath`. - */ -class VaultGit { - vaultPath; - constructor(vaultPath) { - this.vaultPath = vaultPath; - } - /** - * Preflight: verify a runnable `git` binary is on PATH. The daemon shells out - * to system `git` for every vault operation, so a missing binary (e.g. a slim - * container image without git) must fail fast with an actionable message - * rather than a cryptic ENOENT deep inside the first real git call. Presence - * check only — we do NOT gate on a specific version. Runs `git --version` - * with NO `cwd` (the vault dir may not exist yet at preflight time). - */ - async assertGitAvailable() { - // Goes through the single `runRaw` primitive like every other invocation. - // `cwd: null` means "do not set a cwd" — the vault dir may not exist yet at - // preflight time, so we must not point git at a missing directory. - const r = await this.runRaw(["--version"], { cwd: null }); - if (r.code !== 0) { - const detail = (r.stderr || r.stdout || "").trim(); - throw new Error("git binary not found or not runnable — install git (the vault state " + - `store requires it). Underlying error: ${detail}`); - } - } - /** - * Run a git command in the vault and return trimmed stdout. THIN wrapper over - * the single `runRaw` primitive: throws a clear, unified Error (including - * stderr/stdout) on a non-zero exit. - */ - async run(args, opts) { - const r = await this.runRaw(args, opts); - if (r.code !== 0) { - const detail = (r.stderr || r.stdout || "").trim(); - throw new Error(`git ${args.join(" ")} failed: ${detail}`); - } - return r.stdout.trim(); - } - /** - * The ONE primitive every git invocation in this module flows through. Builds - * the full argv (`--no-pager -c core.quotepath=false `), env, cwd, and - * maxBuffer, runs git, and NEVER throws — it returns the exit info so callers - * can treat a non-zero exit as either an error (`run`) or a meaningful state - * (e.g. a merge conflict, a porcelain diff that "fails" deliberately). - * - * - argv: ALWAYS prepends `--no-pager -c core.quotepath=false`, so git never - * blocks on a pager and always prints verbatim UTF-8 paths (no octal - * escaping/quoting). `quotepath=false` is the baseline for ALL path- - * printing commands (ls-files, diff --name-only, …). - * - cwd: `opts.cwd === null` -> do NOT set cwd (the preflight, where the - * vault dir may not exist); otherwise `opts.cwd ?? this.vaultPath`. - * - env: `vaultGitEnv(opts?.env)` (cwd-isolation + caller extras). - * - On a spawn/exec error we capture the error `message` too, so a failure - * before git could write to stderr (e.g. ENOENT) is NOT lost. - */ - async runRaw(args, opts) { - const cwd = opts?.cwd === null ? undefined : (opts?.cwd ?? this.vaultPath); - try { - const { stdout, stderr } = await execFileAsync("git", ["--no-pager", "-c", "core.quotepath=false", ...args], { - // Generous buffer: file listings / porcelain output on a large vault - // can be sizable. - ...(cwd !== undefined ? { cwd } : {}), - maxBuffer: 64 * 1024 * 1024, - env: vaultGitEnv(opts?.env), - }); - return { code: 0, stdout, stderr }; - } - catch (err) { - const e = err; - return { - code: typeof e.code === "number" ? e.code : 1, - stdout: e.stdout ?? "", - // Preserve the error message when there is no stderr (e.g. a spawn - // failure like ENOENT, where promisified execFile sets stderr to an - // EMPTY STRING — so `||`, not `??`, to fall through to `message`). - stderr: e.stderr || e.message || "", - }; - } - } - /** - * Ensure the vault directory exists and is an initialized git repo on `main` - * with an initial (empty) commit so branches exist. Idempotent: safe to call - * on every run. Sets a LOCAL bot identity for the vault repo if none is set - * (so engine commits never fall back to a global/unset identity). - */ - async ensureRepo() { - await (0, promises_1.mkdir)(this.vaultPath, { recursive: true }); - if (!(await this.isRepo())) { - // `git init -b main` sets the initial branch on modern git; we still - // guard the branch name below for safety on older binaries. - await this.run(["init", "-b", exports.DEFAULT_BRANCH]); - } - // Set a local identity for the vault repo if unset, so engine commits have - // a deterministic committer even on a machine with no global git config. - if (!(await this.hasLocalConfig("user.name"))) { - await this.run(["config", "user.name", exports.BOT_AUTHOR_NAME]); - } - if (!(await this.hasLocalConfig("user.email"))) { - await this.run(["config", "user.email", exports.BOT_AUTHOR_EMAIL]); - } - // Neutralize correctness-affecting git config in the vault's LOCAL config so - // a user's GLOBAL/system config cannot change porcelain BEHAVIOR (not just - // output) and corrupt the vault. The vault is OUR dedicated repo, so LOCAL - // values (which override global/system) are the right scope. Set - // UNCONDITIONALLY every run — idempotent and cheap; `git config ` - // writes to `--local` by default inside the repo. These MUST be in place - // before any add/commit/checkout that could be affected, hence they run - // before the initial-commit block below. - // - core.autocrlf=false — CRITICAL (SPEC §11): a global core.autocrlf=true - // would rewrite LF<->CRLF on add/checkout, making our deterministic, - // byte-stable markdown churn and breaking the round-trip invariant. - // `false` guarantees git stores/checks out verbatim bytes. - // - core.safecrlf=false — avoid CRLF-related warnings/aborts on add. - // - commit.gpgsign=false — the headless daemon must never try to GPG-sign - // a commit (would fail/hang; we already set GIT_TERMINAL_PROMPT=0). - // - core.attributesFile=/dev/null — neutralize the user's GLOBAL - // gitattributes so a global clean/smudge filter (filter..clean) - // cannot rewrite the STORED blob and break §11 byte-stability (a config - // that core.autocrlf=false does not cover). POSIX-only path, which is - // fine: the daemon runs on Linux (Docker) / macOS. A system - // /etc/gitattributes remains the host admin's domain (out of scope). - // NOTE: these stay PERSISTED LOCAL config (not `-c` flags) on purpose — a - // human running git by hand in the vault must inherit the same neutralized - // behavior; a transient `-c` would not persist. (core.quotepath, by - // contrast, only affects OUR parsing of output and so is baked into the - // `runRaw` argv baseline instead.) - try { - await this.run(["config", "core.autocrlf", "false"]); - await this.run(["config", "core.safecrlf", "false"]); - await this.run(["config", "commit.gpgsign", "false"]); - await this.run(["config", "core.attributesFile", "/dev/null"]); - } - catch (err) { - const detail = err instanceof Error ? err.message : String(err); - throw new Error(`failed to pin vault git config (SPEC §11) — ensure ${this.vaultPath}` + - "/.git/config is writable and not locked (e.g. stale config.lock): " + - detail); - } - // Create the initial empty commit on `main` if the repo has no commits yet, - // so both `main` and (later) `docmost` branches have a common base. - if (!(await this.hasAnyCommit())) { - // Make sure we are on the default branch before the first commit (covers - // the older-git case where `init -b` was not honored). - await this.run(["checkout", "-B", exports.DEFAULT_BRANCH]); - await this.commitRaw("init vault", { - authorName: exports.BOT_AUTHOR_NAME, - authorEmail: exports.BOT_AUTHOR_EMAIL, - allowEmpty: true, - }); - } - } - /** True if `cwd` is inside a git work-tree (the vault is initialized). */ - async isRepo() { - const r = await this.runRaw(["rev-parse", "--is-inside-work-tree"]); - return r.code === 0 && r.stdout.trim() === "true"; - } - /** True if a LOCAL git config key is set in the vault repo. */ - async hasLocalConfig(key) { - const r = await this.runRaw(["config", "--local", "--get", key]); - return r.code === 0 && r.stdout.trim().length > 0; - } - /** True if the repo has at least one commit (HEAD resolves). */ - async hasAnyCommit() { - const r = await this.runRaw(["rev-parse", "--verify", "HEAD"]); - return r.code === 0; - } - /** True if a branch with the given name exists. */ - async branchExists(name) { - const r = await this.runRaw([ - "rev-parse", - "--verify", - `refs/heads/${name}`, - ]); - return r.code === 0; - } - /** - * Create `name` from `fromBranch` if it does not already exist. No-op (and no - * checkout) when the branch is already present. - */ - async ensureBranch(name, fromBranch) { - if (await this.branchExists(name)) - return; - await this.run(["branch", name, fromBranch]); - } - /** Name of the currently checked-out branch. */ - async currentBranch() { - return this.run(["rev-parse", "--abbrev-ref", "HEAD"]); - } - /** Check out an existing branch. */ - async checkout(name) { - await this.run(["checkout", name]); - } - /** Stage everything (adds, modifications, deletions). */ - async stageAll() { - await this.run(["add", "-A"]); - } - /** - * True if the vault is mid-merge (an unresolved merge from a previous run, - * SPEC §9 / §12). Detected via a `MERGE_HEAD` ref OR any unmerged - * (conflicted) index entries (`git ls-files -u`). The pull cycle checks this - * BEFORE any checkout so a left-over merge produces a clear, actionable - * message instead of a raw "you need to resolve your current index first" - * failure deep inside `checkout`. This is what makes re-runs converge - * (resumability, SPEC §12). - */ - async isMergeInProgress() { - // MERGE_HEAD exists exactly while a merge is in progress. - const mergeHead = await this.runRaw([ - "rev-parse", - "--verify", - "--quiet", - "MERGE_HEAD", - ]); - if (mergeHead.code === 0 && mergeHead.stdout.trim().length > 0) - return true; - // Fallback / belt-and-suspenders: any unmerged index entries also mean the - // working tree is mid-conflict and a checkout would refuse. - const unmerged = await this.runRaw(["ls-files", "-u"]); - return unmerged.code === 0 && unmerged.stdout.trim().length > 0; - } - /** - * Commit the currently STAGED changes with an explicit author/committer - * identity and the given trailers appended to the message body (SPEC §7.3 - * provenance). Returns `true` if a commit was made, `false` if there was - * nothing to commit (graceful no-op). The caller is expected to have staged - * its changes first (e.g. via `stageAll`). - */ - async commit(message, opts) { - // Nothing staged -> nothing to commit. Treat as a no-op (SPEC §11: a - // deterministic re-pull of unchanged pages produces identical bytes, so - // git sees no diff and we must not error). - const staged = await this.runRaw([ - "diff", - "--cached", - "--quiet", - ]); - // `diff --cached --quiet` exits 0 when the index matches HEAD (nothing - // staged), 1 when there are staged changes. - if (staged.code === 0) - return false; - await this.commitRaw(message, opts); - return true; - } - /** - * Low-level commit used by both `commit` and `ensureRepo`'s initial commit. - * Builds the full message with appended trailers and sets author + committer - * identity via env vars (so the committer matches the author, not the repo - * default). - */ - async commitRaw(message, opts) { - const fullMessage = buildCommitMessage(message, opts.trailers); - // `--no-verify` skips pre-commit/commit-msg hooks: a global core.hooksPath - // (or any injected hook) must never interfere with engine commits in our - // dedicated vault repo. - const args = ["commit", "--no-verify", "-m", fullMessage]; - if (opts.allowEmpty) - args.push("--allow-empty"); - // Route through the single `runRaw` primitive; set author + committer - // identity via env vars (so the committer matches the author, not the repo - // default). Throw via the same unified message on a non-zero exit. - const r = await this.runRaw(args, { - env: { - GIT_AUTHOR_NAME: opts.authorName, - GIT_AUTHOR_EMAIL: opts.authorEmail, - GIT_COMMITTER_NAME: opts.authorName, - GIT_COMMITTER_EMAIL: opts.authorEmail, - }, - }); - if (r.code !== 0) { - const detail = (r.stderr || r.stdout || "").trim(); - throw new Error(`git ${args.join(" ")} failed: ${detail}`); - } - } - /** - * Merge `fromBranch` into the current branch (`git merge --no-edit`). - * Fast-forwards when possible; performs a real 3-way merge otherwise. Conflict - * state is SURFACED (returned), NOT auto-resolved (SPEC §9): the conflict - * markers are left in the worktree for manual resolution by a later increment, - * and — critically — nothing is pushed to Docmost (we never write to Docmost - * anyway). - */ - async merge(fromBranch) { - const r = await this.runRaw(["merge", "--no-edit", fromBranch]); - const output = `${r.stdout}\n${r.stderr}`.trim(); - if (r.code === 0) { - return { ok: true, conflict: false, output }; - } - // A non-zero exit on merge most commonly means a conflict. Confirm by - // checking for unmerged paths (porcelain "U" status) so we don't mislabel - // an unrelated failure as a conflict. - const conflict = await this.hasUnmergedPaths(); - return { ok: false, conflict, output }; - } - /** True if the index has any unmerged (conflicted) paths. */ - async hasUnmergedPaths() { - const r = await this.runRaw(["diff", "--name-only", "--diff-filter=U"]); - return r.code === 0 && r.stdout.trim().length > 0; - } - /** - * List tracked files on the current branch (paths relative to the vault - * root, forward-slash separated). An optional glob (a git pathspec) narrows - * the listing, e.g. `"*.md"`. - * - * The target wiki is RUSSIAN, so vault file names routinely contain Cyrillic - * (e.g. `Колонка.md`). With git's DEFAULT `core.quotepath=true`, `ls-files` - * returns non-ASCII paths octal-escaped and double-quoted (`"\320\232..."`), - * which `src/pull.ts` `readExisting` would then parse as garbage paths, - * breaking move/duplicate detection. We defeat that two ways at once: - * - `core.quotepath=false` disables the octal-escape/quoting. It is now the - * `runRaw` argv baseline (prepended to EVERY invocation), so we no longer - * pass it inline here. - * - `-z` emits NUL-delimited RAW UTF-8 paths (no quoting, no newline - * ambiguity), which we split on `\0`. - * We read the RAW stdout (NOT the trimming `run()` helper, which would mangle - * the NUL-delimited bytes) and split on `\0`, dropping empty entries. Paths - * are returned verbatim — git already emits forward slashes. - */ - async listTrackedFiles(glob) { - const r = await this.runRaw(["ls-files", "-z", ...(glob ? [glob] : [])]); - if (r.code !== 0) { - const detail = (r.stderr || r.stdout || "").trim(); - throw new Error(`git ls-files failed: ${detail}`); - } - 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, toRef) { - 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 = []; - 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]; - 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) { - 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) { - 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, target) { - await this.run(["update-ref", ref, target]); - } - /** - * Fast-forward `branch` to `toCommit` — but ONLY if it is a TRUE fast-forward, - * i.e. the current `branch` tip is an ancestor of `toCommit` (verified via - * `git merge-base --is-ancestor `). Used to advance the - * `docmost` mirror branch after a clean push (SPEC §6 step 3 / §10): once a - * push succeeds, Docmost already contains the pushed `main` content, so the - * mirror must reflect it — otherwise the NEXT pull would diff our own write - * back and re-pull it (loop-guard). - * - * SAFETY — never force, never clobber divergent history: - * - If `branch` IS an ancestor of `toCommit`, advance it with - * `git update-ref refs/heads/ `. The `docmost` branch is - * NOT checked out during a push (push works on `main`), so updating the ref - * directly is safe and avoids any working-tree touch. - * - If `branch` is NOT an ancestor (divergent / would-be non-fast-forward), - * do NOT move it — return `{ ok: false, reason: 'not-fast-forward' }` and - * let the caller log it. We must never overwrite a `docmost` history that - * has commits the push base does not contain. - * - * Returns `{ ok: true }` when the branch was advanced (or already at - * `toCommit`, a degenerate fast-forward), `{ ok: false, reason }` otherwise. - * A missing `branch` or `toCommit` also yields `{ ok: false }` with a reason. - */ - async fastForwardBranch(branch, toCommit) { - const branchRef = `refs/heads/${branch}`; - // Resolve both endpoints first so a missing ref is a clean refusal, not a - // confusing `merge-base` failure. - const branchSha = await this.revParse(branchRef); - if (branchSha === null) { - return { ok: false, reason: `branch ${branch} does not exist` }; - } - const targetSha = await this.revParse(toCommit); - if (targetSha === null) { - return { ok: false, reason: `target ${toCommit} does not resolve` }; - } - // Already at the target -> a no-op fast-forward (still ok). - if (branchSha === targetSha) - return { ok: true }; - // `merge-base --is-ancestor A B` exits 0 iff A is an ancestor of B. Only a - // true ancestor is a fast-forward; anything else is divergent and refused. - const ancestor = await this.runRaw([ - "merge-base", - "--is-ancestor", - branchSha, - targetSha, - ]); - if (ancestor.code !== 0) { - return { ok: false, reason: "not-fast-forward" }; - } - // Safe to advance: the branch is not checked out during push, so a direct - // ref update avoids a checkout/working-tree touch. - await this.updateRef(branchRef, targetSha); - return { ok: true }; - } - /** - * 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, path) { - // `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; - } -} -exports.VaultGit = VaultGit; -/** - * Build the environment for a vault git invocation (SPEC §12 cwd-isolation). - * Used by the single `runRaw` primitive every git command flows through, so - * these pins apply uniformly (including the `git --version` preflight). - * - * cwd-isolation is this module's central safety guarantee: every git command - * MUST operate on the vault repo at `cwd: vaultPath` and nothing else. An - * inherited `GIT_DIR` / `GIT_WORK_TREE` in `process.env` would silently - * redirect the operation away from `cwd` (e.g. to the source repo or another - * checkout), defeating that guarantee. So we always strip them, regardless of - * whatever else the caller adds (author/committer identity, etc.). - * - * Exported for unit testing. - */ -function vaultGitEnv(extra) { - const env = { - ...process.env, - // Locale-independent output (defense in depth). We never parse localized - // prose, but pinning the locale prevents a future regression where some - // git message we DO key on is translated by an inherited LC_ALL/LANG. - LC_ALL: "C", - LANG: "C", - // Never page (we already pass --no-pager, but a stray GIT_PAGER could still - // bite) and never block on an interactive prompt (e.g. credentials) — the - // daemon runs unattended and must not hang. - GIT_PAGER: "cat", - GIT_TERMINAL_PROMPT: "0", - ...extra, - }; - delete env.GIT_DIR; - delete env.GIT_WORK_TREE; - return env; -} -/** - * Build a commit message body with trailer lines appended (SPEC §7.3). The - * trailers are separated from the subject by a blank line so `git interpret- - * trailers` / `git log --format=%(trailers)` parse them as trailers. - * Exported for unit testing. - */ -function buildCommitMessage(subject, trailers) { - if (!trailers || trailers.length === 0) - return subject; - return `${subject}\n\n${trailers.join("\n")}`; -} diff --git a/packages/git-sync/build/engine/layout.d.ts b/packages/git-sync/build/engine/layout.d.ts deleted file mode 100644 index 8e6d14b4..00000000 --- a/packages/git-sync/build/engine/layout.d.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Pure page-tree -> vault path mapping (SPEC §12). - * - * Given the flat list of page nodes for a space (as returned by - * `listAllSpacePages`), compute for every page a deterministic, collision-free - * destination: a folder path (root -> leaf ancestors) plus a file stem (the - * page's own name, no extension). This module is intentionally PURE and - * dependency-free apart from the sanitization helpers, so the whole tree -> - * path logic is unit-testable without any I/O. The names are COSMETIC; identity - * lives in each file's meta block (pageId / slugId). - */ -/** Flat page node as returned by `listAllSpacePages` (no content). */ -export interface PageNode { - id: string; - title?: string; - slugId?: string; - parentPageId?: string | null; - hasChildren?: boolean; -} -/** A page's resolved vault destination: folder path + file stem. */ -export interface VaultEntry { - /** Folder path, root -> leaf (the page's ancestors). Empty for a root page. */ - segments: string[]; - /** The page's own file name without extension. */ - stem: string; -} -/** - * Build the full vault layout for a space. - * - * Returns a Map keyed by pageId -> `{ segments, stem }`. The result is - * deterministic for a given input and guarantees every full destination path - * (`[...segments, stem].join("/")`) is unique, so no page can silently overwrite - * another. - * - * Disambiguation is layered: - * 1. Sibling collisions (same sanitized title under the same parent) are - * resolved with a stable ` ~` suffix (the suffix is itself - * sanitized, since slugId/id is untrusted data that must never inject a - * path separator). - * 2. A final full-path pass catches residual collisions that sibling-scoping - * cannot see — e.g. two pages whose parents are BOTH outside the input set - * both bucket at the root with `segments: []`. - */ -export declare function buildVaultLayout(pages: PageNode[]): Map; diff --git a/packages/git-sync/build/engine/layout.js b/packages/git-sync/build/engine/layout.js deleted file mode 100644 index 1b229a40..00000000 --- a/packages/git-sync/build/engine/layout.js +++ /dev/null @@ -1,151 +0,0 @@ -"use strict"; -/** - * Pure page-tree -> vault path mapping (SPEC §12). - * - * Given the flat list of page nodes for a space (as returned by - * `listAllSpacePages`), compute for every page a deterministic, collision-free - * destination: a folder path (root -> leaf ancestors) plus a file stem (the - * page's own name, no extension). This module is intentionally PURE and - * dependency-free apart from the sanitization helpers, so the whole tree -> - * path logic is unit-testable without any I/O. The names are COSMETIC; identity - * lives in each file's meta block (pageId / slugId). - */ -Object.defineProperty(exports, "__esModule", { value: true }); -exports.buildVaultLayout = buildVaultLayout; -const sanitize_1 = require("./sanitize"); -/** - * Build the full vault layout for a space. - * - * Returns a Map keyed by pageId -> `{ segments, stem }`. The result is - * deterministic for a given input and guarantees every full destination path - * (`[...segments, stem].join("/")`) is unique, so no page can silently overwrite - * another. - * - * Disambiguation is layered: - * 1. Sibling collisions (same sanitized title under the same parent) are - * resolved with a stable ` ~` suffix (the suffix is itself - * sanitized, since slugId/id is untrusted data that must never inject a - * path separator). - * 2. A final full-path pass catches residual collisions that sibling-scoping - * cannot see — e.g. two pages whose parents are BOTH outside the input set - * both bucket at the root with `segments: []`. - */ -function buildVaultLayout(pages) { - // Index pages by id so the parent chain can be walked. Guard against - // duplicate ids in the input (first one wins). - const byId = new Map(); - for (const p of pages) { - if (p && p.id && !byId.has(p.id)) - byId.set(p.id, p); - } - // Resolve each node's display name once, deterministically, tracking sibling - // collisions per parent. `usedBySibling` maps a parent key -> set of names - // already taken under that parent. The bucket key is the node's parent ONLY - // when that parent is actually present in `byId`; otherwise (null parent, or - // an orphan whose parent is outside the input set) the node buckets at - // `"__root__"`. This is critical: orphans land at the vault root (see - // `folderSegmentsFor`), so they MUST share the root bucket with real root - // pages to be disambiguated against each other here — making `nameById` final - // before any `segments` are computed, so no ancestor name can drift later. - const usedBySibling = new Map(); - const nameById = new Map(); - for (const p of pages) { - if (p && p.id && !nameById.has(p.id)) { - const parentKey = p.parentPageId && byId.has(p.parentPageId) ? p.parentPageId : "__root__"; - nameById.set(p.id, nameForNode(p, parentKey, usedBySibling)); - } - } - // Every id we index above MUST get a resolved name; this helper returns it - // and THROWS if it is somehow absent, rather than silently recomputing a - // DIFFERENT, non-disambiguated name (which would desync a folder segment from - // its target file). - const nameOf = (id) => { - const name = nameById.get(id); - if (name === undefined) { - throw new Error(`buildVaultLayout: no resolved name for page id ${id}`); - } - return name; - }; - // Build the folder path for a page by walking parentPageId to the root. The - // page's OWN name is the file stem; its ancestors become folders. A `visited` - // guard prevents an infinite loop on a malformed parent cycle. - const folderSegmentsFor = (node) => { - const ancestors = []; - const visited = new Set(); - let current = node.parentPageId - ? byId.get(node.parentPageId) - : undefined; - while (current && current.id && !visited.has(current.id)) { - visited.add(current.id); - ancestors.unshift(nameOf(current.id)); - current = current.parentPageId - ? byId.get(current.parentPageId) - : undefined; - } - return ancestors; - }; - // First pass: compute the provisional { segments, stem } for every node. - const layout = new Map(); - for (const p of pages) { - if (!p || !p.id || layout.has(p.id)) - continue; - layout.set(p.id, { - segments: folderSegmentsFor(p), - stem: nameOf(p.id), - }); - } - // Final full-path uniqueness pass — a belt-and-suspenders safety net. Note - // that cross-bucket (orphan/root) collisions are now resolved in the name pass - // above (orphans share the "__root__" bucket), so ancestor names are final - // before `segments` are built and this pass should rarely/never re-stem an - // ancestor. It only re-stems the colliding LATER leaf via the sanitized - // slugId/id, then (if still colliding) appends the id. - const usedPaths = new Set(); - const seenIds = new Set(); - const pathKey = (e) => [...e.segments, e.stem].join("/"); - for (const p of pages) { - if (!p || !p.id || seenIds.has(p.id)) - continue; - seenIds.add(p.id); - const entry = layout.get(p.id); - if (!entry) - continue; - if (usedPaths.has(pathKey(entry))) { - // First attempt: disambiguate the stem with the sanitized slugId (or id). - entry.stem = (0, sanitize_1.disambiguate)(entry.stem, (0, sanitize_1.sanitizeTitle)(p.slugId ?? p.id)); - if (usedPaths.has(pathKey(entry))) { - // Still colliding: append the (sanitized) id as a last resort. The id - // is globally unique, so this always resolves the collision. - entry.stem = (0, sanitize_1.disambiguate)(entry.stem, (0, sanitize_1.sanitizeTitle)(p.id)); - } - } - usedPaths.add(pathKey(entry)); - } - return layout; -} -/** - * Compute a deterministic, collision-free name for a node among its SIBLINGS. - * `usedBySibling` maps a parent key -> set of names already taken, so two - * siblings that sanitize to the same name get a stable ` ~slugId` suffix - * (SPEC §12). The suffix is itself passed through `sanitizeTitle`, because the - * slugId/id is a second untrusted-data channel that must never leak a path - * separator into the name. `parentKey` is supplied by the caller (it resolves - * to `"__root__"` for root pages AND for orphans whose parent is outside the - * input set, so they share one bucket). The name is COSMETIC; identity lives in - * the meta block. - */ -function nameForNode(node, parentKey, usedBySibling) { - let used = usedBySibling.get(parentKey); - if (!used) { - used = new Set(); - usedBySibling.set(parentKey, used); - } - let name = (0, sanitize_1.sanitizeTitle)(node.title ?? ""); - if (used.has(name)) { - // Sibling collision: disambiguate with the stable, sanitized slugId (fall - // back to the sanitized pageId if no slugId is present). - name = (0, sanitize_1.disambiguate)(name, (0, sanitize_1.sanitizeTitle)(node.slugId ?? node.id)); - } - used.add(name); - return name; -} diff --git a/packages/git-sync/build/engine/loop-guard.d.ts b/packages/git-sync/build/engine/loop-guard.d.ts deleted file mode 100644 index 95980d02..00000000 --- a/packages/git-sync/build/engine/loop-guard.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Stable hash of a page's markdown BODY (SPEC §10 "хэш тела"). Deterministic: - * the same input string always yields the same digest, a different input a - * different one. Used to recognize our own write later (loop suppression). - * - * We hash the body STRING as-is (UTF-8) with SHA-256 and return lowercase hex. - * SPEC §10 keys on the body hash rather than file bytes; callers decide WHAT - * counts as "the body" (here it is the exact string passed in — typically the - * self-contained markdown that was pushed). No normalization is applied: the - * caller is responsible for passing a canonical/stable representation if it - * wants hash equality across cosmetic-only differences. - */ -export declare function bodyHash(markdownBody: string): string; diff --git a/packages/git-sync/build/engine/loop-guard.js b/packages/git-sync/build/engine/loop-guard.js deleted file mode 100644 index 88f4af00..00000000 --- a/packages/git-sync/build/engine/loop-guard.js +++ /dev/null @@ -1,31 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.bodyHash = bodyHash; -/** - * Loop-guard primitives (SPEC §10). The sync engine must never re-pull its OWN - * write as if it were a remote edit: after a push, the next poll will see the - * page it just wrote with a fresh `updatedAt`. To suppress that, we key on two - * signals — the body HASH of what we pushed (this module) and the `updatedAt` - * returned by the write — recorded per page at push time. - * - * This module owns the PURE, deterministic body-hash. The CONSUMPTION on the - * pull side (comparing an incoming page's body hash against the last pushed hash - * to decide "this is our own write, ignore it") is a future increment — here we - * only PRODUCE the hash and the per-page push record (see `src/push.ts`). - */ -const node_crypto_1 = require("node:crypto"); -/** - * Stable hash of a page's markdown BODY (SPEC §10 "хэш тела"). Deterministic: - * the same input string always yields the same digest, a different input a - * different one. Used to recognize our own write later (loop suppression). - * - * We hash the body STRING as-is (UTF-8) with SHA-256 and return lowercase hex. - * SPEC §10 keys on the body hash rather than file bytes; callers decide WHAT - * counts as "the body" (here it is the exact string passed in — typically the - * self-contained markdown that was pushed). No normalization is applied: the - * caller is responsible for passing a canonical/stable representation if it - * wants hash equality across cosmetic-only differences. - */ -function bodyHash(markdownBody) { - return (0, node_crypto_1.createHash)("sha256").update(markdownBody, "utf8").digest("hex"); -} diff --git a/packages/git-sync/build/engine/pull.d.ts b/packages/git-sync/build/engine/pull.d.ts deleted file mode 100644 index e2f119c0..00000000 --- a/packages/git-sync/build/engine/pull.d.ts +++ /dev/null @@ -1,136 +0,0 @@ -import type { GitSyncClient } from "./client.types"; -import { type PageNode } from "./layout"; -import { VaultGit } from "./git"; -import { type MovedEntry, type DeletionDecision } from "./reconcile"; -/** - * Injectable IO for `readExisting` (R-Pull-1, test-strategy report §5). The real - * `main` wires these to `git.listTrackedFiles("*.md")` and an `fs.readFile` - * rooted at the vault; tests pass fakes so the parsing/skip rules are unit- - * testable without a real git repo or filesystem. - */ -export interface ReadExistingDeps { - /** List tracked .md paths (forward-slash, vault-relative). */ - listTracked: () => Promise; - /** Read a tracked file's text by its (forward-slash) vault-relative path. */ - readFile: (relPath: string) => Promise; -} -/** - * Read every tracked .md file in the vault and parse its `docmost:meta` to - * recover `{ pageId, relPath }`. Files without a parseable pageId in meta are - * skipped (they are not engine-tracked pages — e.g. a stray hand-written file). - * - * The IO is injected (R-Pull-1) so this is testable with fakes. Skip rules: - * - a `readFile` rejection (tracked but missing on disk, a mid-operation race) - * -> skipped, NOT thrown; the next pull converges; - * - unparseable meta (`parseDocmostMarkdown` throws) -> skipped; - * - parseable but no `pageId` in meta -> skipped. - */ -export declare function readExisting(deps: ReadExistingDeps): Promise<{ - pageId: string; - relPath: string; -}[]>; -/** - * Input to the PURE `computePullActions` (R-Pull-2). All data, no IO: the live - * tree nodes + completeness flag (from `listSpaceTree`) and the parsed - * `existing` tracked files (from `readExisting`). - */ -export interface PullActionsInput { - /** Live page nodes for the space (from `listSpaceTree`). */ - pages: PageNode[]; - /** Whether the live tree fetch was COMPLETE (SPEC §8 suppression). */ - treeComplete: boolean; - /** Parsed tracked files: `{ pageId, relPath }` (from `readExisting`). */ - existing: { - pageId: string; - relPath: string; - }[]; -} -/** - * The PURE decisions object computed by `computePullActions` (no IO). It holds - * the reconciliation plan plus the SPEC §8 absence-deletion decision, with the - * suppression already folded in: `toDelete` is the POST-suppression set the - * caller should actually remove (empty when `deletionDecision.apply` is false). - */ -export interface PullActions { - /** Pages to (re)write at their relPath (add + update + move target). */ - toWrite: { - pageId: string; - relPath: string; - }[]; - /** Moves: write new path, then remove old path (only on a successful write). */ - moved: MovedEntry[]; - /** - * Absence-based paths to delete AFTER suppression. Empty when the decision - * suppressed deletions this cycle, so the caller can apply it unconditionally. - */ - toDelete: string[]; - /** Why absence deletions were (or were not) applied (for logging + tests). */ - deletionDecision: DeletionDecision; - /** Tracked-file count (for the suppression log messages). */ - existingCount: number; - /** Planned absence-delete count BEFORE suppression (for the log message). */ - plannedDeleteCount: number; -} -/** - * PURE pull-action planner (R-Pull-2, test-strategy report §5). Takes the live - * tree nodes + completeness + existing tracked files and returns the full set of - * decisions with NO IO: - * - * - builds the vault layout (deterministic relPath per live page), - * - `planReconciliation` -> toWrite / moved / absence-toDelete, - * - `decideAbsenceDeletions` -> the SPEC §8 suppression (incomplete-fetch + - * empty-live + mass-delete guard), folded IN here so `toDelete` is the - * POST-suppression set (empty when suppressed). - * - * Moves are NOT governed by the suppression: a moved page is present in `live`, - * so its old-path removal is real (the caller still gates it on the write - * succeeding). The expensive content fetch / file write / git ops happen in the - * thin `applyPullActions`. - */ -export declare function computePullActions(input: PullActionsInput): PullActions; -/** - * Injectable IO for `applyPullActions` (R-Pull-2). The real `main` wires these - * to the live client, the vault git wrapper, and `node:fs/promises`; tests pass - * fakes that RECORD calls so the ordering + the move-on-success data-loss guard - * are testable without real git/fs/network. - */ -export interface ApplyPullActionsDeps { - client: Pick; - git: Pick; - /** Write a file by ABSOLUTE path (mkdir of the parent is done internally). */ - writeFile: (absPath: string, text: string) => Promise; - /** Recursive mkdir of an ABSOLUTE directory path. */ - mkdir: (absDir: string) => Promise; - /** Remove a file by ABSOLUTE path (force: a missing file is a no-op). */ - rm: (absPath: string) => Promise; -} -/** Outcome counters from `applyPullActions` (for the summary + tests). */ -export interface ApplyResult { - written: number; - movedApplied: number; - deleted: number; - failed: number; - committed: boolean; - merge: { - ok: boolean; - conflict: boolean; - output: string; - }; -} -/** - * THIN IO applier (R-Pull-2). Performs the side effects in the EXACT current - * order, with all the original safety guards preserved bit-for-bit: - * - * 1. for each `toWrite`: fetch content (`client.getPageJson`) -> stabilize - * (normalize-on-write fixpoint, SPEC §11) -> mkdir + write. One bad page - * never aborts the pull (bounded-concurrency pool, fault-tolerant). - * 2. apply MOVE old-path removals — ONLY when the planner marked the old path - * removable AND the new-path write SUCCEEDED (the ⭐ data-loss guard: a - * failed move-write keeps the old path so the page never vanishes). - * 3. apply (post-suppression) absence deletes. - * 4. stageAll + commit on `docmost` (subject from ACTUAL written/deleted - * counts) + checkout main + merge docmost (conflicts surfaced, SPEC §9). - * - * `vaultRoot` roots the relPath -> absolute-path conversion for the fs deps. - */ -export declare function applyPullActions(deps: ApplyPullActionsDeps, actions: PullActions, vaultRoot: string): Promise; diff --git a/packages/git-sync/build/engine/pull.js b/packages/git-sync/build/engine/pull.js deleted file mode 100644 index 122cae08..00000000 --- a/packages/git-sync/build/engine/pull.js +++ /dev/null @@ -1,303 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.readExisting = readExisting; -exports.computePullActions = computePullActions; -exports.applyPullActions = applyPullActions; -/** - * Pull cycle — Docmost -> vault (SPEC §6 "Docmost -> ФС"). - * - * This increment turns the read-only mirror into the git-backed pull cycle: - * - * 1. ensureRepo(vault); refuse if a merge is in progress (SPEC §9/§12); - * ensureBranch("docmost", "main") (SPEC §5 branches) - * 2. checkout docmost - * 3. fetch the live tree (listSpaceTree -> {pages, complete}) -> compute the - * desired `live` files (relPath via the pure sanitize/disambiguation layout) - * 4. parse `existing` tracked .md files (pageId + relPath from docmost:meta) - * 5. plan = planReconciliation(live, existing) (pure, SPEC §5/§8); toDelete - * is absence-only, moves are separate - * 6. decideAbsenceDeletions: SUPPRESS absence deletions on an incomplete tree - * fetch (SPEC §8) and behind the mass-delete guard (defense in depth) - * 7. write each live page in its fixpoint form (normalize-on-write, SPEC §11); - * apply moved-old-path removals (only when the move write SUCCEEDED) and - * absence-delete removals (only when the decision allowed them) - * 8. stageAll + commit on `docmost` with the provenance trailer (SPEC §7.3) - * 9. checkout main + merge docmost (conflicts are surfaced, NOT auto-resolved, - * SPEC §9); push is deferred (SPEC §7) - * 10. one-line summary - * - * DIRECTION IS Docmost -> vault ONLY. Nothing here ever writes to Docmost - * (read-only: listSpaceTree + getPageJson). All git operations run against - * the vault repo (`cwd = vaultPath`), never the source repo (see ./git.ts). - * - * VENDORED into gitmost (plan §2.1/§3.1): the client seam is the native - * `GitSyncClient` (`Pick`), not the upstream REST - * `DocmostClient`; the upstream CLI `main()` entry point is dropped (the gitmost - * server drives the engine in-process). Engine LOGIC is byte-identical. - */ -const node_path_1 = require("node:path"); -const node_path_2 = require("node:path"); -const index_1 = require("../lib/index"); -const layout_1 = require("./layout"); -const git_1 = require("./git"); -const reconcile_1 = require("./reconcile"); -const stabilize_1 = require("./stabilize"); -// Engine-only mirror branch (SPEC §5): the engine writes here, humans never do. -const DOCMOST_BRANCH = "docmost"; -// Machine-readable provenance the loop-guard keys on (SPEC §7.3 / §12). -const SOURCE_TRAILER = "Docmost-Sync-Source: docmost"; -// Number of pages fetched/stabilized concurrently. Bounded so a large space -// does not open thousands of simultaneous requests/conversions at once. -const CONCURRENCY = 6; -// How often to log incremental progress (every N completed pages). -const PROGRESS_EVERY = 25; -/** Convert a vault-relative path (forward-slash) to an absolute FS path. */ -function relToAbs(vaultRoot, relPath) { - return [vaultRoot, ...relPath.split("/")].join("/"); -} -/** Convert an absolute/relative segment list under the vault to a relPath. */ -function segmentsToRelPath(segments, stem) { - return [...segments, `${stem}.md`].join("/"); -} -/** - * Read every tracked .md file in the vault and parse its `docmost:meta` to - * recover `{ pageId, relPath }`. Files without a parseable pageId in meta are - * skipped (they are not engine-tracked pages — e.g. a stray hand-written file). - * - * The IO is injected (R-Pull-1) so this is testable with fakes. Skip rules: - * - a `readFile` rejection (tracked but missing on disk, a mid-operation race) - * -> skipped, NOT thrown; the next pull converges; - * - unparseable meta (`parseDocmostMarkdown` throws) -> skipped; - * - parseable but no `pageId` in meta -> skipped. - */ -async function readExisting(deps) { - const tracked = await deps.listTracked(); - const existing = []; - for (const relPath of tracked) { - // git ls-files always emits forward-slash paths; normalize just in case. - const rel = relPath.split(node_path_2.sep).join("/"); - let text; - try { - text = await deps.readFile(rel); - } - catch { - // Tracked but missing on disk (mid-operation race) — skip; the next pull - // converges. - continue; - } - let pageId; - try { - const { meta } = (0, index_1.parseDocmostMarkdown)(text); - pageId = meta?.pageId; - } - catch { - // Unparseable meta — not engine-tracked; leave it alone. - pageId = undefined; - } - if (pageId) - existing.push({ pageId, relPath: rel }); - } - return existing; -} -/** - * PURE pull-action planner (R-Pull-2, test-strategy report §5). Takes the live - * tree nodes + completeness + existing tracked files and returns the full set of - * decisions with NO IO: - * - * - builds the vault layout (deterministic relPath per live page), - * - `planReconciliation` -> toWrite / moved / absence-toDelete, - * - `decideAbsenceDeletions` -> the SPEC §8 suppression (incomplete-fetch + - * empty-live + mass-delete guard), folded IN here so `toDelete` is the - * POST-suppression set (empty when suppressed). - * - * Moves are NOT governed by the suppression: a moved page is present in `live`, - * so its old-path removal is real (the caller still gates it on the write - * succeeding). The expensive content fetch / file write / git ops happen in the - * thin `applyPullActions`. - */ -function computePullActions(input) { - const { pages, treeComplete, existing } = input; - const layout = (0, layout_1.buildVaultLayout)(pages); - const live = []; - for (const p of pages) { - if (!p || !p.id) - continue; - const entry = layout.get(p.id); - if (!entry) - continue; - live.push({ - pageId: p.id, - relPath: segmentsToRelPath(entry.segments, entry.stem), - }); - } - // Plan reconciliation (pure). `plan.toDelete` is ABSENCE-based only; - // `plan.moved` carries move old-path removals separately. - const plan = (0, reconcile_1.planReconciliation)(live, existing); - // Decide whether the ABSENCE-based deletions may be applied this cycle - // (SPEC §8): incomplete-fetch suppression + empty-live + mass-delete guard. - // Moves are NOT governed by this. - const deletionDecision = (0, reconcile_1.decideAbsenceDeletions)({ - treeComplete, - liveCount: live.length, - existingCount: existing.length, - deleteCount: plan.toDelete.length, - }); - return { - toWrite: plan.toWrite, - moved: plan.moved, - // Fold the suppression in: a suppressed cycle deletes nothing. - toDelete: deletionDecision.apply ? plan.toDelete : [], - deletionDecision, - existingCount: existing.length, - plannedDeleteCount: plan.toDelete.length, - }; -} -/** - * THIN IO applier (R-Pull-2). Performs the side effects in the EXACT current - * order, with all the original safety guards preserved bit-for-bit: - * - * 1. for each `toWrite`: fetch content (`client.getPageJson`) -> stabilize - * (normalize-on-write fixpoint, SPEC §11) -> mkdir + write. One bad page - * never aborts the pull (bounded-concurrency pool, fault-tolerant). - * 2. apply MOVE old-path removals — ONLY when the planner marked the old path - * removable AND the new-path write SUCCEEDED (the ⭐ data-loss guard: a - * failed move-write keeps the old path so the page never vanishes). - * 3. apply (post-suppression) absence deletes. - * 4. stageAll + commit on `docmost` (subject from ACTUAL written/deleted - * counts) + checkout main + merge docmost (conflicts surfaced, SPEC §9). - * - * `vaultRoot` roots the relPath -> absolute-path conversion for the fs deps. - */ -async function applyPullActions(deps, actions, vaultRoot) { - const { client, git } = deps; - // Emit the SPEC §8 suppression warnings (preserved from the original `main`). - const decision = actions.deletionDecision; - if (!decision.apply) { - if (decision.reason === "incomplete-fetch") { - console.warn("pull: tree fetch incomplete — deletions suppressed this cycle (SPEC §8)"); - } - else if (decision.reason === "empty-live") { - console.warn(`pull: live fetch returned 0 pages but ${actions.existingCount} file(s) are ` + - `tracked — deletions suppressed this cycle (SPEC §8). Re-run when ` + - `Docmost is reachable.`); - } - else { - console.warn(`pull: plan would delete ${actions.plannedDeleteCount} of ${actions.existingCount} ` + - `tracked file(s) (mass-delete guard) — deletions suppressed this ` + - `cycle (SPEC §8). Verify the live Docmost tree, then re-run.`); - } - } - // 1. Write each live page in its fixpoint form (normalize-on-write, SPEC §11). - let written = 0; - let failed = 0; - let completed = 0; - let nextIndex = 0; - // pageIds whose write FAILED. A moved page whose new-path write failed must - // NOT have its old path removed (otherwise the page vanishes entirely). - const failedPageIds = new Set(); - const writeOne = async (w) => { - try { - const page = await client.getPageJson(w.pageId); - const meta = { - version: 1, - pageId: page.id, - slugId: page.slugId, - title: page.title, - spaceId: page.spaceId, - parentPageId: page.parentPageId ?? null, - }; - const text = await (0, stabilize_1.stabilizePageFile)(page.content, meta); - const abs = relToAbs(vaultRoot, w.relPath); - await deps.mkdir((0, node_path_1.dirname)(abs)); - await deps.writeFile(abs, text); - written++; - } - catch (err) { - failed++; - failedPageIds.add(w.pageId); - console.error(`pull: failed page ${w.pageId}:`, err instanceof Error ? err.message : String(err)); - } - finally { - completed++; - if (completed % PROGRESS_EVERY === 0) { - console.log(`pulled ${completed}/${actions.toWrite.length}`); - } - } - }; - // Bounded-concurrency pool (dependency-free): a fixed set of runners each - // take the next index until the write list is exhausted. One bad page never - // aborts the whole pull (mirrors the fault-tolerant tree walk). - const runner = async () => { - while (true) { - const i = nextIndex++; - if (i >= actions.toWrite.length) - return; - await writeOne(actions.toWrite[i]); - } - }; - await Promise.all(Array.from({ length: Math.min(CONCURRENCY, actions.toWrite.length) || 1 }, () => runner())); - // Helper: `rm` with force:true is a no-op if the file is already gone. - const removePath = async (rel, what) => { - try { - await deps.rm(relToAbs(vaultRoot, rel)); - return true; - } - catch (err) { - console.error(`pull: failed to ${what} ${rel}:`, err instanceof Error ? err.message : String(err)); - return false; - } - }; - // 2. Apply MOVE old-path removals. A moved page IS present in `live`, so its - // old path is genuinely stale — NOT subject to the incomplete-fetch - // suppression. BUT only remove the old path when (a) the planner marked it - // removable (not reused by another live page) AND (b) the new-path write - // actually SUCCEEDED — otherwise we would delete the only copy of a page - // whose move-write failed (⭐ data-loss guard). - let movedApplied = 0; - for (const m of actions.moved) { - if (!m.removeOldPath) - continue; - if (failedPageIds.has(m.pageId)) { - console.warn(`pull: move write for ${m.pageId} failed — keeping old path ` + - `${m.fromRelPath} (SPEC §8)`); - continue; - } - if (await removePath(m.fromRelPath, "remove moved old path")) - movedApplied++; - } - // 3. Apply ABSENCE-based deletions — `actions.toDelete` is ALREADY the - // post-suppression set (empty when the decision suppressed them, SPEC §8). - let deleted = 0; - for (const rel of actions.toDelete) { - if (await removePath(rel, "delete")) - deleted++; - } - // 4. Stage + commit on `docmost` (only if there is something to commit). - // Deterministic stabilized output means unchanged pages produce identical - // bytes -> git sees no diff -> no churn (SPEC §11). The subject reflects the - // ACTUAL work applied (pages written + files deleted), not the planned size, - // so a run with failures does not over-report (SPEC §5 nit). - const subject = deleted > 0 - ? `docmost: sync ${written} page(s), ${deleted} deleted` - : `docmost: sync ${written} page(s)`; - await git.stageAll(); - const committed = await git.commit(subject, { - authorName: git_1.BOT_AUTHOR_NAME, - authorEmail: git_1.BOT_AUTHOR_EMAIL, - trailers: [SOURCE_TRAILER], - }); - // Merge docmost -> main. Conflicts are surfaced and left in git (SPEC §9); - // we never push to Docmost. Push to a git remote is deferred (SPEC §7). - await git.checkout(git_1.DEFAULT_BRANCH); - const merge = await git.merge(DOCMOST_BRANCH); - if (merge.conflict) { - console.error("pull: merge of docmost -> main CONFLICTED. Conflict markers were left " + - "in the vault for manual resolution (SPEC §9). Nothing is pushed to " + - "Docmost (read-only). Resolve locally, then re-run."); - } - else if (!merge.ok) { - console.error(`pull: merge of docmost -> main failed: ${merge.output}`); - } - console.log("pull: git push to remote is DEFERRED in this increment (SPEC §7)."); - return { written, movedApplied, deleted, failed, committed, merge }; -} diff --git a/packages/git-sync/build/engine/push.d.ts b/packages/git-sync/build/engine/push.d.ts deleted file mode 100644 index 9e159508..00000000 --- a/packages/git-sync/build/engine/push.d.ts +++ /dev/null @@ -1,480 +0,0 @@ -/** - * Push cycle — vault -> Docmost (SPEC §6 "ФС → Docmost"), FIRST increment. - * - * This module mirrors the structure of `./pull.ts`: a set of VaultGit diff/ref - * primitives (in `./git.ts`), a PURE planner (`computePushActions`) that turns - * a git diff into a classified action set with NO IO, and a THIN injectable - * applier (`applyPushActions`) exercised in tests via fakes only. - * - * Direction is vault -> Docmost. The diff is `main` against - * `refs/docmost/last-pushed` (SPEC §6 step 2); each `A`/`M`/`D`/`R` row is - * translated into a Docmost mutation by `pageId` identity (SPEC §4): - * - A without pageId -> create_page (then write the assigned pageId back). - * - A with pageId -> update (restored/copied file; the page already exists). - * - M -> update content (collab/Yjs path, SPEC §2/§15.6). - * - D -> delete_page (pageId recovered from the PRE-IMAGE meta). - * - R -> rename/move (CLASSIFIED here, APPLIED in push #3). - * - * MOVE/RENAME APPLY (push #3) — DONE here. `classifyRenameMoves` (PURE) resolves - * each `renamesMoves` entry into the Docmost op(s) it needs, comparing the PATH- - * derived parent (SPEC §5: the file path is the source of truth for tree - * position, NOT stale `meta.parentPageId`) and the meta title; `applyPushActions` - * then calls `move_page` / `rename_page` (both for a reparent+retitle), or - * records a NO-OP for a cosmetic local-only file-path rename. - * - * VENDORED into gitmost (plan §2.1/§3.1): the client seam is the native - * `GitSyncClient` (`Pick`), not the upstream REST - * `DocmostClient`; the upstream CLI `main()` entry point is dropped (the gitmost - * server drives the engine in-process). Engine LOGIC is byte-identical. - */ -import { type DocmostMdMeta } from "../lib/index"; -import type { GitSyncClient } from "./client.types"; -import type { DiffEntry } from "./git"; -import { VaultGit } from "./git"; -import { type Settings } from "./settings"; -export type { DiffEntry } from "./git"; -/** 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; -} -/** - * A CLASSIFIED rename/move (push #3): a `RenameMoveAction` resolved into the - * Docmost op(s) it actually needs. The file PATH is the source of truth for tree - * position (SPEC §5: "истина связи — pageId, не путь" — the path is COSMETIC and - * LOCAL, the page identity is its pageId), so we compare the RESOLVED parent of - * the new path against the resolved parent of the old path, and the title in the - * current meta against the title in the previous meta. Each sub-op is emitted - * ONLY when something real changed: - * - `move` — the resolved parent page changed (reparent in Docmost). A `null` - * `parentPageId` means the new parent is ROOT (the file sits at the space - * root, no enclosing folder). - * - `rename` — the page title changed (a pure title edit in Docmost). - * - `noop` — neither changed: a purely LOCAL file-path rename (same parent, - * same title). The page identity is its pageId, so Docmost is NOT called. - * `move` and `rename` are independent and may BOTH be present (reparent + retitle). - */ -export interface RenameMoveActionClassified { - pageId: string; - oldPath: string; - newPath: string; - /** Present iff the resolved parent changed -> `move_page` (reparent). */ - move?: { - parentPageId: string | null; - }; - /** Present iff the title changed -> `rename_page` (title-only). */ - rename?: { - title: string; - }; - /** True iff neither parent nor title changed (cosmetic local-only rename). */ - noop?: true; -} -/** - * Injected resolvers for the PURE `classifyRenameMoves` (push #3). Both are PURE - * given a path + side; the real `main` (a follow-up) wires them to the file tree - * (`readFile` for `current`, `git.showFileAtRef` for `prev`), tests pass plain - * lookups. SPEC §5 path-as-truth: - * - `metaAt`: the file's `docmost:meta` at that side (for the title). - * - `resolveParentPageId`: the pageId of the page whose FILE is the parent - * FOLDER's `.md` (one level up from the given path), or `null` for ROOT. - */ -export interface ClassifyRenameMovesDeps { - metaAt: (path: string, side: MetaSide) => DocmostMdMeta | null; - resolveParentPageId: (path: string, side: MetaSide) => string | null; -} -/** - * PURE classifier for the `renamesMoves` produced by `computePushActions` - * (push #3, SPEC §5/§6/§8). Resolves each `{pageId, oldPath, newPath}` into the - * Docmost op(s) it needs, with NO IO (both resolvers are injected). - * - * SPEC §5 — the file PATH is the source of truth for tree position, NOT the - * (possibly stale) `meta.parentPageId`. So the NEW parent is resolved from - * `newPath`'s enclosing folder, and the OLD parent from `oldPath`'s enclosing - * folder, via `deps.resolveParentPageId`. The title comes from the meta. - * - * For each entry: - * - `newParent = resolveParentPageId(newPath, 'current')`, - * `oldParent = resolveParentPageId(oldPath, 'prev')`. - * - `newTitle = metaAt(newPath,'current')?.title`, - * `oldTitle = metaAt(oldPath,'prev')?.title`. - * - include `move` iff `newParent !== oldParent` (a real reparent), - * - include `rename` iff `newTitle` is a NON-EMPTY string AND differs from - * `oldTitle` (a real title edit; an empty/absent new title is never a rename), - * - if NEITHER applies -> `noop: true` (a cosmetic local-only file-path rename; - * the page is its pageId, so Docmost is not touched). - */ -export declare function classifyRenameMoves(renamesMoves: RenameMoveAction[], deps: ClassifyRenameMovesDeps): RenameMoveActionClassified[]; -/** 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 a pageId -> UPDATE (a restored/copied file whose - * page already exists; we push its content rather than create a dup). - * - current meta has NO pageId but HAS a non-empty spaceId -> CREATE (a - * brand-new local file; the page does not exist in Docmost yet). - * - current meta has NO pageId and NO usable spaceId -> SKIP with reason - * `create-without-spaceId`: Docmost `create_page` REQUIRES a spaceId - * (§16), and a new local file may carry only partial human meta. We - * refuse to create rather than guess a space (SPEC §8 guard spirit). - * - `M` (modified): current meta has a pageId -> UPDATE content. (If a modified - * file somehow lost its pageId it is skipped — there is nothing to target.) - * - `D` (deleted): recover the pageId from the PRE-IMAGE meta (`metaAt(path, - * 'prev')`) -> DELETE. If no pageId can be recovered, SKIP with a reason - * (untracked-file guard, SPEC §8: never delete an untracked page). - * - `R` (renamed/moved): same pageId (from current meta), path changed -> - * RENAME/MOVE. Resolution of move-vs-rename + the new parentPageId is - * DEFERRED to the next increment; here we only record oldPath/newPath/ - * pageId. If the renamed file has no recoverable pageId it is SKIPPED. - * (`C` copy is treated the same as `R` for recording purposes.) - */ -export declare function computePushActions(input: PushActionsInput): PushActions; -/** The marker the push direction advances after a successful push (SPEC §5/§6). */ -export declare const LAST_PUSHED_REF = "refs/docmost/last-pushed"; -/** - * The mirror branch fast-forwarded after a clean push (SPEC §5/§6 step 3). It - * reflects "what Docmost currently contains"; advancing it to the pushed `main` - * commit closes the loop so the next pull diffs empty for the pushed pages. - */ -export declare const DOCMOST_BRANCH = "docmost"; -/** - * 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/move/rename subset of `GitSyncClient`. - * - `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`: `updateRef` (advance `refs/docmost/last-pushed`) and - * `fastForwardBranch` (advance the `docmost` mirror after a clean push, the - * loop-close — SPEC §6 step 3 / §10). - */ -export interface ApplyPushDeps { - client: Pick; - /** 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; - /** - * `updateRef` advances `refs/docmost/last-pushed`; `fastForwardBranch` advances - * the `docmost` mirror after a clean push. `showFileAtRef` reads a file's text - * at a ref (used by the move/rename classifier to resolve the PREVIOUS parent - * folder's `.md` at `refs/docmost/last-pushed`, SPEC §5 path-as-truth). - */ - git: Pick; -} -/** A file whose meta was rewritten with a freshly-assigned pageId (post-create). */ -export interface WrittenBackPage { - path: string; - pageId: string; -} -/** - * The per-page push record consulted by a FUTURE poll-suppression (SPEC §10): a - * pulled page whose body hash + `updatedAt` match a record here is OUR OWN write - * and must not be re-pulled. PRODUCED here; CONSUMED on the pull side later. - */ -export interface PushedPageRecord { - /** The Docmost pageId that was updated/created. */ - pageId: string; - /** - * The `updatedAt` from the create/update client result, when the result - * exposed one. Absent when the (fake) client did not return it. - */ - updatedAt?: string; - /** Stable hash of the markdown BODY that was pushed (SPEC §10 "хэш тела"). */ - bodyHash: string; -} -/** - * One page whose operation FAILED during apply (SPEC §12 resumability). The bad - * page is isolated — recorded here — and the rest of the batch still runs; the - * refs are NOT advanced when there is any failure, so a re-run retries cleanly. - */ -export interface PushFailure { - kind: "update" | "create" | "delete" | "move" | "rename"; - /** The pageId for update/delete/move/rename; absent for a never-id'd create. */ - pageId?: string; - /** The vault-relative path for create/update/move/rename; absent for delete. */ - path?: string; - /** The error message captured from the thrown error. */ - error: string; -} -/** - * A rename/move action that resolved to a NO-OP (push #3, SPEC §5): a purely - * LOCAL file-path rename whose resolved parent AND title are both unchanged. The - * page identity is its pageId and the path is COSMETIC/local-only, so Docmost is - * NOT called — the skip is recorded here (with the reason) for logging. - */ -export interface PushNoop { - pageId: string; - oldPath: string; - newPath: string; - /** Why no Docmost op was emitted (currently always a path-only rename). */ - reason: "path-only-rename"; -} -/** Structured outcome of `applyPushActions` (counts + write-backs + noops). */ -export interface ApplyPushResult { - created: number; - updated: number; - deleted: number; - /** Pages reparented in Docmost via `move_page` (push #3, SPEC §5/§16). */ - moved: number; - /** Pages retitled in Docmost via `rename_page` (push #3, SPEC §5/§6). */ - renamed: 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[]; - /** - * Per-page push records (pageId + optional `updatedAt` + body hash) for every - * page successfully updated/created — the §10 loop-guard data a future - * poll-suppression (pull side) will consult so it does not re-pull our own - * write. Deletes are not included (no body was pushed). - */ - pushed: PushedPageRecord[]; - /** - * Pages whose operation threw — isolated and recorded, the batch continued - * (SPEC §12). Non-empty here means the refs were NOT advanced. - */ - failures: PushFailure[]; - /** - * Rename/move actions that resolved to a NO-OP — a purely LOCAL file-path - * rename (same parent, same title). NO Docmost call was made for these (SPEC - * §5: the page is its pageId, the path is local-only). Recorded for logging. - */ - noops: PushNoop[]; - /** Diff rows the planner could not classify (carried through for logging). */ - skipped: PushActions["skipped"]; - /** Whether `refs/docmost/last-pushed` was advanced (only on a CLEAN push). */ - lastPushedAdvanced: boolean; - /** - * Result of fast-forwarding the `docmost` mirror branch after a CLEAN push - * (the loop-close, SPEC §6 step 3 / §10). `null` when no advance was attempted - * (no `pushedCommit`, or there were failures). `{ ok:false, reason }` when a - * non-fast-forward was REFUSED (divergent `docmost` history is never clobbered). - */ - docmostFastForward: { - ok: boolean; - reason?: string; - } | null; -} -/** - * THIN IO applier for the COMMON push cases (create/update/delete). Exercised - * via FAKES only in this increment — there is no live wiring. - * - * - UPDATE: read the file body, then `client.importPageMarkdown(pageId, body)`. - * This is the collab/Yjs write path (SPEC §2/§15.6) — NEVER a raw jsonb - * overwrite. The full self-contained markdown (meta + body) is sent as-is; - * `importPageMarkdown` parses the meta/body itself. - * - CREATE: derive title/spaceId/parentPageId from the file's current meta, - * `client.createPage(...)`, take the assigned pageId from the result, and - * write it BACK into the file's `docmost:meta` (re-serialized via - * `serializeDocmostMarkdownBody`, body preserved) so the file becomes - * tracked. The write-back is recorded in `writtenBack` (a follow-up commit - * is needed — NEXT increment). - * - DELETE: `client.deletePage(pageId)` — soft-delete to Trash (SPEC §8). - * - RENAME/MOVE (push #3, SPEC §5/§6/§16): classify each `renamesMoves` entry - * with `classifyRenameMoves` (resolvers read the parent FOLDER's `.md` for - * the parent pageId — path-as-truth — and the meta for the title), then: - * - `move` -> `client.movePage(pageId, parentPageId, position?)` (reparent; - * `position` is UNDEFINED for now — the client supplies a default), - * - `rename` -> `client.renamePage(pageId, title)` (title-only), - * - BOTH -> move (reparent) THEN rename (title), in that order, - * - `noop` -> NO client call; recorded in `noops` (a cosmetic local-only - * file-path rename: the page is its pageId, the path is local, SPEC §5). - * - * FAIL-SAFE / per-page isolation (SPEC §12 resumability). Each page's operation - * is wrapped in its own try/catch: a single failing page is recorded in - * `failures[]` (with its kind + pageId/path + error) and the batch CONTINUES — - * one bad page must never block the rest. Crucially, the refs are advanced ONLY - * when `failures.length === 0`: a PARTIAL push must NOT advance - * `refs/docmost/last-pushed` or the `docmost` mirror, so a re-run retries the - * whole batch cleanly (the already-applied pages are idempotent re-applies). - * - * LOOP-CLOSE (SPEC §6 step 3 / §10). After a fully-successful push, when a - * `pushedCommit` is supplied: - * - advance `refs/docmost/last-pushed` to it (what of `main` is in Docmost), AND - * - fast-forward the `docmost` mirror branch to it via - * `git.fastForwardBranch('docmost', pushedCommit)` — so the mirror reflects - * what Docmost now contains and the NEXT pull diffs EMPTY for these pages - * (it does not re-pull our own write). The ff is REFUSED (not forced) if - * `docmost` is not an ancestor of the pushed commit; the result is surfaced - * in `docmostFastForward`. On ANY failure, NEITHER ref is advanced. - * - * LOOP-GUARD DATA (SPEC §10). For every page successfully updated/created the - * result carries a `pushed` record `{ pageId, updatedAt?, bodyHash }` — the body - * hash of what was pushed plus the write's `updatedAt` (when the client returned - * one). A future pull-side poll-suppression consults this so it does not re-pull - * our own write; producing it is in scope here, consuming it is deferred. - * - * @param pushedCommit The `main` commit just reflected into Docmost (SHA or - * commit-ish). When omitted, NEITHER ref is advanced (e.g. a dry plan). - */ -export declare function applyPushActions(deps: ApplyPushDeps, actions: PushActions, pushedCommit?: string): Promise; -/** - * SPEC §5 path-as-truth: the parent FOLDER's `.md` file for a vault-relative - * (forward-slash) path. `buildVaultLayout` puts a page with children at - * `<...>/Title.md` and nests its children under `<...>/Title/`, so for - * `newPath = /Child.md` the parent page's file is `.md` (the enclosing - * folder, one level up). A path with NO enclosing folder (`Child.md`, at the - * space root) has no parent folder file -> `null` (the parent is ROOT). - */ -export declare function parentFolderFile(path: string): string | null; -/** - * 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 declare const LOCAL_AUTHOR_NAME = "Local"; -export declare const LOCAL_AUTHOR_EMAIL = "local@local"; -/** The provenance trailer marking a `main`-side (human/local) commit (SPEC §7.3). */ -export declare 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; - /** Build a real client — 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 declare function runPush(deps: PushDeps, opts: { - dryRun: boolean; -}): Promise; -/** 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 declare function parseArgs(argv: string[]): PushParsedArgs; diff --git a/packages/git-sync/build/engine/push.js b/packages/git-sync/build/engine/push.js deleted file mode 100644 index e1023b45..00000000 --- a/packages/git-sync/build/engine/push.js +++ /dev/null @@ -1,864 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.LOCAL_SOURCE_TRAILER = exports.LOCAL_AUTHOR_EMAIL = exports.LOCAL_AUTHOR_NAME = exports.DOCMOST_BRANCH = exports.LAST_PUSHED_REF = void 0; -exports.classifyRenameMoves = classifyRenameMoves; -exports.computePushActions = computePushActions; -exports.applyPushActions = applyPushActions; -exports.parentFolderFile = parentFolderFile; -exports.runPush = runPush; -exports.parseArgs = parseArgs; -/** - * Push cycle — vault -> Docmost (SPEC §6 "ФС → Docmost"), FIRST increment. - * - * This module mirrors the structure of `./pull.ts`: a set of VaultGit diff/ref - * primitives (in `./git.ts`), a PURE planner (`computePushActions`) that turns - * a git diff into a classified action set with NO IO, and a THIN injectable - * applier (`applyPushActions`) exercised in tests via fakes only. - * - * Direction is vault -> Docmost. The diff is `main` against - * `refs/docmost/last-pushed` (SPEC §6 step 2); each `A`/`M`/`D`/`R` row is - * translated into a Docmost mutation by `pageId` identity (SPEC §4): - * - A without pageId -> create_page (then write the assigned pageId back). - * - A with pageId -> update (restored/copied file; the page already exists). - * - M -> update content (collab/Yjs path, SPEC §2/§15.6). - * - D -> delete_page (pageId recovered from the PRE-IMAGE meta). - * - R -> rename/move (CLASSIFIED here, APPLIED in push #3). - * - * MOVE/RENAME APPLY (push #3) — DONE here. `classifyRenameMoves` (PURE) resolves - * each `renamesMoves` entry into the Docmost op(s) it needs, comparing the PATH- - * derived parent (SPEC §5: the file path is the source of truth for tree - * position, NOT stale `meta.parentPageId`) and the meta title; `applyPushActions` - * then calls `move_page` / `rename_page` (both for a reparent+retitle), or - * records a NO-OP for a cosmetic local-only file-path rename. - * - * VENDORED into gitmost (plan §2.1/§3.1): the client seam is the native - * `GitSyncClient` (`Pick`), not the upstream REST - * `DocmostClient`; the upstream CLI `main()` entry point is dropped (the gitmost - * server drives the engine in-process). Engine LOGIC is byte-identical. - */ -const index_1 = require("../lib/index"); -const git_1 = require("./git"); -const loop_guard_1 = require("./loop-guard"); -/** - * PURE classifier for the `renamesMoves` produced by `computePushActions` - * (push #3, SPEC §5/§6/§8). Resolves each `{pageId, oldPath, newPath}` into the - * Docmost op(s) it needs, with NO IO (both resolvers are injected). - * - * SPEC §5 — the file PATH is the source of truth for tree position, NOT the - * (possibly stale) `meta.parentPageId`. So the NEW parent is resolved from - * `newPath`'s enclosing folder, and the OLD parent from `oldPath`'s enclosing - * folder, via `deps.resolveParentPageId`. The title comes from the meta. - * - * For each entry: - * - `newParent = resolveParentPageId(newPath, 'current')`, - * `oldParent = resolveParentPageId(oldPath, 'prev')`. - * - `newTitle = metaAt(newPath,'current')?.title`, - * `oldTitle = metaAt(oldPath,'prev')?.title`. - * - include `move` iff `newParent !== oldParent` (a real reparent), - * - include `rename` iff `newTitle` is a NON-EMPTY string AND differs from - * `oldTitle` (a real title edit; an empty/absent new title is never a rename), - * - if NEITHER applies -> `noop: true` (a cosmetic local-only file-path rename; - * the page is its pageId, so Docmost is not touched). - */ -function classifyRenameMoves(renamesMoves, deps) { - return renamesMoves.map((rm) => { - const newParent = deps.resolveParentPageId(rm.newPath, "current"); - const oldParent = deps.resolveParentPageId(rm.oldPath, "prev"); - const newTitle = deps.metaAt(rm.newPath, "current")?.title; - const oldTitle = deps.metaAt(rm.oldPath, "prev")?.title; - const out = { - pageId: rm.pageId, - oldPath: rm.oldPath, - newPath: rm.newPath, - }; - // A reparent: the new path's resolved parent page differs from the old's. - if (newParent !== oldParent) { - out.move = { parentPageId: newParent }; - } - // A title edit: only when there is a real, non-empty new title that changed. - if (typeof newTitle === "string" && - newTitle.length > 0 && - newTitle !== oldTitle) { - out.rename = { title: newTitle }; - } - // Neither changed -> a purely LOCAL file-path rename; do NOT call Docmost. - if (!out.move && !out.rename) { - out.noop = true; - } - return out; - }); -} -/** - * PURE push planner (SPEC §4/§6/§8). Classifies each diff row into a Docmost - * action by `pageId` identity, with NO IO (the `metaAt` resolver is injected). - * - * Classification rules: - * - `A` (added): - * - current meta HAS a pageId -> UPDATE (a restored/copied file whose - * page already exists; we push its content rather than create a dup). - * - current meta has NO pageId but HAS a non-empty spaceId -> CREATE (a - * brand-new local file; the page does not exist in Docmost yet). - * - current meta has NO pageId and NO usable spaceId -> SKIP with reason - * `create-without-spaceId`: Docmost `create_page` REQUIRES a spaceId - * (§16), and a new local file may carry only partial human meta. We - * refuse to create rather than guess a space (SPEC §8 guard spirit). - * - `M` (modified): current meta has a pageId -> UPDATE content. (If a modified - * file somehow lost its pageId it is skipped — there is nothing to target.) - * - `D` (deleted): recover the pageId from the PRE-IMAGE meta (`metaAt(path, - * 'prev')`) -> DELETE. If no pageId can be recovered, SKIP with a reason - * (untracked-file guard, SPEC §8: never delete an untracked page). - * - `R` (renamed/moved): same pageId (from current meta), path changed -> - * RENAME/MOVE. Resolution of move-vs-rename + the new parentPageId is - * DEFERRED to the next increment; here we only record oldPath/newPath/ - * pageId. If the renamed file has no recoverable pageId it is SKIPPED. - * (`C` copy is treated the same as `R` for recording purposes.) - */ -function computePushActions(input) { - const { changes, metaAt } = input; - const actions = { - creates: [], - updates: [], - deletes: [], - renamesMoves: [], - skipped: [], - }; - for (const change of changes) { - switch (change.status) { - case "A": { - const meta = metaAt(change.path, "current"); - const pageId = meta?.pageId; - if (pageId) { - // Added but already carries a pageId (restored/copied file): the page - // exists in Docmost, so push content as an UPDATE — never a duplicate. - actions.updates.push({ pageId, path: change.path }); - } - else if (meta?.spaceId) { - // Brand-new local file with a target space -> create the page, then - // write the assigned pageId back into its meta (in `applyPushActions`). - // `meta.spaceId` is truthy here, so empty-string is also rejected. - actions.creates.push({ path: change.path }); - } - else { - // A create needs a spaceId (Docmost `create_page` requires it, §16). A - // new file with partial meta and no usable spaceId is SKIPPED rather - // than created into a guessed space (SPEC §8 guard spirit). - actions.skipped.push({ - path: change.path, - status: "A", - reason: "create-without-spaceId", - }); - } - break; - } - case "M": { - const meta = metaAt(change.path, "current"); - const pageId = meta?.pageId; - if (pageId) { - actions.updates.push({ pageId, path: change.path }); - } - else { - // A modified file with no pageId has no Docmost target to update. - actions.skipped.push({ - path: change.path, - status: "M", - reason: "modified file has no pageId in meta", - }); - } - break; - } - case "D": { - // The file is gone from `main`; recover its pageId from the PRE-IMAGE - // (the version last pushed to Docmost) so we delete the RIGHT page. - const prevMeta = metaAt(change.path, "prev"); - const pageId = prevMeta?.pageId; - if (pageId) { - actions.deletes.push({ pageId }); - } - else { - // Untracked-file guard (SPEC §8): a file with no recoverable pageId was - // never a Docmost page — do NOT translate its removal into a delete. - actions.skipped.push({ - path: change.path, - status: "D", - reason: "deleted file has no recoverable pageId (pre-image meta)", - }); - } - break; - } - case "R": - case "C": { - // Same page, new path. Identity comes from the CURRENT (post-rename) meta - // since the file still exists. RESOLUTION (move vs rename, parentPageId) - // is deferred — record oldPath/newPath/pageId only. - const meta = metaAt(change.path, "current"); - const pageId = meta?.pageId; - const oldPath = change.oldPath ?? change.path; - if (pageId) { - actions.renamesMoves.push({ - pageId, - oldPath, - newPath: change.path, - }); - } - else { - actions.skipped.push({ - path: change.path, - status: change.status, - reason: "renamed/moved file has no pageId in meta", - }); - } - break; - } - default: { - // Unreachable for A/M/D/R/C; defensive for any future status. - actions.skipped.push({ - path: change.path, - status: change.status, - reason: `unhandled diff status ${change.status}`, - }); - } - } - } - return actions; -} -// --- thin apply (create/update/delete), fakes-only in this increment --------- -/** The marker the push direction advances after a successful push (SPEC §5/§6). */ -exports.LAST_PUSHED_REF = "refs/docmost/last-pushed"; -/** - * The mirror branch fast-forwarded after a clean push (SPEC §5/§6 step 3). It - * reflects "what Docmost currently contains"; advancing it to the pushed `main` - * commit closes the loop so the next pull diffs empty for the pushed pages. - */ -exports.DOCMOST_BRANCH = "docmost"; -/** - * THIN IO applier for the COMMON push cases (create/update/delete). Exercised - * via FAKES only in this increment — there is no live wiring. - * - * - UPDATE: read the file body, then `client.importPageMarkdown(pageId, body)`. - * This is the collab/Yjs write path (SPEC §2/§15.6) — NEVER a raw jsonb - * overwrite. The full self-contained markdown (meta + body) is sent as-is; - * `importPageMarkdown` parses the meta/body itself. - * - CREATE: derive title/spaceId/parentPageId from the file's current meta, - * `client.createPage(...)`, take the assigned pageId from the result, and - * write it BACK into the file's `docmost:meta` (re-serialized via - * `serializeDocmostMarkdownBody`, body preserved) so the file becomes - * tracked. The write-back is recorded in `writtenBack` (a follow-up commit - * is needed — NEXT increment). - * - DELETE: `client.deletePage(pageId)` — soft-delete to Trash (SPEC §8). - * - RENAME/MOVE (push #3, SPEC §5/§6/§16): classify each `renamesMoves` entry - * with `classifyRenameMoves` (resolvers read the parent FOLDER's `.md` for - * the parent pageId — path-as-truth — and the meta for the title), then: - * - `move` -> `client.movePage(pageId, parentPageId, position?)` (reparent; - * `position` is UNDEFINED for now — the client supplies a default), - * - `rename` -> `client.renamePage(pageId, title)` (title-only), - * - BOTH -> move (reparent) THEN rename (title), in that order, - * - `noop` -> NO client call; recorded in `noops` (a cosmetic local-only - * file-path rename: the page is its pageId, the path is local, SPEC §5). - * - * FAIL-SAFE / per-page isolation (SPEC §12 resumability). Each page's operation - * is wrapped in its own try/catch: a single failing page is recorded in - * `failures[]` (with its kind + pageId/path + error) and the batch CONTINUES — - * one bad page must never block the rest. Crucially, the refs are advanced ONLY - * when `failures.length === 0`: a PARTIAL push must NOT advance - * `refs/docmost/last-pushed` or the `docmost` mirror, so a re-run retries the - * whole batch cleanly (the already-applied pages are idempotent re-applies). - * - * LOOP-CLOSE (SPEC §6 step 3 / §10). After a fully-successful push, when a - * `pushedCommit` is supplied: - * - advance `refs/docmost/last-pushed` to it (what of `main` is in Docmost), AND - * - fast-forward the `docmost` mirror branch to it via - * `git.fastForwardBranch('docmost', pushedCommit)` — so the mirror reflects - * what Docmost now contains and the NEXT pull diffs EMPTY for these pages - * (it does not re-pull our own write). The ff is REFUSED (not forced) if - * `docmost` is not an ancestor of the pushed commit; the result is surfaced - * in `docmostFastForward`. On ANY failure, NEITHER ref is advanced. - * - * LOOP-GUARD DATA (SPEC §10). For every page successfully updated/created the - * result carries a `pushed` record `{ pageId, updatedAt?, bodyHash }` — the body - * hash of what was pushed plus the write's `updatedAt` (when the client returned - * one). A future pull-side poll-suppression consults this so it does not re-pull - * our own write; producing it is in scope here, consuming it is deferred. - * - * @param pushedCommit The `main` commit just reflected into Docmost (SHA or - * commit-ish). When omitted, NEITHER ref is advanced (e.g. a dry plan). - */ -async function applyPushActions(deps, actions, pushedCommit) { - const { client, git } = deps; - let created = 0; - let updated = 0; - let deleted = 0; - let moved = 0; - let renamed = 0; - const writtenBack = []; - const pushed = []; - const failures = []; - const noops = []; - // 1. UPDATES — collab/Yjs write path (SPEC §2/§15.6), never a raw overwrite. - // Each update is isolated: a thrown page is recorded and the batch goes on. - for (const u of actions.updates) { - try { - const fullMarkdown = await deps.readFile(u.path); - const result = await client.importPageMarkdown(u.pageId, fullMarkdown); - updated++; - // §10 loop-guard data: hash the body we pushed + capture `updatedAt`. - pushed.push({ - pageId: u.pageId, - ...extractUpdatedAt(result), - bodyHash: (0, loop_guard_1.bodyHash)(fullMarkdown), - }); - } - catch (err) { - failures.push({ - kind: "update", - pageId: u.pageId, - path: u.path, - error: errMessage(err), - }); - } - } - // 2. CREATES — create the page, then write the assigned pageId back to meta so - // the file becomes tracked (SPEC §4 "записать присвоенный pageId обратно"). - // Isolated per page like updates. - for (const c of actions.creates) { - try { - const text = await deps.readFile(c.path); - const { meta, body } = (0, index_1.parseDocmostMarkdown)(text); - // Derive create args from the file's current meta. A new local file may - // have partial meta (e.g. title/spaceId only); spaceId is required by - // Docmost (the planner already guards a create against a missing spaceId). - const title = meta?.title ?? ""; - const spaceId = meta?.spaceId ?? ""; - const parentPageId = meta?.parentPageId ?? undefined; - const result = await client.createPage(title, body, spaceId, parentPageId); - // `createPage` returns `{ data: { id, ... }, success }`; the assigned - // pageId is at `result.data.id`. - const assignedPageId = result?.data?.id; - if (assignedPageId) { - // Re-serialize the file with the pageId in meta, body preserved. - const newMeta = { - version: meta?.version ?? 1, - ...meta, - pageId: assignedPageId, - }; - const rewritten = (0, index_1.serializeDocmostMarkdownBody)(newMeta, body); - await deps.writeFile(c.path, rewritten); - writtenBack.push({ path: c.path, pageId: assignedPageId }); - // §10 loop-guard data for the created page (hash the pushed body). - pushed.push({ - pageId: assignedPageId, - ...extractUpdatedAt(result), - bodyHash: (0, loop_guard_1.bodyHash)(text), - }); - } - created++; - } - catch (err) { - failures.push({ kind: "create", path: c.path, error: errMessage(err) }); - } - } - // 3. DELETES — soft-delete to Trash (SPEC §8), reversible. Isolated per page. - for (const d of actions.deletes) { - try { - await client.deletePage(d.pageId); - deleted++; - } - catch (err) { - failures.push({ - kind: "delete", - pageId: d.pageId, - error: errMessage(err), - }); - } - } - // 4. RENAME/MOVE (push #3, SPEC §5/§6/§16). Classify each entry against the - // tree-backed resolvers (the NEW parent comes from the new path's enclosing - // folder `.md`, the OLD parent from the old path's at last-pushed — PATH is - // the truth, not stale `meta.parentPageId`; the title from the meta), then - // apply only the real ops. Each page is isolated like the cases above: a - // thrown op is recorded in `failures` and the batch continues. ORDER for a - // page that needs both: reparent (move) FIRST, then retitle (rename). - if (actions.renamesMoves.length > 0) { - // The classifier is PURE over sync resolvers; the tree reads are async, so - // prefetch every (path, side) lookup it will make into plain tables first. - const parentTable = new Map(); - const metaTable = new Map(); - // A tree read (readFile / git.showFileAtRef) throwing must isolate THAT page - // into `failures`, NOT abort the whole batch (§12 resumability). The helpers - // already swallow their own errors, but this per-entry try/catch keeps the - // batch-isolation invariant holding regardless of future changes to them. - const prefetchFailed = new Set(); - for (const rm of actions.renamesMoves) { - // newParent + newTitle from the CURRENT tree; oldParent + oldTitle from the - // last-pushed pre-image (`prev`). Keyed by `path|side` so duplicates fold. - try { - parentTable.set(`${rm.newPath}|current`, await resolveParentPageIdViaTree(deps, rm.newPath, "current")); - parentTable.set(`${rm.oldPath}|prev`, await resolveParentPageIdViaTree(deps, rm.oldPath, "prev")); - metaTable.set(`${rm.newPath}|current`, await metaAtViaTree(deps, rm.newPath, "current")); - metaTable.set(`${rm.oldPath}|prev`, await metaAtViaTree(deps, rm.oldPath, "prev")); - } - catch (err) { - prefetchFailed.add(rm.pageId); - failures.push({ - kind: "move", - pageId: rm.pageId, - path: rm.newPath, - error: errMessage(err), - }); - } - } - const classified = classifyRenameMoves(actions.renamesMoves.filter((rm) => !prefetchFailed.has(rm.pageId)), { - metaAt: (path, side) => metaTable.get(`${path}|${side}`) ?? null, - resolveParentPageId: (path, side) => parentTable.get(`${path}|${side}`) ?? null, - }); - for (const c of classified) { - if (c.noop) { - // Cosmetic local-only file-path rename — no Docmost op (SPEC §5). - noops.push({ - pageId: c.pageId, - oldPath: c.oldPath, - newPath: c.newPath, - reason: "path-only-rename", - }); - continue; - } - // Track which op is in flight so a failure is attributed to the op that - // ACTUALLY threw: for a page needing both, a move that succeeds then a - // rename that throws must be recorded as `rename`, not `move`. - let failingKind = c.move ? "move" : "rename"; - try { - // Reparent FIRST so the page is in its new tree position, THEN retitle. - if (c.move) { - failingKind = "move"; - // TODO(next): compute a fractional-index position between siblings - // (SPEC §16). `position` is UNDEFINED here; the client supplies a valid - // default. Pass `parentPageId: null` for a move to the space ROOT. - await client.movePage(c.pageId, c.move.parentPageId); - moved++; - } - if (c.rename) { - failingKind = "rename"; - await client.renamePage(c.pageId, c.rename.title); - renamed++; - } - } - catch (err) { - // Isolate the failed page: the op that ACTUALLY threw is recorded so a - // re-run can retry. A move that threw before its rename leaves `rename` - // for the next run (idempotent re-apply); refs are NOT advanced (below). - failures.push({ - kind: failingKind, - pageId: c.pageId, - path: c.newPath, - error: errMessage(err), - }); - } - } - } - // 5. Advance the refs ONLY on a CLEAN push (no failures) AND when a pushed - // commit is supplied. A partial push must advance NEITHER ref, so a re-run - // retries the whole batch (SPEC §12). The loop-close (SPEC §6 step 3 / §10): - // advance `refs/docmost/last-pushed` AND fast-forward the `docmost` mirror, - // so Docmost's new content is mirrored and the next pull diffs empty. - let lastPushedAdvanced = false; - let docmostFastForward = null; - if (pushedCommit && failures.length === 0) { - await git.updateRef(exports.LAST_PUSHED_REF, pushedCommit); - lastPushedAdvanced = true; - // Fast-forward the mirror (refused, not forced, on a non-fast-forward — the - // caller logs the reason). Surfaced in the result. - docmostFastForward = await git.fastForwardBranch(exports.DOCMOST_BRANCH, pushedCommit); - } - return { - created, - updated, - deleted, - moved, - renamed, - writtenBack, - pushed, - failures, - noops, - skipped: actions.skipped, - lastPushedAdvanced, - docmostFastForward, - }; -} -/** Stringify a thrown value into a stable error message. */ -function errMessage(err) { - return err instanceof Error ? err.message : String(err); -} -/** - * SPEC §5 path-as-truth: the parent FOLDER's `.md` file for a vault-relative - * (forward-slash) path. `buildVaultLayout` puts a page with children at - * `<...>/Title.md` and nests its children under `<...>/Title/`, so for - * `newPath = /Child.md` the parent page's file is `.md` (the enclosing - * folder, one level up). A path with NO enclosing folder (`Child.md`, at the - * space root) has no parent folder file -> `null` (the parent is ROOT). - */ -function parentFolderFile(path) { - const slash = path.lastIndexOf("/"); - if (slash < 0) - return null; // root-level file: no enclosing folder. - return `${path.slice(0, slash)}.md`; -} -/** - * Build the `resolveParentPageId(path, side)` resolver `classifyRenameMoves` - * needs, reading the PARENT FOLDER's `.md` (SPEC §5 path-as-truth): - * - `current` -> `deps.readFile(.md)` (the live working tree), - * - `prev` -> `git.showFileAtRef('refs/docmost/last-pushed', .md)` (the - * last-pushed pre-image), - * then parse its `docmost:meta` and return that page's pageId. A root-level path - * (no enclosing folder), a missing/unreadable parent file, or a parent file with - * no parseable pageId all resolve to `null` (parent is ROOT / unknown -> - * `parentPageId: null`, SPEC §16 "parentPageId: null -> в корень"). - * - * The IO is async, so this returns an ASYNC resolver; the call sites prefetch the - * parent pageIds (the classifier itself stays pure/sync over a plain table). - */ -async function resolveParentPageIdViaTree(deps, path, side) { - const parentFile = parentFolderFile(path); - if (parentFile === null) - return null; // root-level: parent is ROOT. - let text; - try { - text = - side === "current" - ? await deps.readFile(parentFile) - : await deps.git.showFileAtRef(exports.LAST_PUSHED_REF, parentFile); - } - catch { - // Parent folder file missing/unreadable at that side -> treat as ROOT. - return null; - } - if (text === null) - return null; // showFileAtRef returns null when absent. - try { - const { meta } = (0, index_1.parseDocmostMarkdown)(text); - return meta?.pageId ?? null; - } - catch { - // Unparseable parent meta -> no resolvable parent pageId. - return null; - } -} -/** - * Resolve the file `docmost:meta` at a side for the rename/move classifier (the - * title comes from here). Mirrors `resolveParentPageIdViaTree`'s IO sides: - * `current` reads the working tree, `prev` reads `refs/docmost/last-pushed`. - * Returns `null` on a missing/unreadable/unparseable file. - */ -async function metaAtViaTree(deps, path, side) { - let text; - try { - text = - side === "current" - ? await deps.readFile(path) - : await deps.git.showFileAtRef(exports.LAST_PUSHED_REF, path); - } - catch { - return null; - } - if (text === null) - return null; - try { - return (0, index_1.parseDocmostMarkdown)(text).meta ?? null; - } - catch { - return null; - } -} -/** - * Pull an `updatedAt` out of a create/update client result, if present. The - * shape is `{ data: { updatedAt? }, ... }` (createPage) or a flatter object; - * absent in the simple fakes, so the field is omitted rather than `undefined`. - */ -function extractUpdatedAt(result) { - const r = result; - const raw = r?.data?.updatedAt ?? r?.updatedAt; - return typeof raw === "string" ? { updatedAt: raw } : {}; -} -// --- runnable push orchestration (`runPush`) --------------------------------- -// -// `runPush` is the FS->Docmost twin of `pull.ts`'s `main`: it wires the VaultGit -// diff/ref primitives + the PURE `computePushActions` planner + the THIN -// `applyPushActions` applier into one runnable cycle. SAFE BY DEFAULT — the -// engine's FIRST write path to Docmost defaults to DRY-RUN (plan only, NO -// Docmost writes, NO ref advance); an explicit `--apply` is the ONLY path that -// builds a client and mutates Docmost. -// -// Every external effect is injected (`PushDeps`) so the whole orchestration is -// driven by FAKES in tests — no live Docmost, git, fs, or network. -/** - * The human ("local") git identity used for engine-made commits on `main` in the - * push direction (SPEC §7.3). The provenance is carried by the trailer (below), - * which the loop-guard keys on; the identity is for history readability only. - * When the vault repo already has a configured `user.name`/`user.email`, git - * uses that for the working-tree commit; this is the fallback the daemon stamps. - */ -exports.LOCAL_AUTHOR_NAME = "Local"; -exports.LOCAL_AUTHOR_EMAIL = "local@local"; -/** The provenance trailer marking a `main`-side (human/local) commit (SPEC §7.3). */ -exports.LOCAL_SOURCE_TRAILER = "Docmost-Sync-Source: local"; -/** - * Run one FS->Docmost push cycle (SPEC §6 "ФС → Docmost"), DRY-RUN BY DEFAULT. - * - * Steps (mirrors `pull.ts`): - * 1. Preflight git: `assertGitAvailable` + `ensureRepo`; ABORT (clear message + - * non-zero-ish result) if a merge is in progress — never push on top of an - * unresolved conflict (SPEC §9/§12). Conflict markers must NEVER reach - * Docmost (SPEC §9). - * 2. Checkout `main` (the human-facing branch the push reads from). - * 3. Commit the human's pending working-tree changes on `main` with the - * `local` provenance trailer (SPEC §7.3). A no-op when nothing changed. - * 4. Pick the diff BASE: `refs/docmost/last-pushed` if it resolves, else the - * `docmost` mirror branch (what Docmost currently has). Resolve `main`. - * 5. `diffNameStatus(base, main)` -> changes; build the `metaAt(path, side)` - * resolver (current = working tree, prev = `git show :`); run - * the PURE `computePushActions`. - * 6. DRY-RUN (default): LOG the full plan and RETURN — NO client, NO Docmost - * calls, NO ref advance. - * 7. `--apply`: build the client, run `applyPushActions(..., pushedCommit=main)`, - * then (a) if any pageIds were written back (creates), commit them on `main` - * with the `local` trailer and RE-advance `refs/docmost/last-pushed` to the - * new commit so the recorded pageIds are persisted in what Docmost mirrors; - * (b) ESCALATE a divergent-`docmost` ff refusal (SPEC §5) with a prominent - * WARNING and a non-zero-ish flag. Then log a one-line summary. - */ -async function runPush(deps, opts) { - const { git, settings, log } = deps; - const dryRun = opts.dryRun; - // 1. Preflight git. Fail fast (actionable message via main().catch) if the git - // binary is missing — the vault state store relies on it. - await git.assertGitAvailable(); - await git.ensureRepo(); - // 1b. Refuse to push on top of an unresolved merge (SPEC §9/§12). A previous - // conflicting pull leaves the vault mid-merge; pushing now could leak - // conflict markers into Docmost (SPEC §9, the cardinal invariant). Detect - // it BEFORE any checkout/diff and stop with a clear, actionable message so - // re-runs converge once the human resolves (or aborts) the merge. - if (await git.isMergeInProgress()) { - log(`push: vault has an unresolved merge at ${settings.vaultPath} — resolve ` + - `it (or 'git merge --abort') and re-run. Nothing was pushed to Docmost ` + - `(conflict markers must never reach Docmost, SPEC §9).`); - return { mode: dryRun ? "dry-run" : "apply", aborted: "merge-in-progress" }; - } - // 2. Work on `main` — the human-facing branch the push diffs FROM. - await git.checkout(git_1.DEFAULT_BRANCH); - // 3. Commit the human's pending working-tree changes on `main` with the `local` - // provenance trailer (SPEC §7.3). A no-op commit when nothing changed is - // fine (`commit` returns false). The loop-guard keys on the trailer. - // Even on a "plan only" dry-run this commits the working tree (it is the - // only way to diff `base..main`, acceptable §6.1 behavior) — so make that - // LOCAL git mutation VISIBLE, never silent: a created commit is local-only - // and nothing is sent to Docmost. - await git.stageAll(); - const committedWorkingTree = await git.commit("local: working-tree changes", { - authorName: exports.LOCAL_AUTHOR_NAME, - authorEmail: exports.LOCAL_AUTHOR_EMAIL, - trailers: [exports.LOCAL_SOURCE_TRAILER], - }); - if (committedWorkingTree) { - const sha = await git.revParse(git_1.DEFAULT_BRANCH); - log(`push: committed local working-tree changes on main` + - (sha ? ` as ${sha.slice(0, 8)}` : "") + - ` (local git only — nothing sent to Docmost).`); - } - else { - log("push: working tree clean (no local changes to push)."); - } - // 4. Pick the diff BASE (SPEC §5/§6): `refs/docmost/last-pushed` if it resolves - // (the marker of what `main` is already in Docmost), else fall back to the - // `docmost` mirror branch (the mirror of what Docmost currently has) — which - // is what exists before the first push ever advanced last-pushed. - let base; - const lastPushedSha = await git.readRef(exports.LAST_PUSHED_REF); - if (lastPushedSha) { - base = { ref: exports.LAST_PUSHED_REF, source: "last-pushed", sha: lastPushedSha }; - } - else { - base = { - ref: exports.DOCMOST_BRANCH, - source: "docmost", - sha: await git.revParse(exports.DOCMOST_BRANCH), - }; - } - const pushedCommit = await git.revParse(git_1.DEFAULT_BRANCH); - if (!pushedCommit) { - // `main` has no commit — `ensureRepo` always makes an initial one, so this is - // defensive. Nothing to diff. - log("push: `main` has no commit to push — nothing to do."); - return { mode: dryRun ? "dry-run" : "apply", base }; - } - // 5. Diff the base against `main` and build the `metaAt` resolver (PURE planner - // input). `current` reads the live working tree; `prev` reads the base ref's - // pre-image via `git show :` (so a DELETE recovers its pageId). - const changes = await git.diffNameStatus(base.ref, git_1.DEFAULT_BRANCH); - // Synchronous resolver over PREFETCHED meta tables: `computePushActions` is - // PURE/sync, but the file/ref reads are async — so we prefetch every (path, - // side) the diff will ask for into a table first, then resolve from it. - const metaTable = new Map(); - for (const change of changes) { - // `current`: A/M/R/C still have the file on `main`. `prev`: D needs the - // pre-image; R/C also benefit (old title). Prefetch both sides per path. - const currentPath = change.path; - const prevPath = change.oldPath ?? change.path; - if (!metaTable.has(`${currentPath}|current`)) { - metaTable.set(`${currentPath}|current`, await readMetaCurrent(deps, currentPath)); - } - if (!metaTable.has(`${prevPath}|prev`)) { - metaTable.set(`${prevPath}|prev`, await readMetaPrev(deps, base.ref, prevPath)); - } - } - const metaAt = (path, side) => metaTable.get(`${path}|${side}`) ?? null; - const actions = computePushActions({ changes, metaAt }); - const planned = { - creates: actions.creates.length, - updates: actions.updates.length, - deletes: actions.deletes.length, - renamesMoves: actions.renamesMoves.length, - skipped: actions.skipped.length, - }; - // 6. DRY-RUN (default): log the full plan and RETURN — build NO client, make - // ZERO Docmost calls, advance NO refs. This is the SAFE default. - logPlan(log, base, pushedCommit, actions, planned, dryRun); - if (dryRun) { - return { mode: "dry-run", base, pushedCommit, planned }; - } - // 7. --apply: build the REAL client and execute. This is the ONLY write path. - const client = deps.makeClient(settings); - const applied = await applyPushActions({ - client, - // Pass the WHOLE `git` object (it satisfies the applier's - // `Pick` deps surface). Passing bare method references - // (`git.updateRef`, …) would lose their `this` binding, so on a REAL - // `VaultGit` they would throw `this.runRaw is not a function`. Hand over - // the object so the methods keep their receiver — exactly as `pull.ts` - // does for `applyPullActions`. - git, - readFile: deps.readFile, - writeFile: deps.writeFile, - }, actions, pushedCommit); - // 7a. Persist freshly-assigned pageIds (creates) back into git. `applyPushActions` - // rewrote those files on disk; commit them on `main` with the `local` trailer - // so the new pageIds are recorded, then RE-advance `refs/docmost/last-pushed` - // to the new commit so what Docmost mirrors and what last-pushed points at - // stay in lock-step (the write-back commit is part of `main` now). - // Track a divergent-`docmost` mirror across BOTH ff sites (the applier's main - // push ff in 7b, and the write-back ff here). A divergent mirror is a §5 - // invariant breach in EITHER branch and must escalate identically (exit 1). - let divergentDocmost = false; - if (applied.writtenBack.length > 0) { - await git.stageAll(); - const recorded = await git.commit("local: record created pageIds", { - authorName: exports.LOCAL_AUTHOR_NAME, - authorEmail: exports.LOCAL_AUTHOR_EMAIL, - trailers: [exports.LOCAL_SOURCE_TRAILER], - }); - if (recorded) { - const newCommit = await git.revParse(git_1.DEFAULT_BRANCH); - // Only re-advance when the original push was CLEAN (last-pushed was already - // advanced by the applier); a partial push left the refs untouched and a - // re-run retries the whole batch, so we must not move them either. - if (newCommit && applied.lastPushedAdvanced) { - await git.updateRef(exports.LAST_PUSHED_REF, newCommit); - const ff = await git.fastForwardBranch(exports.DOCMOST_BRANCH, newCommit); - if (!ff.ok) { - // SYMMETRIC with the main escalation (7b): a divergent mirror in the - // write-back branch is the SAME §5 invariant breach and must escalate - // (exit 1), not just log a soft warning. - divergentDocmost = true; - log(`push: WARNING — the 'docmost' mirror branch DIVERGED and was NOT ` + - `fast-forwarded to the pageId write-back commit ` + - `(${ff.reason ?? "not-fast-forward"}). The §5 invariant ('docmost' ` + - `mirrors what Docmost contains) is broken: reconcile 'docmost' ` + - `against the live Docmost tree before the next cycle.`); - } - } - } - } - // 7b. ESCALATE a divergent-`docmost` fast-forward refusal (SPEC §5 invariant - // broken). The applier already refused to clobber a divergent mirror; make - // it LOUD (not silent) so the operator notices, and fold it into the exit. - if (applied.docmostFastForward && !applied.docmostFastForward.ok) { - divergentDocmost = true; - log(`push: WARNING — the 'docmost' mirror branch DIVERGED and was NOT ` + - `fast-forwarded (${applied.docmostFastForward.reason ?? "not-fast-forward"}). ` + - `The §5 invariant ('docmost' mirrors what Docmost contains) is broken: ` + - `reconcile 'docmost' against the live Docmost tree before the next cycle.`); - } - // 7c. One-line summary (mirrors pull.ts's summary line). - log(`push complete: ${applied.created} created, ${applied.updated} updated, ` + - `${applied.deleted} deleted, ${applied.moved} moved, ${applied.renamed} ` + - `renamed, ${applied.noops.length} no-op(s), ${applied.skipped.length} ` + - `skipped, ${applied.failures.length} failure(s)` + - (divergentDocmost ? " [DIVERGENT docmost mirror]" : "")); - return { - mode: "apply", - base, - pushedCommit, - planned, - applied, - divergentDocmost, - failures: applied.failures, - }; -} -/** Parse a file's `docmost:meta` from the live working tree (`current` side). */ -async function readMetaCurrent(deps, path) { - let text; - try { - text = await deps.readFile(path); - } - catch { - return null; // absent on disk (e.g. a D row's path) -> no current meta. - } - try { - return (0, index_1.parseDocmostMarkdown)(text).meta ?? null; - } - catch { - return null; // unparseable meta -> not engine-tracked. - } -} -/** Parse a file's `docmost:meta` from the base ref's pre-image (`prev` side). */ -async function readMetaPrev(deps, baseRef, path) { - let text; - try { - text = await deps.git.showFileAtRef(baseRef, path); - } - catch { - return null; - } - if (text === null) - return null; // path absent at the base ref. - try { - return (0, index_1.parseDocmostMarkdown)(text).meta ?? null; - } - catch { - return null; - } -} -/** Emit the full plan (counts + per-item) to the injected logger. */ -function logPlan(log, base, pushedCommit, actions, planned, dryRun) { - log(`push plan (${dryRun ? "DRY-RUN — no Docmost writes" : "APPLY"}): base=` + - `${base.ref} (${base.source}${base.sha ? ` ${base.sha.slice(0, 8)}` : ""}) ` + - `-> main ${pushedCommit.slice(0, 8)}`); - log(`push plan counts: ${planned.creates} create, ${planned.updates} update, ` + - `${planned.deletes} delete, ${planned.renamesMoves} rename/move, ` + - `${planned.skipped} skipped`); - for (const c of actions.creates) - log(` create: ${c.path}`); - for (const u of actions.updates) - log(` update: ${u.pageId} (${u.path})`); - for (const d of actions.deletes) - log(` delete: ${d.pageId}`); - for (const rm of actions.renamesMoves) - log(` rename/move: ${rm.oldPath} -> ${rm.newPath} (${rm.pageId})`); - for (const s of actions.skipped) - log(` skipped [${s.status}] ${s.path}: ${s.reason}`); -} -/** - * Parse the `push` CLI flags. SAFE BY DEFAULT: without `--apply` the run is a - * DRY-RUN (plan only). Exported so the flag handling is unit-testable. - */ -function parseArgs(argv) { - return { apply: argv.includes("--apply") }; -} diff --git a/packages/git-sync/build/engine/reconcile.d.ts b/packages/git-sync/build/engine/reconcile.d.ts deleted file mode 100644 index 28a58e92..00000000 --- a/packages/git-sync/build/engine/reconcile.d.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * Pure reconciliation planner (SPEC §5/§6/§8). - * - * Given the desired live set of files (computed from the current Docmost tree) - * and the set of files currently tracked in the vault, compute what to write, - * what to move (old path to remove), and what to delete. Identity is `pageId` - * (the stable file<->page anchor, SPEC §4): a page that keeps its pageId but - * changes relPath is a MOVE, not delete+add; a tracked pageId that is gone from - * the live tree is a DELETE. - * - * This module is intentionally PURE (no IO, no git) so the whole plan is - * unit-testable. The actual file writing / git operations happen in pull.ts. - */ -/** A page that SHOULD exist in the vault at a given path. */ -export interface LiveEntry { - pageId: string; - /** Vault-relative path (forward-slash), e.g. `Space/Parent/Child.md`. */ - relPath: string; -} -/** A page currently tracked in the vault (pageId parsed from its meta). */ -export interface ExistingEntry { - pageId: string; - /** Vault-relative path (forward-slash) of the tracked file. */ - relPath: string; -} -/** A page to (re)write at its destination path. */ -export interface WriteEntry { - pageId: string; - relPath: string; -} -/** A page that moved: written at its NEW relPath, with the OLD path removed. */ -export interface MovedEntry { - pageId: string; - fromRelPath: string; - toRelPath: string; - /** - * Whether the old path (`fromRelPath`) is SAFE to remove. False when another - * live page will (re)write that exact path (path reuse): removing it would - * destroy real data, so the caller must skip the removal. The move itself is - * still recorded (the new path is written regardless). - */ - removeOldPath: boolean; -} -/** The full reconciliation plan. */ -export interface ReconciliationPlan { - /** - * Pages present in `live` -> (re)write at their relPath. This naturally - * covers add, content-update (same path) AND move (same pageId, new path), - * since every live page is (re)written regardless of whether it existed. - */ - toWrite: WriteEntry[]; - /** - * Vault-relative paths to delete because their tracked pageId is ABSENT from - * `live` (page removed/trashed). This set is ONLY absence-based deletions — - * the OLD paths of moved pages are NOT here (they live in `moved` and are - * applied separately by the caller). Keeping the two apart lets pull.ts gate - * absence deletions behind the incomplete-fetch suppression + mass-delete - * guard (SPEC §8) while still applying real moves. - */ - toDelete: string[]; - /** - * Tracked pages whose relPath changed. The caller writes the page at - * `toRelPath`, then removes `fromRelPath` — but ONLY after the new-path write - * succeeded. The old path is NOT in `toDelete`. - */ - moved: MovedEntry[]; -} -/** - * Compute the reconciliation plan. - * - * Rules: - * - Every `live` page is written at its relPath (covers add + update + move). - * - A tracked pageId present in `live` whose relPath changed is `moved`; its - * OLD relPath goes into `moved` ONLY (the caller removes it after the new - * path is written) and is NEVER added to `toDelete`. - * - A tracked pageId NOT present in `live` is an ABSENCE delete; its relPath - * is added to `toDelete`. - * - * Notes: - * - Safety filter (no data loss): no path that is a live TARGET path of any - * page is ever deleted/removed (a write owns it). This applies to BOTH the - * absence `toDelete` set AND a moved page's old-path removal — if a moved - * page's OLD path is reused by ANOTHER live page, the move records no old - * path to remove, because that path will be (re)written. - * - `existing` may legitimately contain duplicate pageIds (two stray files - * carrying the same meta pageId); each such file that is not the live target - * path is removed (as an absence/move) so the vault converges to exactly the - * live set. - */ -export declare function planReconciliation(live: LiveEntry[], existing: ExistingEntry[]): ReconciliationPlan; -/** - * Below this many tracked files the mass-delete fraction guard is not applied - * (a tiny vault where deleting "most" files is normal, e.g. 1-of-2). - */ -export declare const MASS_DELETE_MIN_EXISTING = 4; -/** Fraction of tracked files above which a delete plan is a suspected wipe. */ -export declare const MASS_DELETE_FRACTION = 0.5; -/** Why absence-based deletions were (or were not) applied this cycle. */ -export type DeletionDecision = { - apply: true; -} | { - apply: false; - reason: "incomplete-fetch" | "empty-live" | "mass-delete"; -}; -/** - * Pure decision: should the ABSENCE-based deletions (`plan.toDelete`) be applied - * this cycle? Encapsulates the SPEC §8 safety invariants so they are unit- - * testable without live creds or git: - * - * - `treeComplete === false` (a partial Docmost tree fetch) -> SUPPRESS. A page - * missing from a partial tree is NOT proof of deletion (SPEC §8); we must not - * delete merely-absent files this cycle. (Writes/updates/moves still happen.) - * - The live fetch returned 0 pages while files are tracked -> SUPPRESS - * (almost always a failed fetch, never a real "delete everything"). - * - The plan would delete more than `MASS_DELETE_FRACTION` of a non-trivial - * vault -> SUPPRESS as a mass-deletion guard (defense in depth). - * - * Moves are NOT governed by this decision: a moved page IS present in `live`, so - * its old-path removal is real (handled by the caller separately). - */ -export declare function decideAbsenceDeletions(args: { - treeComplete: boolean; - liveCount: number; - existingCount: number; - deleteCount: number; -}): DeletionDecision; diff --git a/packages/git-sync/build/engine/reconcile.js b/packages/git-sync/build/engine/reconcile.js deleted file mode 100644 index 9ebd2989..00000000 --- a/packages/git-sync/build/engine/reconcile.js +++ /dev/null @@ -1,122 +0,0 @@ -"use strict"; -/** - * Pure reconciliation planner (SPEC §5/§6/§8). - * - * Given the desired live set of files (computed from the current Docmost tree) - * and the set of files currently tracked in the vault, compute what to write, - * what to move (old path to remove), and what to delete. Identity is `pageId` - * (the stable file<->page anchor, SPEC §4): a page that keeps its pageId but - * changes relPath is a MOVE, not delete+add; a tracked pageId that is gone from - * the live tree is a DELETE. - * - * This module is intentionally PURE (no IO, no git) so the whole plan is - * unit-testable. The actual file writing / git operations happen in pull.ts. - */ -Object.defineProperty(exports, "__esModule", { value: true }); -exports.MASS_DELETE_FRACTION = exports.MASS_DELETE_MIN_EXISTING = void 0; -exports.planReconciliation = planReconciliation; -exports.decideAbsenceDeletions = decideAbsenceDeletions; -/** - * Compute the reconciliation plan. - * - * Rules: - * - Every `live` page is written at its relPath (covers add + update + move). - * - A tracked pageId present in `live` whose relPath changed is `moved`; its - * OLD relPath goes into `moved` ONLY (the caller removes it after the new - * path is written) and is NEVER added to `toDelete`. - * - A tracked pageId NOT present in `live` is an ABSENCE delete; its relPath - * is added to `toDelete`. - * - * Notes: - * - Safety filter (no data loss): no path that is a live TARGET path of any - * page is ever deleted/removed (a write owns it). This applies to BOTH the - * absence `toDelete` set AND a moved page's old-path removal — if a moved - * page's OLD path is reused by ANOTHER live page, the move records no old - * path to remove, because that path will be (re)written. - * - `existing` may legitimately contain duplicate pageIds (two stray files - * carrying the same meta pageId); each such file that is not the live target - * path is removed (as an absence/move) so the vault converges to exactly the - * live set. - */ -function planReconciliation(live, existing) { - // Desired path for each live pageId. - const liveByPageId = new Map(); - // Set of all paths that WILL be written (never delete/remove one of these). - const liveTargetPaths = new Set(); - for (const e of live) { - liveByPageId.set(e.pageId, e.relPath); - liveTargetPaths.add(e.relPath); - } - const toWrite = live.map((e) => ({ - pageId: e.pageId, - relPath: e.relPath, - })); - const moved = []; - // Absence-based deletions ONLY (tracked pageId absent from `live`). Use a Set - // so the same path coming from multiple existing rows is queued only once. - const toDeleteSet = new Set(); - for (const ex of existing) { - const liveRel = liveByPageId.get(ex.pageId); - if (liveRel === undefined) { - // Tracked page is gone from the live tree -> absence delete. - // Never queue a path a live page will (re)write (path reuse -> no loss). - if (!liveTargetPaths.has(ex.relPath)) - toDeleteSet.add(ex.relPath); - continue; - } - if (liveRel !== ex.relPath) { - // Same pageId, different path -> a MOVE. Record it so the caller can write - // the new path first, then remove the old one. If the old path is itself a - // live target (reused by another page), it must NOT be removed — the write - // owns it — so flag `removeOldPath: false` (move still recorded). - moved.push({ - pageId: ex.pageId, - fromRelPath: ex.relPath, - toRelPath: liveRel, - removeOldPath: !liveTargetPaths.has(ex.relPath), - }); - } - // liveRel === ex.relPath -> content-update in place; nothing extra to do - // (the write above re-emits the file; identical bytes => git no-op). - } - const toDelete = [...toDeleteSet]; - return { toWrite, toDelete, moved }; -} -/** - * Below this many tracked files the mass-delete fraction guard is not applied - * (a tiny vault where deleting "most" files is normal, e.g. 1-of-2). - */ -exports.MASS_DELETE_MIN_EXISTING = 4; -/** Fraction of tracked files above which a delete plan is a suspected wipe. */ -exports.MASS_DELETE_FRACTION = 0.5; -/** - * Pure decision: should the ABSENCE-based deletions (`plan.toDelete`) be applied - * this cycle? Encapsulates the SPEC §8 safety invariants so they are unit- - * testable without live creds or git: - * - * - `treeComplete === false` (a partial Docmost tree fetch) -> SUPPRESS. A page - * missing from a partial tree is NOT proof of deletion (SPEC §8); we must not - * delete merely-absent files this cycle. (Writes/updates/moves still happen.) - * - The live fetch returned 0 pages while files are tracked -> SUPPRESS - * (almost always a failed fetch, never a real "delete everything"). - * - The plan would delete more than `MASS_DELETE_FRACTION` of a non-trivial - * vault -> SUPPRESS as a mass-deletion guard (defense in depth). - * - * Moves are NOT governed by this decision: a moved page IS present in `live`, so - * its old-path removal is real (handled by the caller separately). - */ -function decideAbsenceDeletions(args) { - const { treeComplete, liveCount, existingCount, deleteCount } = args; - // No tracked files, or nothing to delete -> trivially fine to "apply". - if (existingCount === 0 || deleteCount === 0) - return { apply: true }; - if (!treeComplete) - return { apply: false, reason: "incomplete-fetch" }; - if (liveCount === 0) - return { apply: false, reason: "empty-live" }; - if (existingCount >= exports.MASS_DELETE_MIN_EXISTING && - deleteCount > existingCount * exports.MASS_DELETE_FRACTION) { - return { apply: false, reason: "mass-delete" }; - } - return { apply: true }; -} diff --git a/packages/git-sync/build/engine/roundtrip-helpers.d.ts b/packages/git-sync/build/engine/roundtrip-helpers.d.ts deleted file mode 100644 index 5211e709..00000000 --- a/packages/git-sync/build/engine/roundtrip-helpers.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Pure helpers extracted from the docmost-sync Phase-0 idempotency harness - * (`src/roundtrip.ts`). Only the IO-free comparison utilities are vendored — - * the CLI scaffold (`--fixture`/`--page`/`--corpus`, `loadSettings`, the - * `DocmostClient` live path and `process.exit`) is NOT vendored (plan §2.1: - * the roundtrip harness moves into the package's tests, not the engine). - */ -/** - * Recursively strip every `attrs.id` from a ProseMirror node tree. Block ids - * are regenerated by `markdownToProseMirror` (SPEC §11), so they must be - * ignored when comparing the semantic shape of two documents. Returns a NEW - * tree; the input is not mutated. - */ -export declare function stripBlockIds(node: any): any; -/** - * Find the first divergence between two values via a recursive deep compare. - * Returns a short path + the two differing values, or null if they are equal. - */ -export declare function firstDivergence(a: any, b: any, path?: string): { - path: string; - a: any; - b: any; -} | null; diff --git a/packages/git-sync/build/engine/roundtrip-helpers.js b/packages/git-sync/build/engine/roundtrip-helpers.js deleted file mode 100644 index a8333016..00000000 --- a/packages/git-sync/build/engine/roundtrip-helpers.js +++ /dev/null @@ -1,76 +0,0 @@ -"use strict"; -/** - * Pure helpers extracted from the docmost-sync Phase-0 idempotency harness - * (`src/roundtrip.ts`). Only the IO-free comparison utilities are vendored — - * the CLI scaffold (`--fixture`/`--page`/`--corpus`, `loadSettings`, the - * `DocmostClient` live path and `process.exit`) is NOT vendored (plan §2.1: - * the roundtrip harness moves into the package's tests, not the engine). - */ -Object.defineProperty(exports, "__esModule", { value: true }); -exports.stripBlockIds = stripBlockIds; -exports.firstDivergence = firstDivergence; -/** - * Recursively strip every `attrs.id` from a ProseMirror node tree. Block ids - * are regenerated by `markdownToProseMirror` (SPEC §11), so they must be - * ignored when comparing the semantic shape of two documents. Returns a NEW - * tree; the input is not mutated. - */ -function stripBlockIds(node) { - if (Array.isArray(node)) { - return node.map(stripBlockIds); - } - if (node && typeof node === "object") { - const out = {}; - for (const key of Object.keys(node)) { - if (key === "attrs" && node.attrs && typeof node.attrs === "object") { - // Drop the `id` attr; keep every other attribute. - const { id, ...rest } = node.attrs; - void id; - out.attrs = stripBlockIds(rest); - } - else { - out[key] = stripBlockIds(node[key]); - } - } - return out; - } - return node; -} -/** - * Find the first divergence between two values via a recursive deep compare. - * Returns a short path + the two differing values, or null if they are equal. - */ -function firstDivergence(a, b, path = "$") { - if (a === b) - return null; - const ta = typeof a; - const tb = typeof b; - if (ta !== tb || a === null || b === null) { - return { path, a, b }; - } - if (ta !== "object") { - return { path, a, b }; - } - const aIsArr = Array.isArray(a); - const bIsArr = Array.isArray(b); - if (aIsArr !== bIsArr) - return { path, a, b }; - if (aIsArr) { - if (a.length !== b.length) { - return { path: `${path}.length`, a: a.length, b: b.length }; - } - for (let i = 0; i < a.length; i++) { - const d = firstDivergence(a[i], b[i], `${path}[${i}]`); - if (d) - return d; - } - return null; - } - const keys = new Set([...Object.keys(a), ...Object.keys(b)]); - for (const k of keys) { - const d = firstDivergence(a[k], b[k], `${path}.${k}`); - if (d) - return d; - } - return null; -} diff --git a/packages/git-sync/build/engine/sanitize.d.ts b/packages/git-sync/build/engine/sanitize.d.ts deleted file mode 100644 index 0889a9f6..00000000 --- a/packages/git-sync/build/engine/sanitize.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Deterministic filename strategy (SPEC §12). - * - * The file name is COSMETIC — the source of truth for the file<->page link is - * `pageId` / `slugId` inside the meta block, so renaming a file is safe. These - * functions are intentionally dependency-free and pure, so they are trivially - * unit-testable. - */ -/** - * Sanitize a page title into a safe file-name component (WITHOUT extension). - * - * Steps: replace forbidden / control characters with "-", collapse whitespace - * runs to a single space, trim, cap the length, then guard against an empty - * result, an all-dots result, or a reserved Windows device name by prefixing - * with "_". - */ -export declare function sanitizeTitle(title: string): string; -/** - * Disambiguate a sanitized name when two siblings in the same folder collapse - * to the same name. Appends a stable suffix built from the page's `slugId`, so - * the result stays deterministic across runs (SPEC §12: `Title ~slugId`). - */ -export declare function disambiguate(name: string, slugId: string): string; diff --git a/packages/git-sync/build/engine/sanitize.js b/packages/git-sync/build/engine/sanitize.js deleted file mode 100644 index 684d0bab..00000000 --- a/packages/git-sync/build/engine/sanitize.js +++ /dev/null @@ -1,101 +0,0 @@ -"use strict"; -/** - * Deterministic filename strategy (SPEC §12). - * - * The file name is COSMETIC — the source of truth for the file<->page link is - * `pageId` / `slugId` inside the meta block, so renaming a file is safe. These - * functions are intentionally dependency-free and pure, so they are trivially - * unit-testable. - */ -Object.defineProperty(exports, "__esModule", { value: true }); -exports.sanitizeTitle = sanitizeTitle; -exports.disambiguate = disambiguate; -// Printable characters forbidden in file names on common filesystems (mainly -// Windows): / \ < > : " | ? *. Each match is replaced with a single "-". -// Spaces are NOT in this set; whitespace is normalized separately below. -// ASCII control characters (code points 0..31) are stripped in a separate pass -// (see stripControlChars) to keep this literal free of embedded control bytes. -const FORBIDDEN_PRINTABLE_RE = /[/\\<>:"|?*]/g; -// Runs of whitespace (including tabs/newlines) collapse to a single space. -const WHITESPACE_RUN_RE = /\s+/g; -// Reserved Windows device names (case-insensitive). A bare match (with or -// without an extension) is unusable as a file name, so it is prefixed with "_". -const RESERVED_WINDOWS_NAMES = new Set([ - "con", - "prn", - "aux", - "nul", - "com1", - "com2", - "com3", - "com4", - "com5", - "com6", - "com7", - "com8", - "com9", - "lpt1", - "lpt2", - "lpt3", - "lpt4", - "lpt5", - "lpt6", - "lpt7", - "lpt8", - "lpt9", -]); -// Cap on the sanitized length to stay well within filesystem path-component -// limits (255 bytes on most FSes) while leaving room for an extension and a -// disambiguation suffix. -const MAX_LENGTH = 120; -/** - * Replace every ASCII control character (code points 0..31) with "-". Done by - * scanning code points rather than a control-range regex literal, so the source - * file carries no embedded control bytes. - */ -function stripControlChars(input) { - let out = ""; - for (let i = 0; i < input.length; i++) { - out += input.charCodeAt(i) < 32 ? "-" : input[i]; - } - return out; -} -/** - * Sanitize a page title into a safe file-name component (WITHOUT extension). - * - * Steps: replace forbidden / control characters with "-", collapse whitespace - * runs to a single space, trim, cap the length, then guard against an empty - * result, an all-dots result, or a reserved Windows device name by prefixing - * with "_". - */ -function sanitizeTitle(title) { - let name = stripControlChars(title ?? "") - .replace(FORBIDDEN_PRINTABLE_RE, "-") - .replace(WHITESPACE_RUN_RE, " ") - .trim(); - if (name.length > MAX_LENGTH) { - name = name.slice(0, MAX_LENGTH).trim(); - } - // Compare the base name (before the first dot) against reserved names, so - // both "CON" and "con.md" are caught. - const base = name.split(".")[0]?.toLowerCase() ?? ""; - // A name that is empty, consists only of dots ("." / ".." / "..."), or is a - // reserved Windows device name is unusable as a path component. The all-dots - // case is a path-traversal hazard in particular: an unprefixed ".." would - // become a parent-directory segment and let a page escape the vault, so it - // MUST be neutralized here (becomes "_..", which is a literal file name). - if (name.length === 0 || - /^\.+$/.test(name) || - RESERVED_WINDOWS_NAMES.has(base)) { - name = "_" + name; - } - return name; -} -/** - * Disambiguate a sanitized name when two siblings in the same folder collapse - * to the same name. Appends a stable suffix built from the page's `slugId`, so - * the result stays deterministic across runs (SPEC §12: `Title ~slugId`). - */ -function disambiguate(name, slugId) { - return `${name} ~${slugId}`; -} diff --git a/packages/git-sync/build/engine/settings.d.ts b/packages/git-sync/build/engine/settings.d.ts deleted file mode 100644 index 3cf216eb..00000000 --- a/packages/git-sync/build/engine/settings.d.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Engine settings (plan §2.1 / §7.2 — ADAPTED for vendoring). - * - * Upstream this module also loaded `.env` (`dotenv`) and bound `parseSettings` - * to `process.env` via a `loadSettings()` entry point. In gitmost the engine is - * driven IN-PROCESS by the NestJS server, which builds the `Settings` object - * from `EnvironmentService` (plan §7.2) — so the engine must NOT reach into - * `process.env` here. We therefore vendor ONLY: - * - the `Settings` type the engine consumes, and - * - `parseSettings(env)` as a PURE function (validate a raw env object -> typed - * `Settings`), kept for unit tests and for the server to reuse if it wants - * to validate an env-shaped object. - * The `loadSettings()` / `loadDotenv()` side-effecting entry point is dropped. - */ -import { z } from 'zod'; -export declare const envSchema: z.ZodObject<{ - DOCMOST_API_URL: z.ZodString; - DOCMOST_EMAIL: z.ZodString; - DOCMOST_PASSWORD: z.ZodString; - DOCMOST_SPACE_ID: z.ZodString; - VAULT_PATH: z.ZodDefault; - GIT_REMOTE: z.ZodPipe, z.ZodOptional>; - POLL_INTERVAL_MS: z.ZodDefault>; - DEBOUNCE_MS: z.ZodDefault>; - LOG_LEVEL: z.ZodDefault>; -}, z.core.$strip>; -export type Settings = { - docmostApiUrl: string; - docmostEmail: string; - docmostPassword: string; - docmostSpaceId: string; - vaultPath: string; - gitRemote?: string; - pollIntervalMs: number; - debounceMs: number; - logLevel: 'debug' | 'info' | 'warn' | 'error'; -}; -export declare function parseSettings(env: NodeJS.ProcessEnv): Settings; diff --git a/packages/git-sync/build/engine/settings.js b/packages/git-sync/build/engine/settings.js deleted file mode 100644 index 3c69c4bf..00000000 --- a/packages/git-sync/build/engine/settings.js +++ /dev/null @@ -1,55 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.envSchema = void 0; -exports.parseSettings = parseSettings; -/** - * Engine settings (plan §2.1 / §7.2 — ADAPTED for vendoring). - * - * Upstream this module also loaded `.env` (`dotenv`) and bound `parseSettings` - * to `process.env` via a `loadSettings()` entry point. In gitmost the engine is - * driven IN-PROCESS by the NestJS server, which builds the `Settings` object - * from `EnvironmentService` (plan §7.2) — so the engine must NOT reach into - * `process.env` here. We therefore vendor ONLY: - * - the `Settings` type the engine consumes, and - * - `parseSettings(env)` as a PURE function (validate a raw env object -> typed - * `Settings`), kept for unit tests and for the server to reuse if it wants - * to validate an env-shaped object. - * The `loadSettings()` / `loadDotenv()` side-effecting entry point is dropped. - */ -const zod_1 = require("zod"); -// Schema keyed by the real ENV variable names so validation errors name the -// exact variable. Credentials and the address of our OWN Docmost instance have -// NO default — a missing value must fail at startup, never silently fall back. -exports.envSchema = zod_1.z.object({ - // Docmost connection — address of our own instance, no default. - DOCMOST_API_URL: zod_1.z.string().url(), - // Credentials for /auth/login — no default, never hardcoded. - DOCMOST_EMAIL: zod_1.z.string().min(1), - DOCMOST_PASSWORD: zod_1.z.string().min(1), - // Which Docmost space to mirror. - DOCMOST_SPACE_ID: zod_1.z.string().min(1), - // Local git vault (state store) — kept under data/ so the volume persists it. - VAULT_PATH: zod_1.z.string().min(1).default('data/vault'), - // Optional git remote the vault pushes to. Empty string is treated as unset. - GIT_REMOTE: zod_1.z.preprocess((v) => (v === '' ? undefined : v), zod_1.z.string().min(1).optional()), - // Non-secret tunables — sensible defaults are fine. - POLL_INTERVAL_MS: zod_1.z.coerce.number().int().positive().default(15000), - DEBOUNCE_MS: zod_1.z.coerce.number().int().positive().default(2000), - LOG_LEVEL: zod_1.z.enum(['debug', 'info', 'warn', 'error']).default('info'), -}); -// Pure: validate a raw environment object and map it to a typed Settings. -// Throws ZodError on bad config. No side effects — safe to import in tests. -function parseSettings(env) { - const e = exports.envSchema.parse(env); - return { - docmostApiUrl: e.DOCMOST_API_URL, - docmostEmail: e.DOCMOST_EMAIL, - docmostPassword: e.DOCMOST_PASSWORD, - docmostSpaceId: e.DOCMOST_SPACE_ID, - vaultPath: e.VAULT_PATH, - gitRemote: e.GIT_REMOTE, - pollIntervalMs: e.POLL_INTERVAL_MS, - debounceMs: e.DEBOUNCE_MS, - logLevel: e.LOG_LEVEL, - }; -} diff --git a/packages/git-sync/build/engine/stabilize.d.ts b/packages/git-sync/build/engine/stabilize.d.ts deleted file mode 100644 index b124ef8e..00000000 --- a/packages/git-sync/build/engine/stabilize.d.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Meta object as `exportPageBody` builds it (SPEC §4). Kept byte-for-byte - * compatible so files produced here match `exportPageBody`'s output exactly. - */ -export interface PageMeta { - version: 1; - pageId: string; - slugId: string; - title: string; - spaceId: string; - parentPageId: string | null; -} -/** - * Produce the self-contained `.md` file text for a page from its raw - * ProseMirror `content` + identity meta, in the verified fixpoint form. - * - * md1 = convertProseMirrorToMarkdown(content) - * doc2 = markdownToProseMirror(md1) // one import... - * stableBody = convertProseMirrorToMarkdown(doc2) // ...and re-export - * file = serializeDocmostMarkdownBody(meta, stableBody) - * - * The single export->import->export pass is the verified fixpoint (SPEC §11): - * idempotent for already-stable content, and the convergence point for the - * known converter asymmetries. - */ -export declare function stabilizePageFile(content: unknown, meta: PageMeta): Promise; diff --git a/packages/git-sync/build/engine/stabilize.js b/packages/git-sync/build/engine/stabilize.js deleted file mode 100644 index 3505b640..00000000 --- a/packages/git-sync/build/engine/stabilize.js +++ /dev/null @@ -1,39 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.stabilizePageFile = stabilizePageFile; -/** - * Normalize-on-write helper (SPEC §11 "Резолюция"). - * - * git diffs byte-for-byte, so writing a page in a NON-fixpoint markdown form - * would make the next pull re-export it to a slightly different (but stable) - * form and produce a phantom diff -> churny commits. The converter has a couple - * of known one-pass asymmetries (a block image after a paragraph adds an empty - * paragraph; a diagram materializes `data-align`), all of which converge to a - * fixpoint after ONE `export -> import -> export` round-trip. - * - * So at write time we run exactly that one pass and persist the fixpoint form. - * Already-stable content is unaffected (the pass is idempotent), so re-pulls of - * unchanged pages produce identical bytes and git sees no diff. - */ -const index_1 = require("../lib/index"); -/** - * Produce the self-contained `.md` file text for a page from its raw - * ProseMirror `content` + identity meta, in the verified fixpoint form. - * - * md1 = convertProseMirrorToMarkdown(content) - * doc2 = markdownToProseMirror(md1) // one import... - * stableBody = convertProseMirrorToMarkdown(doc2) // ...and re-export - * file = serializeDocmostMarkdownBody(meta, stableBody) - * - * The single export->import->export pass is the verified fixpoint (SPEC §11): - * idempotent for already-stable content, and the convergence point for the - * known converter asymmetries. - */ -async function stabilizePageFile(content, meta) { - const md1 = (0, index_1.convertProseMirrorToMarkdown)(content); - const doc2 = await (0, index_1.markdownToProseMirror)(md1); - const stableBody = (0, index_1.convertProseMirrorToMarkdown)(doc2); - // The meta shape is exactly what `exportPageBody` writes; cast to the lib's - // DocmostMdMeta (a superset with optional fields) for the serializer. - return (0, index_1.serializeDocmostMarkdownBody)(meta, stableBody); -} diff --git a/packages/git-sync/build/index.d.ts b/packages/git-sync/build/index.d.ts deleted file mode 100644 index a6268313..00000000 --- a/packages/git-sync/build/index.d.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Public surface of `@docmost/git-sync`. - * - * Phase A (plan §12.A) vendors only the PURE converter + pure engine modules - * from docmost-sync. Server integration (GitmostDataSource, orchestrator, - * VaultGit, pull/push) is added in later steps. - */ -export { serializeDocmostMarkdown, serializeDocmostMarkdownBody, parseDocmostMarkdown, convertProseMirrorToMarkdown, markdownToProseMirror, canonicalizeContent, docsCanonicallyEqual, } from "./lib/index"; -export type { DocmostMdMeta } from "./lib/index"; -export { planReconciliation, decideAbsenceDeletions, MASS_DELETE_MIN_EXISTING, MASS_DELETE_FRACTION, } from "./engine/reconcile"; -export type { LiveEntry, ExistingEntry, WriteEntry, MovedEntry, ReconciliationPlan, DeletionDecision, } from "./engine/reconcile"; -export { buildVaultLayout } from "./engine/layout"; -export type { PageNode, VaultEntry } from "./engine/layout"; -export { sanitizeTitle, disambiguate } from "./engine/sanitize"; -export { stabilizePageFile } from "./engine/stabilize"; -export type { PageMeta } from "./engine/stabilize"; -export { bodyHash } from "./engine/loop-guard"; -export type { GitSyncClient, GitSyncPageNodeLite } from "./engine/client.types"; -export { VaultGit, vaultGitEnv, buildCommitMessage, BOT_AUTHOR_NAME, BOT_AUTHOR_EMAIL, DEFAULT_BRANCH, } from "./engine/git"; -export type { DiffEntry, MergeResult, CommitOptions } from "./engine/git"; -export { readExisting, computePullActions, applyPullActions, } from "./engine/pull"; -export type { ReadExistingDeps, PullActionsInput, PullActions, ApplyPullActionsDeps, ApplyResult, } from "./engine/pull"; -export { classifyRenameMoves, computePushActions, applyPushActions, runPush, parentFolderFile, parseArgs, LAST_PUSHED_REF, DOCMOST_BRANCH, LOCAL_AUTHOR_NAME, LOCAL_AUTHOR_EMAIL, LOCAL_SOURCE_TRAILER, } from "./engine/push"; -export type { CreateAction, UpdateAction, DeleteAction, RenameMoveAction, RenameMoveActionClassified, ClassifyRenameMovesDeps, PushActions, PushActionsInput, MetaSide, ApplyPushDeps, WrittenBackPage, PushedPageRecord, PushFailure, PushNoop, ApplyPushResult, PushDeps, PushRunResult, PushParsedArgs, } from "./engine/push"; -export { parseSettings, envSchema } from "./engine/settings"; -export type { Settings } from "./engine/settings"; -export { loadSettingsOrExit } from "./engine/config-errors"; diff --git a/packages/git-sync/build/index.js b/packages/git-sync/build/index.js deleted file mode 100644 index 6856bbfd..00000000 --- a/packages/git-sync/build/index.js +++ /dev/null @@ -1,63 +0,0 @@ -"use strict"; -/** - * Public surface of `@docmost/git-sync`. - * - * Phase A (plan §12.A) vendors only the PURE converter + pure engine modules - * from docmost-sync. Server integration (GitmostDataSource, orchestrator, - * VaultGit, pull/push) is added in later steps. - */ -Object.defineProperty(exports, "__esModule", { value: true }); -exports.loadSettingsOrExit = exports.envSchema = exports.parseSettings = exports.LOCAL_SOURCE_TRAILER = exports.LOCAL_AUTHOR_EMAIL = exports.LOCAL_AUTHOR_NAME = exports.DOCMOST_BRANCH = exports.LAST_PUSHED_REF = exports.parseArgs = exports.parentFolderFile = exports.runPush = exports.applyPushActions = exports.computePushActions = exports.classifyRenameMoves = exports.applyPullActions = exports.computePullActions = exports.readExisting = exports.DEFAULT_BRANCH = exports.BOT_AUTHOR_EMAIL = exports.BOT_AUTHOR_NAME = exports.buildCommitMessage = exports.vaultGitEnv = exports.VaultGit = exports.bodyHash = exports.stabilizePageFile = exports.disambiguate = exports.sanitizeTitle = exports.buildVaultLayout = exports.MASS_DELETE_FRACTION = exports.MASS_DELETE_MIN_EXISTING = exports.decideAbsenceDeletions = exports.planReconciliation = exports.docsCanonicallyEqual = exports.canonicalizeContent = exports.markdownToProseMirror = exports.convertProseMirrorToMarkdown = exports.parseDocmostMarkdown = exports.serializeDocmostMarkdownBody = exports.serializeDocmostMarkdown = void 0; -// Pure converter (markdown <-> ProseMirror, file envelope, canonicalization). -var index_1 = require("./lib/index"); -Object.defineProperty(exports, "serializeDocmostMarkdown", { enumerable: true, get: function () { return index_1.serializeDocmostMarkdown; } }); -Object.defineProperty(exports, "serializeDocmostMarkdownBody", { enumerable: true, get: function () { return index_1.serializeDocmostMarkdownBody; } }); -Object.defineProperty(exports, "parseDocmostMarkdown", { enumerable: true, get: function () { return index_1.parseDocmostMarkdown; } }); -Object.defineProperty(exports, "convertProseMirrorToMarkdown", { enumerable: true, get: function () { return index_1.convertProseMirrorToMarkdown; } }); -Object.defineProperty(exports, "markdownToProseMirror", { enumerable: true, get: function () { return index_1.markdownToProseMirror; } }); -Object.defineProperty(exports, "canonicalizeContent", { enumerable: true, get: function () { return index_1.canonicalizeContent; } }); -Object.defineProperty(exports, "docsCanonicallyEqual", { enumerable: true, get: function () { return index_1.docsCanonicallyEqual; } }); -// Pure engine (no IO): reconcile planner, vault layout, sanitize, stabilize, -// loop-guard body hash. -var reconcile_1 = require("./engine/reconcile"); -Object.defineProperty(exports, "planReconciliation", { enumerable: true, get: function () { return reconcile_1.planReconciliation; } }); -Object.defineProperty(exports, "decideAbsenceDeletions", { enumerable: true, get: function () { return reconcile_1.decideAbsenceDeletions; } }); -Object.defineProperty(exports, "MASS_DELETE_MIN_EXISTING", { enumerable: true, get: function () { return reconcile_1.MASS_DELETE_MIN_EXISTING; } }); -Object.defineProperty(exports, "MASS_DELETE_FRACTION", { enumerable: true, get: function () { return reconcile_1.MASS_DELETE_FRACTION; } }); -var layout_1 = require("./engine/layout"); -Object.defineProperty(exports, "buildVaultLayout", { enumerable: true, get: function () { return layout_1.buildVaultLayout; } }); -var sanitize_1 = require("./engine/sanitize"); -Object.defineProperty(exports, "sanitizeTitle", { enumerable: true, get: function () { return sanitize_1.sanitizeTitle; } }); -Object.defineProperty(exports, "disambiguate", { enumerable: true, get: function () { return sanitize_1.disambiguate; } }); -var stabilize_1 = require("./engine/stabilize"); -Object.defineProperty(exports, "stabilizePageFile", { enumerable: true, get: function () { return stabilize_1.stabilizePageFile; } }); -var loop_guard_1 = require("./engine/loop-guard"); -Object.defineProperty(exports, "bodyHash", { enumerable: true, get: function () { return loop_guard_1.bodyHash; } }); -var git_1 = require("./engine/git"); -Object.defineProperty(exports, "VaultGit", { enumerable: true, get: function () { return git_1.VaultGit; } }); -Object.defineProperty(exports, "vaultGitEnv", { enumerable: true, get: function () { return git_1.vaultGitEnv; } }); -Object.defineProperty(exports, "buildCommitMessage", { enumerable: true, get: function () { return git_1.buildCommitMessage; } }); -Object.defineProperty(exports, "BOT_AUTHOR_NAME", { enumerable: true, get: function () { return git_1.BOT_AUTHOR_NAME; } }); -Object.defineProperty(exports, "BOT_AUTHOR_EMAIL", { enumerable: true, get: function () { return git_1.BOT_AUTHOR_EMAIL; } }); -Object.defineProperty(exports, "DEFAULT_BRANCH", { enumerable: true, get: function () { return git_1.DEFAULT_BRANCH; } }); -var pull_1 = require("./engine/pull"); -Object.defineProperty(exports, "readExisting", { enumerable: true, get: function () { return pull_1.readExisting; } }); -Object.defineProperty(exports, "computePullActions", { enumerable: true, get: function () { return pull_1.computePullActions; } }); -Object.defineProperty(exports, "applyPullActions", { enumerable: true, get: function () { return pull_1.applyPullActions; } }); -var push_1 = require("./engine/push"); -Object.defineProperty(exports, "classifyRenameMoves", { enumerable: true, get: function () { return push_1.classifyRenameMoves; } }); -Object.defineProperty(exports, "computePushActions", { enumerable: true, get: function () { return push_1.computePushActions; } }); -Object.defineProperty(exports, "applyPushActions", { enumerable: true, get: function () { return push_1.applyPushActions; } }); -Object.defineProperty(exports, "runPush", { enumerable: true, get: function () { return push_1.runPush; } }); -Object.defineProperty(exports, "parentFolderFile", { enumerable: true, get: function () { return push_1.parentFolderFile; } }); -Object.defineProperty(exports, "parseArgs", { enumerable: true, get: function () { return push_1.parseArgs; } }); -Object.defineProperty(exports, "LAST_PUSHED_REF", { enumerable: true, get: function () { return push_1.LAST_PUSHED_REF; } }); -Object.defineProperty(exports, "DOCMOST_BRANCH", { enumerable: true, get: function () { return push_1.DOCMOST_BRANCH; } }); -Object.defineProperty(exports, "LOCAL_AUTHOR_NAME", { enumerable: true, get: function () { return push_1.LOCAL_AUTHOR_NAME; } }); -Object.defineProperty(exports, "LOCAL_AUTHOR_EMAIL", { enumerable: true, get: function () { return push_1.LOCAL_AUTHOR_EMAIL; } }); -Object.defineProperty(exports, "LOCAL_SOURCE_TRAILER", { enumerable: true, get: function () { return push_1.LOCAL_SOURCE_TRAILER; } }); -var settings_1 = require("./engine/settings"); -Object.defineProperty(exports, "parseSettings", { enumerable: true, get: function () { return settings_1.parseSettings; } }); -Object.defineProperty(exports, "envSchema", { enumerable: true, get: function () { return settings_1.envSchema; } }); -var config_errors_1 = require("./engine/config-errors"); -Object.defineProperty(exports, "loadSettingsOrExit", { enumerable: true, get: function () { return config_errors_1.loadSettingsOrExit; } }); diff --git a/packages/git-sync/build/lib/canonicalize.d.ts b/packages/git-sync/build/lib/canonicalize.d.ts deleted file mode 100644 index b899a7a3..00000000 --- a/packages/git-sync/build/lib/canonicalize.d.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * docmost-sync ADDITION (not present in docmost-mcp). - * - * Semantic canonicalization of ProseMirror/TipTap documents for the Phase-0 - * round-trip idempotency check (SPEC §11, "Задача №0", option (б): compare a - * CANONICALIZED form rather than raw bytes). - * - * `markdownToProseMirror` reconstructs schema DEFAULT attributes (e.g. - * `indent: null` where the source omitted it) and regenerates per-block ids on - * every import. A raw deep-equal of the source doc against the re-imported doc - * therefore diverges even when the two are semantically identical. This module - * normalizes a document so that two semantically-equal docs compare deep-equal - * regardless of block ids and absent-vs-explicit-default-null attributes. - * - * This file is intentionally a NEW, self-contained module so it is trivial to - * backport into docmost-mcp without touching existing code. - */ -/** - * Return a DEEP COPY of a ProseMirror node tree, canonicalized so that two - * semantically-equal documents compare deep-equal. Rules (applied recursively - * to the node, its `content`, and its `marks`): - * - * 1. Remove node-level `attrs.id` (regenerated on import). Mark attrs are NOT - * touched for `id` (marks carry no block id; only their meaningful attrs). - * 2. In any `attrs` object (node OR mark) drop keys whose value is `null`/ - * `undefined` (absent ≡ explicit default null) OR equals that node/mark - * type's known non-null schema default (absent ≡ explicit default). - * Keep every non-default value. The type is passed into the attrs - * normalizer so it can look up `KNOWN_DEFAULTS`. - * 3. If an `attrs` object becomes empty after pruning, drop the `attrs` key. - * 4. Preserve `marks` (including the `comment` mark and its `commentId` — a - * meaningful anchor per SPEC §3; never strip it). - * 5. Preserve `text`, `type`, and `content` order exactly. - * 6. Never mutate the input. - */ -export declare function canonicalizeContent(node: any): any; -/** - * True when two ProseMirror documents are semantically equal: equal after - * canonicalization (block ids stripped, absent-vs-default-null normalized). - */ -export declare function docsCanonicallyEqual(a: any, b: any): boolean; diff --git a/packages/git-sync/build/lib/canonicalize.js b/packages/git-sync/build/lib/canonicalize.js deleted file mode 100644 index fbb2c315..00000000 --- a/packages/git-sync/build/lib/canonicalize.js +++ /dev/null @@ -1,252 +0,0 @@ -"use strict"; -/** - * docmost-sync ADDITION (not present in docmost-mcp). - * - * Semantic canonicalization of ProseMirror/TipTap documents for the Phase-0 - * round-trip idempotency check (SPEC §11, "Задача №0", option (б): compare a - * CANONICALIZED form rather than raw bytes). - * - * `markdownToProseMirror` reconstructs schema DEFAULT attributes (e.g. - * `indent: null` where the source omitted it) and regenerates per-block ids on - * every import. A raw deep-equal of the source doc against the re-imported doc - * therefore diverges even when the two are semantically identical. This module - * normalizes a document so that two semantically-equal docs compare deep-equal - * regardless of block ids and absent-vs-explicit-default-null attributes. - * - * This file is intentionally a NEW, self-contained module so it is trivial to - * backport into docmost-mcp without touching existing code. - */ -Object.defineProperty(exports, "__esModule", { value: true }); -exports.canonicalizeContent = canonicalizeContent; -exports.docsCanonicallyEqual = docsCanonicallyEqual; -/** - * Known NON-NULL schema defaults that `markdownToProseMirror` materializes on - * import, keyed by node/mark type → { attr: defaultValue }. - * - * Why this exists: `canonicalizeAttrs` already treats an absent attr as - * equivalent to an explicit `null`/`undefined`. But several Docmost schema - * attributes default to a NON-null value, so import fills them in even when the - * source omitted them — making "attr absent" diverge from "attr at its default - * value" under a raw deep-equal. To keep "absent ≡ explicit-default", we ALSO - * drop any attr whose value equals its known schema default. A non-default - * value (e.g. `orderedList.start: 5`) is NOT a default, so it is KEPT. - * - * Every entry below was read from `packages/docmost-client/src/lib/ - * docmost-schema.ts` (the line refs are the exact `default:` declarations) and - * confirmed to be materialized by an export→import→export round-trip: - * - mark `link` target / rel — DocmostAttributes + StarterKit link. - * StarterKit's link extension defaults `target: "_blank"` and - * `rel: "noopener noreferrer nofollow"`; both materialize on import - * (empirically confirmed) even when the source had only `href`. - * - mark `comment` resolved — docmost-schema.ts L213-214 (`default: false`). - * - node `orderedList` start — provided by StarterKit's orderedList - * (`default: 1`); materializes on import (empirically confirmed). - * - node `drawio`/`excalidraw`/`video`/`youtube`/`embed` align — the diagram - * attribute set and the media nodes declare `align: { default: "center" }` - * (docmost-schema.ts L745-750 diagramAttributes; L564 video; L626 youtube; - * L667 embed). The diagram `align` is the one the round-trip materializes - * (docmost-schema.ts L745); the media/embed entries normalize the SAME - * `align` default for consistency. Note: this only normalizes `align` — - * full canonical stability of `embed` is separately limited by the - * converter coercing numeric `width`/`height` to strings, which is outside - * canonicalize's scope. - * - * NOTE: `image` has NO non-null align default — its `align` defaults to `null` - * (docmost-schema.ts L174), so it is already handled by the null-drop rule and - * is intentionally NOT listed here. - */ -const KNOWN_DEFAULTS = { - // mark types - link: { - target: "_blank", - rel: "noopener noreferrer nofollow", - }, - comment: { - resolved: false, - }, - // node types - orderedList: { - start: 1, - }, - drawio: { - align: "center", - }, - excalidraw: { - align: "center", - }, - video: { - align: "center", - }, - youtube: { - align: "center", - }, - embed: { - align: "center", - }, -}; -/** - * Prune an `attrs` object in place on a fresh copy: drop keys whose value is - * `null` or `undefined` (an absent attribute and an explicit default of `null` - * are semantically equivalent here). Optionally also drop a node-level `id` - * (block ids are regenerated on import, SPEC §11). ALSO drop any attr whose - * value equals the node/mark `type`'s known NON-null schema default - * (`KNOWN_DEFAULTS`), so "attr absent" ≡ "attr at its default value" — without - * this, the import-materialized `link.target`/`comment.resolved`/ - * `orderedList.start`/diagram `align` defaults would be a phantom diff. Every - * non-default attribute value is KEPT (level, language, src, href, commentId, - * width, a non-default `start`/`align`, ...). - * - * Returns the pruned attrs object, or `undefined` if nothing meaningful is - * left (so the caller can drop the `attrs` key entirely: `{attrs:{}}` ≡ no - * attrs). - */ -function canonicalizeAttrs(attrs, dropId, type) { - const defaults = type ? KNOWN_DEFAULTS[type] : undefined; - const out = {}; - // Stable key order so a JSON.stringify of the canonical form is comparable - // regardless of the input's key order. - for (const key of Object.keys(attrs).sort()) { - // Block ids are regenerated on import; drop them on NODE attrs only. - if (dropId && key === "id") - continue; - const value = attrs[key]; - // Absent ≡ explicit-default-null/undefined. - if (value === null || value === undefined) - continue; - // Absent ≡ explicit known non-null default (e.g. link.target="_blank"). - // A non-default value (e.g. orderedList.start=5) does NOT match, so it is - // kept. The `comment` mark's `commentId` is never a default, so it always - // survives (SPEC §3); only its `resolved: false` default is normalized away. - if (defaults && key in defaults && value === defaults[key]) - continue; - out[key] = value; - } - return Object.keys(out).length > 0 ? out : undefined; -} -/** - * Return a DEEP COPY of a ProseMirror node tree, canonicalized so that two - * semantically-equal documents compare deep-equal. Rules (applied recursively - * to the node, its `content`, and its `marks`): - * - * 1. Remove node-level `attrs.id` (regenerated on import). Mark attrs are NOT - * touched for `id` (marks carry no block id; only their meaningful attrs). - * 2. In any `attrs` object (node OR mark) drop keys whose value is `null`/ - * `undefined` (absent ≡ explicit default null) OR equals that node/mark - * type's known non-null schema default (absent ≡ explicit default). - * Keep every non-default value. The type is passed into the attrs - * normalizer so it can look up `KNOWN_DEFAULTS`. - * 3. If an `attrs` object becomes empty after pruning, drop the `attrs` key. - * 4. Preserve `marks` (including the `comment` mark and its `commentId` — a - * meaningful anchor per SPEC §3; never strip it). - * 5. Preserve `text`, `type`, and `content` order exactly. - * 6. Never mutate the input. - */ -function canonicalizeContent(node) { - if (Array.isArray(node)) { - return node.map((child) => canonicalizeContent(child)); - } - if (node === null || typeof node !== "object") { - // Primitive leaf (string/number/boolean/null): returned as-is. - return node; - } - // A node is a mark when it has a `type` but never carries block `content` - // and lives inside a `marks` array. We cannot tell from the node alone, so - // we distinguish at the recursion site: node `attrs` drop `id`, mark `attrs` - // do not. This is handled by passing a `dropId` flag down for the `attrs` - // key specifically (nodes) vs the `marks[].attrs` path (marks). - const out = {}; - for (const key of Object.keys(node)) { - if (key === "attrs" && node.attrs && typeof node.attrs === "object") { - // Node-level attrs: drop the block id, null/undefined attrs, and any - // attr at this node type's known non-null schema default. - const canon = canonicalizeAttrs(node.attrs, true, typeof node.type === "string" ? node.type : undefined); - if (canon !== undefined) - out.attrs = canon; - // else: drop the `attrs` key entirely (rule 3). - } - else if (key === "marks" && Array.isArray(node.marks)) { - // Marks: keep them all (incl. comment); canonicalize their attrs but do - // NOT drop `id` (a mark's `id` would be a meaningful attr, not a block - // id). An empty marks array is dropped so `marks:[]` ≡ no marks. - const marks = node.marks.map((mark) => canonicalizeMark(mark)); - if (marks.length > 0) - out.marks = marks; - } - else { - out[key] = canonicalizeContent(node[key]); - } - } - return out; -} -/** - * Canonicalize a single mark: keep `type`, prune its `attrs` (null/undefined - * AND known non-null defaults dropped, empty attrs removed) but NEVER drop a - * mark's attribute as a "block id" — marks have no block id, only meaningful - * attrs (href, commentId, color, level, ...). Meaningful NON-default attrs - * survive (the `comment` mark's `commentId` is never a default, so it always - * survives — SPEC §3); only known defaults like `link.target="_blank"`, - * `link.rel="noopener…"` and `comment.resolved=false` are normalized away. - */ -function canonicalizeMark(mark) { - if (mark === null || typeof mark !== "object") - return mark; - const out = {}; - for (const key of Object.keys(mark)) { - if (key === "attrs" && mark.attrs && typeof mark.attrs === "object") { - const canon = canonicalizeAttrs(mark.attrs, false, typeof mark.type === "string" ? mark.type : undefined); - if (canon !== undefined) - out.attrs = canon; - } - else { - out[key] = canonicalizeContent(mark[key]); - } - } - return out; -} -/** - * Deep structural equality of two values that is key-order-insensitive. - * Used to compare canonical forms. (`canonicalizeContent` already emits - * `attrs` in a stable key order, but the top-level node keys preserve input - * order, so we compare structurally rather than by string.) - */ -function deepEqual(a, b) { - if (a === b) - return true; - if (typeof a !== typeof b) - return false; - if (a === null || b === null) - return a === b; - if (typeof a !== "object") - return false; - const aIsArr = Array.isArray(a); - const bIsArr = Array.isArray(b); - if (aIsArr !== bIsArr) - return false; - if (aIsArr) { - if (a.length !== b.length) - return false; - for (let i = 0; i < a.length; i++) { - if (!deepEqual(a[i], b[i])) - return false; - } - return true; - } - const aKeys = Object.keys(a); - const bKeys = Object.keys(b); - if (aKeys.length !== bKeys.length) - return false; - for (const k of aKeys) { - if (!Object.prototype.hasOwnProperty.call(b, k)) - return false; - if (!deepEqual(a[k], b[k])) - return false; - } - return true; -} -/** - * True when two ProseMirror documents are semantically equal: equal after - * canonicalization (block ids stripped, absent-vs-default-null normalized). - */ -function docsCanonicallyEqual(a, b) { - return deepEqual(canonicalizeContent(a), canonicalizeContent(b)); -} diff --git a/packages/git-sync/build/lib/diff.d.ts b/packages/git-sync/build/lib/diff.d.ts deleted file mode 100644 index 60997f4a..00000000 --- a/packages/git-sync/build/lib/diff.d.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Headless, Docmost-equivalent document diff. - * - * Docmost's history editor computes a change set with the exact pipeline below - * (recreateTransform -> ChangeSet.addSteps -> simplifyChanges) and renders it as - * editor decorations. This module runs the SAME computation but serializes the - * result to text + integrity counts instead of decorations, so a diff can be - * previewed without a browser. - * - * recreateTransform here comes from @fellow/prosemirror-recreate-transform, the - * maintained published fork of the MIT prosemirror-recreate-steps source that - * Docmost vendors in @docmost/editor-ext; it exposes the identical - * recreateTransform(fromDoc, toDoc, { complexSteps, wordDiffs, simplifyDiff }) - * signature. - * - * If recreateTransform / the changeset throws on a pathological document pair, - * we fall back to a coarse block-level text diff so the tool never hard-fails. - */ -/** A single inserted/deleted change with its containing-block context. */ -export interface DiffChange { - op: "insert" | "delete"; - /** Lead (plain) text of the block that contains the change, for context. */ - block: string; - /** The inserted or deleted text. */ - text: string; -} -/** Integrity counts as [old, new] tuples; footnoteMarkers as [oldList, newList]. */ -export interface DiffIntegrity { - images: [number, number]; - links: [number, number]; - tables: [number, number]; - callouts: [number, number]; - footnoteMarkers: [number[], number[]]; -} -export interface DiffResult { - summary: { - inserted: number; - deleted: number; - blocksChanged: number; - }; - integrity: DiffIntegrity; - changes: DiffChange[]; - /** Human-readable unified-ish summary. */ - markdown: string; -} -/** - * Diff two ProseMirror JSON documents the way Docmost's history editor does and - * serialize the result to text + integrity counts. - * - * @param oldDocJson the earlier document - * @param newDocJson the later document - * @param notesHeading heading delimiting body from notes for footnote counting - */ -export declare function diffDocs(oldDocJson: any, newDocJson: any, notesHeading?: string): DiffResult; diff --git a/packages/git-sync/build/lib/diff.js b/packages/git-sync/build/lib/diff.js deleted file mode 100644 index e14f7049..00000000 --- a/packages/git-sync/build/lib/diff.js +++ /dev/null @@ -1,276 +0,0 @@ -"use strict"; -/** - * Headless, Docmost-equivalent document diff. - * - * Docmost's history editor computes a change set with the exact pipeline below - * (recreateTransform -> ChangeSet.addSteps -> simplifyChanges) and renders it as - * editor decorations. This module runs the SAME computation but serializes the - * result to text + integrity counts instead of decorations, so a diff can be - * previewed without a browser. - * - * recreateTransform here comes from @fellow/prosemirror-recreate-transform, the - * maintained published fork of the MIT prosemirror-recreate-steps source that - * Docmost vendors in @docmost/editor-ext; it exposes the identical - * recreateTransform(fromDoc, toDoc, { complexSteps, wordDiffs, simplifyDiff }) - * signature. - * - * If recreateTransform / the changeset throws on a pathological document pair, - * we fall back to a coarse block-level text diff so the tool never hard-fails. - */ -Object.defineProperty(exports, "__esModule", { value: true }); -exports.diffDocs = diffDocs; -const core_1 = require("@tiptap/core"); -const model_1 = require("@tiptap/pm/model"); -const changeset_1 = require("@tiptap/pm/changeset"); -const prosemirror_recreate_transform_1 = require("@fellow/prosemirror-recreate-transform"); -const docmost_schema_1 = require("./docmost-schema"); -/** Build the schema once; it is pure and reused across calls. */ -const schema = (0, core_1.getSchema)(docmost_schema_1.docmostExtensions); -/** Recursively concatenate the plain text of a JSON node. */ -function plainText(node) { - if (!node || typeof node !== "object") - return ""; - let out = ""; - if (typeof node.text === "string") - out += node.text; - if (Array.isArray(node.content)) { - for (const child of node.content) - out += plainText(child); - } - return out; -} -/** Count nodes in a JSON doc that satisfy `pred` (recursive). */ -function countNodes(doc, pred) { - let n = 0; - const visit = (node) => { - if (!node || typeof node !== "object") - return; - if (pred(node)) - n++; - if (Array.isArray(node.content)) - for (const c of node.content) - visit(c); - }; - visit(doc); - return n; -} -/** - * Count UNIQUE links in a JSON doc by their `href`. A single link can be split - * across several adjacent text runs (e.g. a "link+bold" run followed by a "link" - * run); counting link-bearing runs would over-count it. Walking the tree and - * collecting hrefs into a Set keys each distinct link once. Link marks with a - * missing/empty href are bucketed under a single "" key so a malformed link is - * still counted as one. - */ -function countUniqueLinks(doc) { - const hrefs = new Set(); - const visit = (node) => { - if (!node || typeof node !== "object") - return; - if (node.type === "text" && Array.isArray(node.marks)) { - for (const m of node.marks) { - if (m && m.type === "link") { - const href = m.attrs && typeof m.attrs.href === "string" ? m.attrs.href : ""; - hrefs.add(href); - } - } - } - if (Array.isArray(node.content)) - for (const c of node.content) - visit(c); - }; - visit(doc); - return hrefs.size; -} -/** - * Parse the ordered list of integers from `[N]` footnote markers found in the - * BODY only (every top-level block before the first "Примечания..." notes - * heading; if no such heading, the whole doc). Returned in reading order. - */ -function footnoteMarkers(doc, notesHeading) { - const top = Array.isArray(doc?.content) ? doc.content : []; - const notesIdx = top.findIndex((n) => n && - n.type === "heading" && - plainText(n).trim() === notesHeading); - const bodyBlocks = notesIdx >= 0 ? top.slice(0, notesIdx) : top; - const markers = []; - const re = /\[(\d+)\]/g; - for (const block of bodyBlocks) { - const text = plainText(block); - let m; - re.lastIndex = 0; - while ((m = re.exec(text)) !== null) { - markers.push(Number(m[1])); - } - } - return markers; -} -/** Compute the [old,new] integrity tuples for two JSON docs. */ -function computeIntegrity(oldDoc, newDoc, notesHeading) { - const images = [ - countNodes(oldDoc, (n) => n.type === "image"), - countNodes(newDoc, (n) => n.type === "image"), - ]; - const links = [ - countUniqueLinks(oldDoc), - countUniqueLinks(newDoc), - ]; - const tables = [ - countNodes(oldDoc, (n) => n.type === "table"), - countNodes(newDoc, (n) => n.type === "table"), - ]; - const callouts = [ - countNodes(oldDoc, (n) => n.type === "callout"), - countNodes(newDoc, (n) => n.type === "callout"), - ]; - const fns = [ - footnoteMarkers(oldDoc, notesHeading), - footnoteMarkers(newDoc, notesHeading), - ]; - return { images, links, tables, callouts, footnoteMarkers: fns }; -} -/** - * Resolve the lead text of the top-level block in a ProseMirror Node that - * contains the given document position. Returns "" when out of range. - */ -function blockContextAt(node, pos) { - try { - const clamped = Math.max(0, Math.min(pos, node.content.size)); - const $pos = node.resolve(clamped); - // depth 1 is the top-level block in a doc node. - const block = $pos.depth >= 1 ? $pos.node(1) : $pos.node(0); - const text = block.textContent || ""; - return text.length > 80 ? text.slice(0, 77) + "..." : text; - } - catch { - return ""; - } -} -/** Truncate a string for the markdown summary. */ -function truncate(s, n = 120) { - return s.length > n ? s.slice(0, n - 3) + "..." : s; -} -/** - * Coarse fallback: a block-by-block plain-text diff. Used only when the precise - * changeset pipeline throws, so the tool degrades gracefully instead of failing. - */ -function coarseDiff(oldDoc, newDoc) { - const oldBlocks = Array.isArray(oldDoc?.content) ? oldDoc.content : []; - const newBlocks = Array.isArray(newDoc?.content) ? newDoc.content : []; - const oldTexts = oldBlocks.map(plainText); - const newTexts = newBlocks.map(plainText); - const oldSet = new Set(oldTexts); - const newSet = new Set(newTexts); - const changes = []; - for (const t of oldTexts) { - if (!newSet.has(t) && t.trim() !== "") { - changes.push({ op: "delete", block: truncate(t, 80), text: t }); - } - } - for (const t of newTexts) { - if (!oldSet.has(t) && t.trim() !== "") { - changes.push({ op: "insert", block: truncate(t, 80), text: t }); - } - } - return changes; -} -/** Build the human-readable unified-ish markdown summary. */ -function renderMarkdown(result, fellBack) { - const lines = []; - const { summary, integrity, changes } = result; - lines.push(`# Diff: ${summary.inserted} inserted / ${summary.deleted} deleted (${summary.blocksChanged} blocks changed)`); - if (fellBack) { - lines.push(""); - lines.push("> note: precise diff failed; coarse block-level diff shown."); - } - lines.push(""); - lines.push("## Integrity (old -> new)"); - lines.push(`- images: ${integrity.images[0]} -> ${integrity.images[1]}`); - lines.push(`- links: ${integrity.links[0]} -> ${integrity.links[1]}`); - lines.push(`- tables: ${integrity.tables[0]} -> ${integrity.tables[1]}`); - lines.push(`- callouts: ${integrity.callouts[0]} -> ${integrity.callouts[1]}`); - lines.push(`- footnoteMarkers: [${integrity.footnoteMarkers[0].join(", ")}] -> [${integrity.footnoteMarkers[1].join(", ")}]`); - lines.push(""); - lines.push("## Changes"); - if (changes.length === 0) { - lines.push("(no textual changes)"); - } - else { - for (const c of changes) { - const sign = c.op === "insert" ? "+" : "-"; - const ctx = c.block ? ` @ ${truncate(c.block, 60)}` : ""; - lines.push(`${sign} ${truncate(c.text)}${ctx}`); - } - } - return lines.join("\n"); -} -/** - * Diff two ProseMirror JSON documents the way Docmost's history editor does and - * serialize the result to text + integrity counts. - * - * @param oldDocJson the earlier document - * @param newDocJson the later document - * @param notesHeading heading delimiting body from notes for footnote counting - */ -function diffDocs(oldDocJson, newDocJson, notesHeading = "Примечания переводчика") { - const integrity = computeIntegrity(oldDocJson, newDocJson, notesHeading); - let changes = []; - let inserted = 0; - let deleted = 0; - let fellBack = false; - const changedBlocks = new Set(); - try { - const oldNode = model_1.Node.fromJSON(schema, oldDocJson); - const newNode = model_1.Node.fromJSON(schema, newDocJson); - const tr = (0, prosemirror_recreate_transform_1.recreateTransform)(oldNode, newNode, { - complexSteps: false, - wordDiffs: true, - simplifyDiff: true, - }); - const changeSet = changeset_1.ChangeSet.create(oldNode).addSteps(tr.doc, tr.mapping.maps, []); - const simplified = (0, changeset_1.simplifyChanges)(changeSet.changes, newNode); - for (const change of simplified) { - // Deleted text lives in the OLD doc coordinate range [fromA, toA). - if (change.toA > change.fromA) { - const text = oldNode.textBetween(change.fromA, change.toA, "\n", " "); - if (text.length > 0) { - deleted += text.length; - const block = blockContextAt(oldNode, change.fromA); - changes.push({ op: "delete", block, text }); - if (block) - changedBlocks.add("d:" + block); - } - } - // Inserted text lives in the NEW doc coordinate range [fromB, toB). - if (change.toB > change.fromB) { - const text = newNode.textBetween(change.fromB, change.toB, "\n", " "); - if (text.length > 0) { - inserted += text.length; - const block = blockContextAt(newNode, change.fromB); - changes.push({ op: "insert", block, text }); - if (block) - changedBlocks.add("i:" + block); - } - } - } - } - catch { - // Pathological pair: degrade to a coarse block-level diff so we never throw. - fellBack = true; - changes = coarseDiff(oldDocJson, newDocJson); - for (const c of changes) { - if (c.op === "insert") - inserted += c.text.length; - else - deleted += c.text.length; - if (c.block) - changedBlocks.add(c.op[0] + ":" + c.block); - } - } - const partial = { - summary: { inserted, deleted, blocksChanged: changedBlocks.size }, - integrity, - changes, - }; - return { ...partial, markdown: renderMarkdown(partial, fellBack) }; -} diff --git a/packages/git-sync/build/lib/docmost-schema.d.ts b/packages/git-sync/build/lib/docmost-schema.d.ts deleted file mode 100644 index 8684e1bc..00000000 --- a/packages/git-sync/build/lib/docmost-schema.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Node, Extension, Mark } from "@tiptap/core"; -export declare const clampCalloutType: (value: string | null | undefined) => string; -export declare const sanitizeCssColor: (value: string | null | undefined) => string | null; -/** - * Full extension list. Image is block-level (matches Docmost); the - * ProseMirror DOM parser hoists found inside

automatically. - * StarterKit v3 already bundles the link extension, configured here. - */ -export declare const docmostExtensions: (Node | Mark | Extension | Extension | Node | Node | Node | Mark | Mark)[]; diff --git a/packages/git-sync/build/lib/docmost-schema.js b/packages/git-sync/build/lib/docmost-schema.js deleted file mode 100644 index 148ea642..00000000 --- a/packages/git-sync/build/lib/docmost-schema.js +++ /dev/null @@ -1,1007 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.docmostExtensions = exports.sanitizeCssColor = exports.clampCalloutType = void 0; -/** - * Full TipTap extension set matching the real Docmost document schema. - * - * The default StarterKit-only schema silently destroys Docmost-specific - * nodes (callout, table) and drops attributes it does not know about - * (node ids, image sizing, link targets). Every code path that converts - * to or from ProseMirror JSON must use THIS set, otherwise a round-trip - * loses content. - */ -const starter_kit_1 = __importDefault(require("@tiptap/starter-kit")); -const extension_image_1 = __importDefault(require("@tiptap/extension-image")); -const extension_task_list_1 = __importDefault(require("@tiptap/extension-task-list")); -const extension_task_item_1 = __importDefault(require("@tiptap/extension-task-item")); -const extension_highlight_1 = __importDefault(require("@tiptap/extension-highlight")); -const extension_subscript_1 = __importDefault(require("@tiptap/extension-subscript")); -const extension_superscript_1 = __importDefault(require("@tiptap/extension-superscript")); -const core_1 = require("@tiptap/core"); -// Inlined from @tiptap/core's getStyleProperty (added after 3.20.x) so this -// package can stay on the same @tiptap/core version as the editor and avoid a -// duplicate-tiptap version split in the monorepo. Reads a single declaration -// from an element's inline `style` attribute, last-wins, case-insensitive. -function getStyleProperty(element, propertyName) { - const styleAttr = element.getAttribute("style"); - if (!styleAttr) { - return null; - } - const decls = styleAttr.split(";").map((decl) => decl.trim()).filter(Boolean); - const target = propertyName.toLowerCase(); - for (let i = decls.length - 1; i >= 0; i -= 1) { - const decl = decls[i]; - const colonIndex = decl.indexOf(":"); - if (colonIndex === -1) { - continue; - } - const prop = decl.slice(0, colonIndex).trim().toLowerCase(); - if (prop === target) { - return decl.slice(colonIndex + 1).trim(); - } - } - return null; -} -/** Allowed Docmost callout types; anything else falls back to "info". */ -const CALLOUT_TYPES = ["info", "warning", "danger", "success"]; -const clampCalloutType = (value) => value && CALLOUT_TYPES.includes(value.toLowerCase()) - ? value.toLowerCase() - : "info"; -exports.clampCalloutType = clampCalloutType; -/** - * Allowlist guard for CSS color values imported from HTML. - * - * Docmost interpolates stored mark colors straight into an inline style - * attribute (e.g. style="background-color: ${color}" / "color: ${color}"). - * An unsanitized value such as `red; --x: url(...)` or `red">