Resolve the code-review findings from comment #1571 on PR #119. Engine (packages/git-sync): - Idempotent CREATE on retry: before createPage, look the page up in the live Docmost tree by (parentPageId, title) and ADOPT it instead of duplicating when a prior cycle created it but failed to persist the pageId back to disk. Only trust a COMPLETE tree for the lookup; fall back to createPage otherwise. Covered by new tests incl. a complete=false regression-lock. - Route applyPullActions diagnostics through an injected logger instead of bare console (thread log from the cycle). - Add a timeout to the git execFile chokepoint (runRaw) so a hung git subprocess cannot wedge a sync cycle. - Translate remaining Russian code comments to English. - Remove dead standalone-CLI code (parseArgs/PushParsedArgs, parseSettings/envSchema, loadSettingsOrExit + config-errors.ts) and the matching index exports/specs; keep the Settings type. - Fix the dangling docs link in package.json. - Add a schema-surface snapshot guard so any drift in the vendored document schema is a loud, must-review CI failure (+ provenance header). Server (apps/server): - Add a configurable watchdog timeout to the spawned git http-backend so a stalled push cannot hold the per-space lock forever (GIT_SYNC_BACKEND_TIMEOUT_MS). - Close the in-process TOCTOU window in SpaceLockService.withSpaceLock by reserving the slot synchronously before acquire. - Add tests: removePage git-sync provenance (both branches), ensureServable force-push-protection git configs, and the phase-B+ datasource methods. Docs / build: - AGENTS.md: list git-sync as the fifth workspace package and note the three schema mirrors; fix the dangling git-sync-plan.md backlog link. - pnpm-lock.yaml: add the missing @docmost/git-sync workspace link so pnpm install --frozen-lockfile (CI default) succeeds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
171 lines
6.1 KiB
TypeScript
171 lines
6.1 KiB
TypeScript
import { VaultGit } from "./git.js";
|
|
import { GitSyncClient } from "./client.types.js";
|
|
import { Settings } from "./settings.js";
|
|
import { readExisting, computePullActions, applyPullActions } from "./pull.js";
|
|
import { runPush } from "./push.js";
|
|
|
|
/**
|
|
* Absolute-path filesystem primitives the cycle needs. Injected (not imported)
|
|
* so the engine stays IO-free and unit-testable. `mkdir` is recursive; `rm` is
|
|
* force (a missing file is a no-op).
|
|
*/
|
|
export interface CycleFs {
|
|
readFile: (absPath: string) => Promise<string>;
|
|
writeFile: (absPath: string, text: string) => Promise<void>;
|
|
mkdir: (absDir: string) => Promise<void>;
|
|
rm: (absPath: string) => Promise<void>;
|
|
}
|
|
|
|
export interface RunCycleDeps {
|
|
spaceId: string;
|
|
/** The Docmost seam (reads for pull, writes for push). */
|
|
client: GitSyncClient;
|
|
/** The per-space git vault (a real working repo). */
|
|
vault: VaultGit;
|
|
/** Engine settings; `vaultPath` roots the relPath -> absolute-path mapping. */
|
|
settings: Settings;
|
|
fs: CycleFs;
|
|
log: (line: string) => void;
|
|
/**
|
|
* Delete-cap hook (the ONLY caller-specific policy). Called with the push
|
|
* dry-run's planned delete count (`Number.POSITIVE_INFINITY` when the dry-run
|
|
* itself failed, so the hook can fail safe) and the live client; returns the
|
|
* client to use for the REAL apply. The default (omitted) applies every op
|
|
* unmodified. gitmost uses it to neutralize deletes when over its cap.
|
|
*
|
|
* When omitted, NO dry-run is performed (one fewer push planning pass).
|
|
*/
|
|
resolveApplyClient?: (
|
|
plannedDeletes: number,
|
|
client: GitSyncClient,
|
|
) => GitSyncClient;
|
|
}
|
|
|
|
export interface RunCycleResult {
|
|
ran: boolean;
|
|
/** Set when the cycle short-circuited without running pull/push. */
|
|
skipped?: "merge-in-progress";
|
|
pull?: { written: number; deleted: number; conflict: boolean };
|
|
push?: { mode: string; failures: number };
|
|
}
|
|
|
|
/**
|
|
* Run ONE full reconcile cycle for a space: PULL (Docmost -> vault) then PUSH
|
|
* (vault -> Docmost), under the engine's required branch choreography. This is
|
|
* the single entry point the app drives — it owns the staging order so it can
|
|
* never drift from the engine it ships with.
|
|
*
|
|
* Staging (the ⭐ data-loss-critical order, SPEC §6/§9):
|
|
* 1. assertGitAvailable + ensureRepo (the git state store must exist).
|
|
* 2. refuse on an unresolved merge (a prior conflicting pull); next checkout
|
|
* would fail otherwise.
|
|
* 3. ensureBranch('docmost','main') + checkout('docmost'). Pull writes MUST
|
|
* land on `docmost`, not `main`: applyPullActions commits on `docmost`,
|
|
* then checks out `main` and merges docmost -> main. Writing Docmost
|
|
* content straight onto `main` would clobber local file edits before push
|
|
* can diff them.
|
|
* 4. PULL: readExisting -> listSpaceTree -> computePullActions -> apply.
|
|
* 5. PUSH: optional dry-run to feed the delete-cap hook, then the real apply.
|
|
*
|
|
* Lock + cap POLICY live in the caller; this owns only the mechanics.
|
|
*/
|
|
export async function runCycle(deps: RunCycleDeps): Promise<RunCycleResult> {
|
|
const { spaceId, client, vault, settings, fs, log, resolveApplyClient } =
|
|
deps;
|
|
const vaultRoot = settings.vaultPath;
|
|
const abs = (relPath: string) => `${vaultRoot}/${relPath}`;
|
|
|
|
// 1. The engine state store is git: make sure the repo + branches exist
|
|
// before any tracked-file listing or diff.
|
|
await vault.assertGitAvailable();
|
|
await vault.ensureRepo();
|
|
|
|
// 2. Refuse to run on top of an unresolved merge (SPEC §9): a prior
|
|
// conflicting pull leaves the vault mid-merge; the next checkout would fail.
|
|
if (await vault.isMergeInProgress()) {
|
|
log(
|
|
`vault has an unresolved merge — resolve it (or 'git merge --abort') ` +
|
|
`and re-run (SPEC §9); skipping cycle.`,
|
|
);
|
|
return { ran: false, skipped: "merge-in-progress" };
|
|
}
|
|
|
|
// 3. Pull writes happen on `docmost`; be on it BEFORE applying (see docstring).
|
|
await vault.ensureBranch("docmost", "main");
|
|
await vault.checkout("docmost");
|
|
|
|
// 4. PULL --------------------------------------------------------------------
|
|
const existing = await readExisting({
|
|
listTracked: () => vault.listTrackedFiles("*.md"),
|
|
readFile: (relPath) => fs.readFile(abs(relPath)),
|
|
});
|
|
|
|
const tree = await client.listSpaceTree(spaceId);
|
|
const pullActions = computePullActions({
|
|
pages: tree.pages,
|
|
treeComplete: tree.complete,
|
|
existing,
|
|
});
|
|
|
|
const pullResult = await applyPullActions(
|
|
{
|
|
client,
|
|
git: vault,
|
|
writeFile: (absPath, text) => fs.writeFile(absPath, text),
|
|
mkdir: (absDir) => fs.mkdir(absDir),
|
|
rm: (absPath) => fs.rm(absPath),
|
|
log,
|
|
},
|
|
pullActions,
|
|
vaultRoot,
|
|
);
|
|
|
|
// 5. PUSH --------------------------------------------------------------------
|
|
const pushDeps = {
|
|
settings,
|
|
git: vault,
|
|
makeClient: () => client,
|
|
readFile: (relPath: string) => fs.readFile(abs(relPath)),
|
|
writeFile: (relPath: string, text: string) => fs.writeFile(abs(relPath), text),
|
|
log,
|
|
};
|
|
|
|
let applyClient = client;
|
|
if (resolveApplyClient) {
|
|
// Plan the push as a DRY-RUN first to read the delete count, then let the
|
|
// caller decide the apply client (e.g. neutralize deletes over a cap). A
|
|
// failed dry-run yields Infinity so the hook can fail safe.
|
|
let plannedDeletes: number;
|
|
try {
|
|
const dry = await runPush(pushDeps, { dryRun: true });
|
|
plannedDeletes = dry.planned?.deletes ?? 0;
|
|
} catch (err) {
|
|
log(
|
|
`push dry-run planning failed (${
|
|
err instanceof Error ? err.message : String(err)
|
|
}); deferring deletion policy to the cap hook (fail-safe).`,
|
|
);
|
|
plannedDeletes = Number.POSITIVE_INFINITY;
|
|
}
|
|
applyClient = resolveApplyClient(plannedDeletes, client);
|
|
}
|
|
|
|
const pushResult = await runPush(
|
|
{ ...pushDeps, makeClient: () => applyClient },
|
|
{ dryRun: false },
|
|
);
|
|
|
|
return {
|
|
ran: true,
|
|
pull: {
|
|
written: pullResult.written,
|
|
deleted: pullResult.deleted,
|
|
conflict: pullResult.merge.conflict,
|
|
},
|
|
push: {
|
|
mode: pushResult.mode,
|
|
failures: pushResult.failures?.length ?? 0,
|
|
},
|
|
};
|
|
}
|