fix(git-sync): address PR #119 review (#1571)

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>
This commit is contained in:
claude_code
2026-06-26 00:06:44 +03:00
committed by claude code agent 227
parent 52959de2f3
commit 28d2560dfd
31 changed files with 767 additions and 462 deletions

View File

@@ -99,17 +99,25 @@ function makeFs(opts?: { failWriteFor?: Set<string> }) {
return { fs, writes, mkdirs, rms };
}
// A single injected `log` spy mirrors the push side: applyPullActions now routes
// EVERY cycle diagnostic through `deps.log` (one channel), so tests inspect this
// spy instead of console.warn/console.error. `deps()` creates a fresh spy per
// call and stashes it on `lastLog` for the current test to assert against.
let lastLog: ReturnType<typeof vi.fn>;
function deps(
client: any,
git: any,
fs: ReturnType<typeof makeFs>,
): ApplyPullActionsDeps {
lastLog = vi.fn();
return {
client,
git,
writeFile: fs.fs.writeFile,
mkdir: fs.fs.mkdir,
rm: fs.fs.rm,
log: lastLog,
};
}
@@ -341,7 +349,7 @@ describe('applyPullActions — deletion suppression (SPEC §8)', () => {
// Subject reflects 0 deleted (no ", N deleted" suffix).
expect(g.committedSubject).toBe('docmost: sync 1 page(s)');
// The suppression warning was emitted.
expect(console.warn).toHaveBeenCalledWith(
expect(lastLog).toHaveBeenCalledWith(
expect.stringMatching(/tree fetch incomplete/),
);
});
@@ -472,10 +480,10 @@ describe('applyPullActions — suppression warning forks (empty-live / mass-dele
expect(fs.rms).toEqual([]);
// The empty-live message names the tracked-file count and "deletions
// suppressed".
expect(console.warn).toHaveBeenCalledWith(
expect(lastLog).toHaveBeenCalledWith(
expect.stringMatching(/live fetch returned 0 pages but 4 file\(s\) are tracked/),
);
expect(console.warn).toHaveBeenCalledWith(
expect(lastLog).toHaveBeenCalledWith(
expect.stringMatching(/deletions suppressed/),
);
});
@@ -502,10 +510,10 @@ describe('applyPullActions — suppression warning forks (empty-live / mass-dele
expect(res.deleted).toBe(0);
expect(fs.rms).toEqual([]);
expect(console.warn).toHaveBeenCalledWith(
expect(lastLog).toHaveBeenCalledWith(
expect.stringMatching(/plan would delete 5 of 6 tracked file\(s\) \(mass-delete guard\)/),
);
expect(console.warn).toHaveBeenCalledWith(
expect(lastLog).toHaveBeenCalledWith(
expect.stringMatching(/deletions suppressed/),
);
});
@@ -513,8 +521,8 @@ describe('applyPullActions — suppression warning forks (empty-live / mass-dele
describe('applyPullActions — removePath fault tolerance (rm REJECTS)', () => {
it('does NOT reject, logs the failure, and does not count the failed removal', async () => {
// pull.ts 354-364: when `deps.rm` throws, removePath logs via console.error
// and returns false; the run continues. Existing delete tests use an rm
// pull.ts removePath catch: when `deps.rm` throws, it logs via the injected
// `log` and returns false; the run continues. Existing delete tests use an rm
// that always succeeds, leaving this catch branch uncovered.
const { client } = makeClient();
const g = makeGit();
@@ -535,9 +543,8 @@ describe('applyPullActions — removePath fault tolerance (rm REJECTS)', () => {
// Resolved (not rejected) — the pull is fault-tolerant.
expect(res.deleted).toBe(0);
// removePath's catch logs "pull: failed to delete Dead.md: ...".
expect(console.error).toHaveBeenCalledWith(
expect(lastLog).toHaveBeenCalledWith(
expect.stringMatching(/failed to .* Dead\.md/),
expect.anything(),
);
// The (would-be) removal never succeeded, so nothing was recorded.
expect(fs.rms).toEqual([]);
@@ -567,12 +574,13 @@ describe('applyPullActions — removePath fault tolerance (rm REJECTS)', () => {
expect(res.deleted).toBe(2);
expect(fs.rms).toEqual(['/vault/Dead1.md', '/vault/Dead3.md']);
expect(g.committedSubject).toBe('docmost: sync 0 page(s), 2 deleted');
// Exactly one rejection was logged (Dead2.md).
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.error).toHaveBeenCalledWith(
expect.stringMatching(/failed to .* Dead2\.md/),
expect.anything(),
);
// Exactly one rejection was logged (Dead2.md). Other diagnostics share the
// `log` channel, so count ONLY the "failed to ..." failure lines.
const failLines = lastLog.mock.calls
.map((c: unknown[]) => String(c[0]))
.filter((m: string) => /failed to /.test(m));
expect(failLines.length).toBe(1);
expect(failLines[0]).toMatch(/failed to .* Dead2\.md/);
// The run still reached commit + checkout + merge.
expect(g.order).toEqual([
'stageAll',
@@ -620,17 +628,16 @@ describe('applyPullActions — move old-path removal rejects vs move-write fails
expect(fs.rms).toEqual(['/vault/Dead.md']); // Old/M.md rm threw, not recorded
expect(g.committedSubject).toBe('docmost: sync 1 page(s), 1 deleted');
// The failure log named the moved old path.
expect(console.error).toHaveBeenCalledWith(
expect(lastLog).toHaveBeenCalledWith(
expect.stringMatching(/failed to .* Old\/M\.md/),
expect.anything(),
);
});
it('a move-write FAILURE keeps the old path: rm is never attempted for it (data-loss guard, 374-383)', async () => {
// Distinct branch from the move-old-path rm rejection above: here the
// new-path WRITE itself fails, so `m` enters failedPageIds and the move
// loop short-circuits at line 376 BEFORE calling rm — emitting a
// console.warn and PRESERVING the old path (the only copy).
// loop short-circuits BEFORE calling rm — emitting a warning via the
// injected `log` and PRESERVING the old path (the only copy).
const { client } = makeClient();
const g = makeGit();
const fs = makeFs({ failWriteFor: new Set(['/vault/New/M.md']) });
@@ -661,7 +668,7 @@ describe('applyPullActions — move old-path removal rejects vs move-write fails
expect(fs.fs.rm).not.toHaveBeenCalledWith('/vault/Old/M.md');
expect(fs.rms).toEqual([]);
// The "keeping old path" warning fired exactly once for `m`.
const warnCalls = (console.warn as unknown as ReturnType<typeof vi.fn>).mock.calls
const warnCalls = lastLog.mock.calls
.map((c: unknown[]) => String(c[0]))
.filter((m: string) => m.includes('move write for m failed'));
expect(warnCalls.length).toBe(1);