fix(git-sync): close #119 blockers — dead edit-revert guard, cross-space guard, red suite (F5/S2/G1/A1/F7)
F5 (HIGH data-loss): guard #2 (GS-EDIT-REVERT) called a local key-sorting equality that never matched a real page (block ids + materialized defaults differ), so the guard was dead and a web edit on a git-sync space was silently reverted within one poll cycle. Use the package's authoritative docsCanonicallyEqual (strips block id + normalizes KNOWN_DEFAULTS), wired through the git-sync loader like sanitizeTitle; delete the dead local canonicalize/canonicalJsonEqual. S2 (security): importPageMarkdown targeted a page by the vault-file id without a spaceId check (deletePage had one) — a space-A vault file carrying space-B's page id could resurrect/overwrite/clear B's page. Mirror deletePage's guard: skip when the loaded page lives in a different space than ctx.spaceId. G1 (jest green): add sanitizeTitle + docsCanonicallyEqual to the loadGitSync mock; update the converter-gate + package golden expectations to the genuinely-fixed output (paragraph textAlign now round-trips, multi-block table cells emit HTML tables); fix the orchestrator spec's stale mock so the per-space enabled gate (added later) is satisfied. A1: the converter dropped heading textAlign on export (bare '## text'); emit a styled <hN> when aligned, symmetric to paragraphs — round-trips losslessly (level + align), no churn for unaligned headings. F7 (docs): reword the false 'single choke point' title-strip comment; correct push.ts docstrings that still described the removed standalone-CLI/daemon model. Adds regression tests: the F5 acceptance test (canonically-equal content with real uuids => writePageBody NOT called), the S2 cross-space import guard, and the A1 heading round-trip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1373,11 +1373,12 @@ function extractUpdatedAt(result: unknown): { updatedAt?: string } {
|
||||
|
||||
// --- runnable push orchestration (`runPush`) ---------------------------------
|
||||
//
|
||||
// `runPush` is the FS->Docmost twin of `pull.ts`'s `main`: it wires the VaultGit
|
||||
// `runPush` is the FS->Docmost twin of the pull direction: 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
|
||||
// `applyPushActions` applier into one runnable cycle. It is driven IN-PROCESS by
|
||||
// the NestJS server (there is no standalone CLI). SAFE BY DEFAULT — a cycle
|
||||
// defaults to DRY-RUN (plan only, NO Docmost writes, NO ref advance) unless the
|
||||
// caller passes `opts.dryRun === false`; that apply path is the ONLY one that
|
||||
// builds a client and mutates Docmost.
|
||||
//
|
||||
// Every external effect is injected (`PushDeps`): production wires the live
|
||||
@@ -1388,7 +1389,7 @@ function extractUpdatedAt(result: unknown): { updatedAt?: string } {
|
||||
* 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.
|
||||
* uses that for the working-tree commit; this is the fallback the engine stamps.
|
||||
*/
|
||||
export const LOCAL_AUTHOR_NAME = "Local";
|
||||
export const LOCAL_AUTHOR_EMAIL = "local@local";
|
||||
@@ -1400,7 +1401,7 @@ export const LOCAL_SOURCE_TRAILER = "Docmost-Sync-Source: local";
|
||||
* Injectable deps for `runPush` (mirrors `pull.ts`'s wiring; everything that
|
||||
* touches the outside world is here so tests pass fakes). `makeClient` is a
|
||||
* FACTORY, not a client — a dry-run must build NO client at all (it is never
|
||||
* called), and only `--apply` invokes it.
|
||||
* called), and only the apply path (`opts.dryRun === false`) invokes it.
|
||||
*/
|
||||
export interface PushDeps {
|
||||
settings: Settings;
|
||||
@@ -1420,7 +1421,7 @@ export interface PushDeps {
|
||||
| "fastForwardBranch"
|
||||
| "listTrackedFiles"
|
||||
>;
|
||||
/** Build a real client — called ONLY on `--apply`, never on dry-run. */
|
||||
/** Build a real client — called ONLY on the apply path, never on dry-run. */
|
||||
makeClient: (settings: Settings) => ApplyPushDeps["client"];
|
||||
/** Read a file's full text by its vault-relative (forward-slash) path. */
|
||||
readFile: (path: string) => Promise<string>;
|
||||
@@ -1448,12 +1449,12 @@ export interface PushRunResult {
|
||||
renamesMoves: number;
|
||||
skipped: number;
|
||||
};
|
||||
/** The applier's structured result — ONLY present on the `--apply` path. */
|
||||
/** 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.
|
||||
* mirror (SPEC §5 invariant broken). Logged prominently and surfaced on this
|
||||
* result so the caller (the server orchestrator) escalates it.
|
||||
*/
|
||||
divergentDocmost?: boolean;
|
||||
/** Per-page failures from the applier (empty/absent on a clean run). */
|
||||
@@ -1461,11 +1462,13 @@ export interface PushRunResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one FS->Docmost push cycle (SPEC §6 "FS -> Docmost"), DRY-RUN BY DEFAULT.
|
||||
* Run one FS->Docmost push cycle (SPEC §6 "FS -> Docmost"), DRY-RUN BY DEFAULT
|
||||
* (the apply path runs only when `opts.dryRun === false`).
|
||||
*
|
||||
* 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
|
||||
* an `aborted: "merge-in-progress"` 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).
|
||||
@@ -1478,12 +1481,12 @@ export interface PushRunResult {
|
||||
* 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)`,
|
||||
* 7. Apply path (`opts.dryRun === false`): 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.
|
||||
* WARNING and the `divergentDocmost` result flag. Then log a one-line summary.
|
||||
*/
|
||||
export async function runPush(
|
||||
deps: PushDeps,
|
||||
@@ -1492,8 +1495,8 @@ export async function runPush(
|
||||
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.
|
||||
// 1. Preflight git. Fail fast (the thrown error propagates to the caller) if
|
||||
// the git binary is missing — the vault state store relies on it.
|
||||
await git.assertGitAvailable();
|
||||
await git.ensureRepo();
|
||||
|
||||
@@ -1622,7 +1625,7 @@ export async function runPush(
|
||||
return { mode: "dry-run", base, pushedCommit, planned };
|
||||
}
|
||||
|
||||
// 7. --apply: build the REAL client and execute. This is the ONLY write path.
|
||||
// 7. Apply path: build the REAL client and execute. This is the ONLY write path.
|
||||
const client = deps.makeClient(settings);
|
||||
const applied = await applyPushActions(
|
||||
{
|
||||
|
||||
@@ -154,6 +154,21 @@ export function convertProseMirrorToMarkdown(content: any): string {
|
||||
case "heading":
|
||||
const level = node.attrs?.level || 1;
|
||||
const headingText = nodeContent.map(processNode).join("");
|
||||
const headingAlign = node.attrs?.textAlign;
|
||||
if (headingAlign && headingAlign !== "left") {
|
||||
// Emit alignment as a styled `<hN>` so it round-trips losslessly,
|
||||
// symmetric to the paragraph case above (review F5/A1). The bare
|
||||
// `## text` markdown form carries NO alignment, so an aligned heading
|
||||
// would silently drop textAlign on export. A styled `<hN>` re-parses:
|
||||
// the heading parse rule (tag:"h1".."h6") matches and the textAlign
|
||||
// global-attribute parseHTML (docmost-schema) reads the style back,
|
||||
// preserving BOTH level and textAlign. escapeAttr keeps the align
|
||||
// value injection-safe, exactly like the paragraph arm.
|
||||
return `<h${level} style="text-align:${escapeAttr(headingAlign)}">${headingText}</h${level}>`;
|
||||
}
|
||||
// No alignment (or the default "left"): keep the plain `## text`
|
||||
// markdown form — HTML-ifying an unaligned heading would be needless
|
||||
// churn, exactly as the paragraph case keeps plain text when unaligned.
|
||||
return "#".repeat(level) + " " + headingText;
|
||||
|
||||
case "text":
|
||||
|
||||
Reference in New Issue
Block a user