refactor(git-sync): internalize the engine — first-class ESM, no vendoring bridge (#119 review)

Closes the architecture item from the #119 review: drop the "vendored from
docmost-sync" framing and the CJS↔ESM `Function('import()')` bridge so the engine
is a normal first-class gitmost package.

Part 1 — vendoring markers removed (prose only, zero behavior change): reworded
"VENDORED into gitmost" / "vendored from docmost-sync" / "Engine LOGIC is
byte-identical" / "it's a port" comments across the engine. Behavior-bearing
strings are untouched: BOT_AUTHOR_NAME/EMAIL and the `Docmost-Sync-Source:`
provenance trailers (changing them would break git authorship + the loop-guard).

Part 2 — the package is now ESM (matching the sibling @docmost/mcp): `type: module`,
tsconfig Node16, `.js` extensions on relative imports, and a static
`import { marked }` replacing the `new Function('return import(...)')` /
`loadMarked` hack — the bridge is GONE from the package. The CommonJS NestJS
server loads the now-ESM engine via a new `git-sync.loader.ts` that mirrors the
existing `docmost-client.loader.ts` mcp loader exactly (Function-indirected
dynamic import + cached promise + retry-on-reject). The 4 server consumers
(orchestrator/datasource/vault-registry/git-http-backend) call `await loadGitSync()`
for value exports; types stay `import type` (erased). The converter-gate spec —
which needs the real converter — loads the package's TS source via a jest
moduleNameMapper + isolatedModules (documented in that spec); the other git-sync
specs mock the loader.

Verified: engine builds pure ESM (no Function/require leftover), vitest 614,
editor-ext build, server + client tsc, full server jest 1397/0. Live stand
smoke-test: server starts clean on the ESM engine (no ERR_REQUIRE_ESM), a real
sync cycle runs through the loader, and the basic e2e suite is 12/12 (clone via
git-http-backend, push, pull, delete, 3-way merge — all through the new loader).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-24 14:23:40 +03:00
parent 3a91e0eca9
commit 5da12e89f9
29 changed files with 270 additions and 205 deletions
@@ -8,9 +8,13 @@ import { spawn } from 'node:child_process';
// fake child lets us drive every stdout/stderr/error/close branch by hand.
jest.mock('node:child_process', () => ({ spawn: jest.fn() }));
// vaultGitEnv just builds the CGI env overlay; stub it to a passthrough so the
// service constructs without the real engine.
jest.mock('@docmost/git-sync', () => ({
vaultGitEnv: (overlay: Record<string, string>) => overlay,
// service runs without the real engine. The service loads it at runtime via the
// `loadGitSync()` bridge (the ESM `@docmost/git-sync` package cannot be
// `require()`d under jest), so we mock that loader rather than the package.
jest.mock('../git-sync.loader', () => ({
loadGitSync: jest.fn(async () => ({
vaultGitEnv: (overlay: Record<string, string>) => overlay,
})),
}));
import {
@@ -81,6 +85,12 @@ function buildService() {
return new GitHttpBackendService(env as any);
}
// `run()` now awaits the async `loadGitSync()` bridge before it spawns the
// child, so the spawn (and its stream-handler wiring) happens one microtask
// after `run()` is called. These tests drive the fake child synchronously, so
// flush the microtask queue first to let `run()` reach the spawn.
const flush = () => new Promise((resolve) => setImmediate(resolve));
describe('GitHttpBackendService.run', () => {
beforeEach(() => {
spawnMock.mockReset();
@@ -96,6 +106,7 @@ describe('GitHttpBackendService.run', () => {
const res = fakeRes();
const p = service.run(baseRequest, fakeReq(), res);
await flush();
// Emit a child 'error' before any stdout -> 500, headers not already sent.
child.emit('error', new Error('ENOENT spawn git'));
await p;
@@ -112,6 +123,7 @@ describe('GitHttpBackendService.run', () => {
const res = fakeRes();
const p = service.run(baseRequest, fakeReq(), res);
await flush();
// stderr diagnostics, then a close with no valid CGI output -> 500.
child.stderr.emit('data', Buffer.from('fatal: boom'));
child.emit('close', 128);
@@ -128,6 +140,7 @@ describe('GitHttpBackendService.run', () => {
const res = fakeRes();
const p = service.run(baseRequest, fakeReq(), res);
await flush();
// A full CGI response: status line + header + blank line + body.
child.stdout.emit(
'data',
@@ -157,6 +170,7 @@ describe('GitHttpBackendService.run', () => {
const warnSpy = jest.spyOn(Logger.prototype, 'warn');
const p = service.run(baseRequest, fakeReq(), res);
await flush();
// The stdout 'error' handler must absorb this — no unhandled throw, no 500.
expect(() => child.stdout.emit('error', new Error('EPIPE'))).not.toThrow();
expect(() => child.stderr.emit('error', new Error('EPIPE'))).not.toThrow();