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