WIP: feat(git-sync): native two-way Docmost↔git Markdown sync (Phases A–D, live-verified) #119

Draft
Ghost wants to merge 76 commits from feat/git-sync into develop

Фича: нативный двусторонний git-sync (страницы Docmost ↔ git-папка Markdown)

Отчёт по скиллу архитектора. Реализация спеки docs/git-sync-plan.md (Фазы A→D), движок вендорен из docmost-sync (HEAD b03eb35).

1. Задача

Встроить прямо в gitmost двусторонний синк страниц спейса ↔ локальный git-репозиторий Markdown: правки в Docmost доезжают в файлы и обратно, нативно (без REST-к-себе и сервис-юзер-токенов), с провенансом и безопасным single-writer при репликах.

2. Архитектура (что собрано)

Новый пакет @docmost/git-sync — вендоринг чистого конвертера (ProseMirror↔Markdown) + чистого и IO-движка (reconcile/layout/sanitize/stabilize/loop-guard + pull/push/VaultGit/settings) из docmost-sync. Пакет ESM-only; CJS-сервер грузит его целиком через мост динамического import() (git-sync.loader.ts, копирование build/ в образ — см. Dockerfile/AGENTS.md), а не через require(). Движок взаимодействует с Docmost через узкий интерфейс GitSyncClient — единственный нетривиальный шов.

Сервер (apps/server/src/integrations/git-sync):

  • GitmostDataSourceService — нативная реализация GitSyncClient: чтение через PageRepo/SpaceRepo; запись тела через collab openDirectConnection('page.'+id, {actor:'git-sync'}) (зеркалит withYdocConnection 'replace': drop fragment + TiptapTransformer.toYdoc(tiptapExtensions) + Y.applyUpdate); структурные мутации через PageService (create/soft-delete/move с вычислением fractional-позиции/rename/restore).
  • GitSyncOrchestrator — per-space Redis leader-lock (SET NX PX + CAS-Lua release) + in-process мьютекс; цикл PULL→PUSH через движок; динамический poll-интервал из конфига (SchedulerRegistry).
  • PageChangeListener@OnEvent PAGE_* → дебаунс → цикл, с loop-guard (lastUpdatedSource==='git-sync').
  • Провенанс 'git-sync' (PersistenceExtension: приоритет agent>git-sync>user, debounced история; PageService штампует строку; AuthProvenanceData расширен).
  • Конфиг GIT_SYNC_* (EnvironmentService + валидация), GitSyncController (admin trigger/status), GitSyncModule в app.module.
  • git smart-HTTP host (http/git-http*.ts + raw-route в main.ts, вне /api-пайплайна Nest): git clone/fetch/push http://.../git/<spaceId>.git — собственный Basic-auth + FailedLoginLimiter (throttle до bcrypt, 429 без сигнала существования), резолюция воркспейса, CASL (read → fetch / Manage → push), security-гейт 404-не-403 (существование/enabled-статус спейса не палится не-участнику), push исполняется через git http-backend под space-lock с abort по потере лока.
  • Фаза C: per-space тоггл «Enable Git sync» (settings.gitSync.enabled, jsonb-merge не затирает соседей, CASL Manage/Settings) + Switch в форме настроек спейса + i18n.
  • Фаза D: клиентский бейдж «Git sync» в истории страницы; git в runtime-образе Dockerfile.

Гейт §13.1 (блокировал Фазу B): 13 editor-ext документов идемпотентно проходят content → convertProseMirrorToMarkdown → markdownToProseMirror → toYdoc/fromYdoc(tiptapExtensions) → canonicalize (docsCanonicallyEqual). Доказывает схема-совместимость вендорного конв��ртера с реальной схемой editor-ext.

3. Безопасность (ключевые инварианты, проверены ревью)

Строгий per-space opt-in (НЕТ фоллбэка «все спейсы»); Redis-lock + мьютекс (single-writer); delete-cap (dry-run считает удаления, при превышении нейтрализует deletePage — fail-safe); soft-delete (не forceDelete); запись только через collab (не в колонку content); провенанс создаётся только in-process (не из клиентского токена); admin-trigger под JWT+CASL.

4. Найденные баги — статистика тестирования

Циклы живого e2e (autonomous, реальный стенд + Postgres/Redis, нашёл КРИТИЧЕСКИЕ баги):

Цикл Симптом Кем/как найден Фикс
1 при включении засинкало 92-страничный спейс General, feedback-loop, absence-удаления soft-удалили 9 реальных страниц мной — по логу оркестратора + запросам в БД strict opt-in scoping (убран all-spaces fallback) + per-cycle delete-cap; 9 страниц восстановлены
2 UNDEFINED_VALUE, vault-папка названа undefined мной — по имени папки vault @IsUUID на DTO (whitelist-pipe стрипал spaceId)
3 push не доезжал (контент пустой) мной — контент в БД оставался 0 после push оркестратор не делал checkout('docmost') перед pull → Docmost-контент писался в main и затирал правки; добавлен checkout + merge-guard
4 PASS обе стороны (push: файл→Docmost+провенанс; pull: rename Docmost→файл); General нетронут

Цикл комплексного ревью (review-субагент, весь серверный интегрейшн): APPROVE WITH SUGGESTIONS, критичных data-loss/security нет. Найдено 3 WARNING + 3 SUGGESTION → исправлено 5 (1 косметика пропущена):

  • мёртвый knob poll-интервала (хардкод 15s) → динамический SchedulerRegistry из конфига;
  • loop-guard был внутри условной ветки → структурные self-writes ре-триггерили циклы → guard теперь всегда;
  • мёртвая подписка PAGE_CONTENT_UPDATED (BullMQ-job, не event) → убрана;
  • устаревший коммент + parseInt radix/NaN-guard (+6 тестов).

Браузерная проверка (Фаза C): субагент — тоггл присутствует и персистит settings.gitSync.enabled (reload + API), PASS.

5. Тесты

  • @docmost/git-sync: 431 pass + 3 expected-fail (upstream known-limitations).
  • Идемпотентный гейт editor-ext: 14 pass (13 корпус + 1 изолированный known-divergence: markdown-картинка теряет width/height/align — интринсик md).
  • Сервер jest: 723 pass. tsc (server+client) чисто; nest build ок.
  • Живой e2e round-trip обе стороны на изолированном спейсе — подтверждён вручную (субагент-операции).

6. НЕ вошло (осознанно отложено — честно)

  • §13.4 автоматизированный e2e-тест в репозитории (round-trip прогонялся ВЖИВУЮ, но не закоммичен как тест) и §13.3 интеграционные тесты GitmostDataSource против тестовой БД — фундамент (харнесс F5) есть, сами тесты ��� следующий шаг.
  • Conflict-marker gating (§9, Фаза D): движок оставляет маркеры при конфликте merge, но «блокировать push конфликтной страницы» не e2e-проверено.
  • git remote push — в e2e remote не настраивался (локальный vault); шаблон remote есть в конфиге.
  • Робастность layout при коллизии имён (пустые/дублирующиеся заголовки → нестабильные пути → фантомные absence-удаления) — СНИЖЕНА delete-cap'ом (реальные страницы не теряются), но первопричина (детерминизм имён по slugId) — отдельный хардеринг.
  • Инкрементальный pull — pull перезаписывает файлы каждый цикл (на чистом сходящемся спейсе безвредно; loop-guard события покрывает).

🤖 Generated with Claude Code

Closes #194

## Фича: нативный двусторонний git-sync (страницы Docmost ↔ git-папка Markdown) Отчёт по скиллу архитектора. Реализация спеки `docs/git-sync-plan.md` (Фазы A→D), движок вендорен из `docmost-sync` (HEAD `b03eb35`). ### 1. Задача Встроить прямо в gitmost двусторонний синк страниц спейса ↔ локальный git-репозиторий Markdown: правки в Docmost доезжают в файлы и обратно, нативно (без REST-к-себе и сервис-юзер-токенов), с провенансом и безопасным single-writer при репликах. ### 2. Архитектура (что собрано) **Новый пакет `@docmost/git-sync`** — вендоринг чистого конвертера (ProseMirror↔Markdown) + чистого и IO-движка (reconcile/layout/sanitize/stabilize/loop-guard + pull/push/VaultGit/settings) из docmost-sync. Пакет **ESM-only**; CJS-сервер грузит его целиком через мост динамического `import()` (`git-sync.loader.ts`, копирование `build/` в образ — см. Dockerfile/AGENTS.md), а не через `require()`. Движок взаимодействует с Docmost через узкий интерфейс `GitSyncClient` — единственный нетривиальный шов. **Сервер (`apps/server/src/integrations/git-sync`):** - `GitmostDataSourceService` — нативная реализация `GitSyncClient`: чтение через `PageRepo`/`SpaceRepo`; запись тела через collab `openDirectConnection('page.'+id, {actor:'git-sync'})` (зеркалит `withYdocConnection` 'replace': drop fragment + `TiptapTransformer.toYdoc(tiptapExtensions)` + `Y.applyUpdate`); структурные мутации через `PageService` (create/soft-delete/move с вычислением fractional-позиции/rename/restore). - `GitSyncOrchestrator` — per-space Redis leader-lock (`SET NX PX` + CAS-Lua release) + in-process мьютекс; цикл PULL→PUSH через движок; **динамический** poll-интервал из конфига (SchedulerRegistry). - `PageChangeListener` — `@OnEvent` PAGE_* → дебаунс → цикл, с loop-guard (`lastUpdatedSource==='git-sync'`). - Провенанс `'git-sync'` (PersistenceExtension: приоритет agent>git-sync>user, debounced история; PageService штампует строку; AuthProvenanceData расширен). - Конфиг `GIT_SYNC_*` (EnvironmentService + валидация), `GitSyncController` (admin trigger/status), `GitSyncModule` в app.module. - **git smart-HTTP host** (`http/git-http*.ts` + raw-route в `main.ts`, вне `/api`-пайплайна Nest): `git clone/fetch/push http://.../git/<spaceId>.git` — собственный Basic-auth + `FailedLoginLimiter` (throttle до bcrypt, 429 без сигнала существования), резолюция воркспейса, CASL (read → fetch / Manage → push), security-гейт **404-не-403** (существование/enabled-статус спейса не палится не-участнику), push исполняется через `git http-backend` под space-lock с abort по потере лока. - **Фаза C:** per-space тоггл «Enable Git sync» (`settings.gitSync.enabled`, jsonb-merge не затирает соседей, CASL Manage/Settings) + Switch в форме настроек спейса + i18n. - **Фаза D:** клиентский бейдж «Git sync» в истории страницы; `git` в runtime-образе Dockerfile. **Гейт §13.1 (блокировал Фазу B):** 13 editor-ext документов идемпотентно проходят `content → convertProseMirrorToMarkdown → markdownToProseMirror → toYdoc/fromYdoc(tiptapExtensions) → canonicalize` (docsCanonicallyEqual). Доказывает схема-совместимость вендорного конв��ртера с реальной схемой editor-ext. ### 3. Безопасность (ключевые инварианты, проверены ревью) Строгий per-space opt-in (НЕТ фоллбэка «все спейсы»); Redis-lock + мьютекс (single-writer); delete-cap (dry-run считает удаления, при превышении нейтрализует deletePage — fail-safe); soft-delete (не forceDelete); запись только через collab (не в колонку content); провенанс создаётся только in-process (не из клиентского токена); admin-trigger под JWT+CASL. ### 4. Найденные баги — статистика тестирования **Циклы живого e2e** (autonomous, реальный стенд + Postgres/Redis, нашёл КРИТИЧЕСКИЕ баги): | Цикл | Симптом | Кем/как найден | Фикс | |---|---|---|---| | 1 | при включении засинкало 92-страничный спейс General, feedback-loop, absence-удаления soft-удалили 9 реальных страниц | мной — по логу оркестратора + запросам в БД | strict opt-in scoping (убран all-spaces fallback) + per-cycle delete-cap; 9 страниц восстановлены | | 2 | `UNDEFINED_VALUE`, vault-папка названа `undefined` | мной — по имени папки vault | `@IsUUID` на DTO (whitelist-pipe стрипал spaceId) | | 3 | push не доезжал (контент пустой) | мной — контент в БД оставался 0 после push | оркестратор не делал `checkout('docmost')` перед pull → Docmost-контент писался в main и затирал правки; добавлен checkout + merge-guard | | 4 | — | — | PASS обе стороны (push: файл→Docmost+провенанс; pull: rename Docmost→файл); General нетронут | **Цикл комплексного ревью** (review-субагент, весь серверный интегрейшн): **APPROVE WITH SUGGESTIONS**, критичных data-loss/security нет. Найдено 3 WARNING + 3 SUGGESTION → исправлено 5 (1 косметика пропущена): - мёртвый knob poll-интервала (хардкод 15s) → динамический SchedulerRegistry из конфига; - loop-guard был внутри условной ветки → структурные self-writes ре-триггерили циклы → guard теперь всегда; - мёртвая подписка PAGE_CONTENT_UPDATED (BullMQ-job, не event) → убрана; - устаревший коммент + parseInt radix/NaN-guard (+6 тестов). **Браузерная проверка (Фаза C):** субагент — тоггл присутствует и персистит `settings.gitSync.enabled` (reload + API), PASS. ### 5. Тесты - `@docmost/git-sync`: **431 pass** + 3 expected-fail (upstream known-limitations). - Идемпотентный гейт editor-ext: **14 pass** (13 корпус + 1 изолированный known-divergence: markdown-картинка теряет width/height/align — интринсик md). - Сервер jest: **723 pass**. tsc (server+client) чисто; `nest build` ок. - Живой e2e round-trip обе стороны на изолированном спейсе — подтверждён вручную (субагент-операции). ### 6. НЕ вошло (осознанно отложено — честно) - **§13.4 автоматизированный e2e-тест** в репозитории (round-trip прогонялся ВЖИВУЮ, но не закоммичен как тест) и **§13.3 интеграционные тесты** `GitmostDataSource` против тестовой БД — фундамент (харнесс F5) есть, сами тесты ��� следующий шаг. - **Conflict-marker gating** (§9, Фаза D): движок оставляет маркеры при конфликте merge, но «блокировать push конфликтной страницы» не e2e-проверено. - **git remote push** — в e2e remote не настраивался (локальный vault); шаблон remote есть в конфиге. - **Робастность layout при коллизии имён** (пустые/дублирующиеся заголовки → нестабильные пути → фантомные absence-удаления) — СНИЖЕНА delete-cap'ом (реальные страницы не теряются), но первопричина (детерминизм имён по slugId) — отдельный хардеринг. - **Инкрементальный pull** — pull перезаписывает файлы каждый цикл (на чистом сходящемся спейсе безвредно; loop-guard события покрывает). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Closes #194 <!-- state:review reviewed_head: fbaaa84419307bffb4d0e3f3eff94803775efc0e baseline_head: fbaaa84419307bffb4d0e3f3eff94803775efc0e verdict: changes-requested round: 3 max_rounds: 6 open_findings: [F4, F5, F6] reopened: {} -->
Ghost added 10 commits 2026-06-21 16:03:23 +03:00
First step of docs/git-sync-plan.md. New workspace package @docmost/git-sync
vendoring the PURE parts from docmost-sync (HEAD b03eb35):
- lib: markdown-converter, markdown-document, canonicalize, docmost-schema,
  node-ops, diff, and an extracted markdown-to-prosemirror (only the pure
  marked->HTML->generateJSON path from upstream collaboration.ts; no websocket).
- engine (pure, no IO): reconcile, layout, sanitize, stabilize, loop-guard.
Ported the upstream pure-module + round-trip corpus tests (vitest): 314 pass,
3 expected upstream known-limitation fails. tsc clean. No server wiring yet.

docmost-schema inlines getStyleProperty (as packages/mcp does — @tiptap/core
3.20.4 doesn't export it). IO engine (pull/push/git/settings) deferred to later
Phase A/B steps; the editor-ext idempotency gate (plan §13.1) is the next step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make @docmost/git-sync natively consumable by the CommonJS server (and jest):
build to CommonJS (tsconfig module CommonJS, drop type:module, strip .js from
relative imports), and lazy-load the only ESM-only dep (marked) via the dynamic
Function('import()') trick (mirrors docmost-client.loader.ts) with a require()
fallback so vitest's evaluator works too. git-sync tests stay green (314 pass,
3 expected fail).

Add the §13.1 idempotency gate (apps/server .../git-sync-converter-gate.spec.ts):
13 editor-ext docs (paragraphs/headings, marks, links, bullet/ordered/task lists,
blockquote, callouts, code block, hr, table, nested mix) round-trip
content(editor-ext) -> convertProseMirrorToMarkdown -> markdownToProseMirror ->
TiptapTransformer.toYdoc/fromYdoc(tiptapExtensions) -> canonicalize and assert
docsCanonicallyEqual. All green => the vendored converter's docmost-schema is
schema-compatible with editor-ext (no node/mark/attr loss), which the plan §13.1
requires before Phase B. The one intrinsic markdown-image lossiness (width/height
/align can't ride plain ![](src)) is isolated in a KNOWN DIVERGENCE block, not
hidden. Server tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Vendor the IO engine from docmost-sync into packages/git-sync/src/engine:
- git.ts (VaultGit, execFile shell-out — verbatim)
- pull.ts (readExisting, computePullActions, applyPullActions)
- push.ts (classifyRenameMoves, computePushActions, applyPushActions, runPush)
- settings.ts adapted (pure parseSettings + Settings type; no process.env binding
  — the server builds Settings from EnvironmentService later), config-errors.ts.
CLI main()/import.meta entrypoints dropped (server drives in-process).

Client seam: new engine/client.types.ts defines GitSyncClient; pull.ts/push.ts
now use Pick<GitSyncClient, ...> instead of the non-vendored DocmostClient. Engine
logic byte-identical except a zod4-compat fix in config-errors (zod4 dropped the
issue.received==='undefined' signal; match /received undefined/ on the message).

Ported the engine unit tests (compute/apply pull+push actions, classify-rename-
moves, run-push, settings, config-errors) incl. real-git temp-repo tests: 431
pass / 3 expected-fail (was 314/3). REST/CLI-coupled upstream tests skipped
(noted). CJS build clean. No apps/server wiring yet (next step).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Native data plane for git-sync (plan §3, §8.1):
- provenance: widen actor to 'user'|'agent'|'git-sync' (jwt-payload,
  auth-provenance decorator); PersistenceExtension resolves lastUpdatedSource
  with precedence agent > git-sync > user, debounced history (like a human edit,
  not the agent's immediate snapshot).
- GitmostDataSourceService implements @docmost/git-sync's GitSyncClient natively:
  reads via PageRepo/SpaceRepo (listSpaceTree complete:true, getPageJson), writes
  via PageService (create/removePage soft-delete/movePage with computed fractional
  position/update-rename/restore) + the writeBody linchpin through collab
  openDirectConnection('page.'+id, {actor:'git-sync'}) mirroring
  collaboration.handler withYdocConnection 'replace'. bind({workspaceId,userId})
  returns the context-bound client for the orchestrator.
- 10 unit/contract tests (mapping + soft-delete + move-position), tsc clean.

Known gap (closed in A.4b): PageService.create/update/movePage only branch on
actor==='agent'; git-sync provenance is already passed through so the row source
marker propagates once PageService honors 'git-sync'. Module/orchestrator/config
come next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Control plane wiring (plan §5-§11):
- PageService create/update/movePage now honor provenance actor 'git-sync'
  (stamp lastUpdatedSource='git-sync'), closing the A.4a gap.
- EnvironmentService: GIT_SYNC_ENABLED / DATA_DIR / REMOTE_TEMPLATE /
  POLL_INTERVAL_MS / DEBOUNCE_MS / SERVICE_USER_ID (required-if-enabled) /
  SSH_KEY_PATH + validation.
- VaultRegistryService: per-space vault path + cached VaultGit.
- GitSyncOrchestrator: per-space Redis leader-lock (SET NX PX + CAS-Lua release,
  randomUUID instanceId) + in-process mutex; runOnce drives the vendored engine
  PULL (readExisting->computePullActions->applyPullActions) then PUSH (runPush)
  with the bound native GitSyncClient + VaultGit; @Interval poll-safety gated on
  GIT_SYNC_ENABLED; imports plain ScheduleModule (TelemetryModule owns forRoot).
- PageChangeListener: @OnEvent PAGE_* -> per-space debounce -> runOnce, with a
  best-effort lastUpdatedSource==='git-sync' loop-guard.
- GitSyncController: admin POST /api/git-sync/trigger + GET /status (ops/e2e).
- GitSyncModule registered in app.module. Enabled-space enumeration uses
  settings.gitSync.enabled, falling back to all live spaces until Phase C writes
  the flag (master gate = GIT_SYNC_ENABLED).

tsc clean; 713 tests/71 suites pass; dev server hot-reloaded the module (route
live, DI graph boots). Live pull/push round-trip verified next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fixes found by the live pull/push e2e:
- CRITICAL: driveCycle never checked out the 'docmost' branch before
  applyPullActions, so Docmost content was written straight onto 'main',
  clobbering local file edits before push could diff them. Now checkout
  'docmost' before pull (applyPullActions commits there then checks out main +
  merges) — mirrors the engine's pull main(). Round-trip now works both ways.
- add an unresolved-merge guard (SPEC §9): skip the cycle if the vault is
  mid-merge instead of failing on checkout.
- SAFETY: enabledSpaces() is now STRICT opt-in — only spaces with
  settings.gitSync.enabled===true; removed the all-spaces fallback that synced
  every space (incl. a 92-page one) the moment GIT_SYNC_ENABLED flipped.
- SAFETY: per-cycle delete cap (GIT_SYNC_MAX_DELETES_PER_CYCLE, default 5):
  dry-run the push, and if planned deletes exceed the cap, run the apply with
  deletePage neutralized — phantom absence-deletions from a non-convergent vault
  can't soft-delete real pages. Fails safe if the dry-run throws.
- fix manual trigger: TriggerGitSyncDto.spaceId needs @IsUUID or the global
  whitelist ValidationPipe strips it (arrived undefined -> vault 'undefined').

Live-verified on an isolated flagged space: push (vault file edit -> Docmost
content, stamped lastUpdatedSource='git-sync') and pull (Docmost rename -> vault
file + meta) both work; an unrelated 92-page space stayed untouched throughout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
UI opt-in for git-sync, mirroring the existing sharing/comments settings pattern
(no new endpoint, no new mechanism; orchestrator read query untouched):
- UpdateSpaceDto.gitSyncEnabled?: boolean.
- SpaceRepo.updateGitSyncSettings: jsonb-merge into settings.gitSync.<key>
  (COALESCE || jsonb_build_object — never clobbers sibling sharing/comments);
  stored as a real jsonb boolean so the orchestrator's
  settings->'gitSync'->>'enabled' = 'true' matches.
- SpaceService.updateSpace handles the flag (audit diff) via the existing
  CASL-guarded space update path (Manage/Settings).
- client: Switch in edit-space-form (optimistic mutate + revert-on-error,
  readOnly-aware) + space types + 2 i18n keys.
- space.service.spec extended (calls updateGitSyncSettings; no-op when undefined).
tsc clean (server+client); jest src/core/space 4 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- page-history history-item: a lastUpdatedSource==='git-sync' version renders a
  neutral gray 'Git sync' badge (git-merge icon), NOT the agent badge/deep-link
  (it is not an agent edit). +2 i18n keys.
- Dockerfile: install git in the installer (runtime) stage — VaultGit shells out
  to git, so assertGitAvailable() needs the binary at runtime.
Client tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comprehensive-review follow-ups (APPROVE WITH SUGGESTIONS; no critical issues):
- poll interval is now actually configurable: replaced the hardcoded
  @Interval('git-sync-poll', 15000) with a dynamic SchedulerRegistry interval
  registered in onModuleInit from getGitSyncPollIntervalMs() (cleared in
  onModuleDestroy); /status and the real cadence now share one config source.
  Boots logging 'poll interval registered (Nms)'.
- loop-guard now ALWAYS applies: the lastUpdatedSource==='git-sync' skip was
  nested inside the !spaceId/!workspaceId branch, so structural self-writes
  (CREATE/MOVE/RESTORE/SOFT_DELETE, which carry spaceId+workspaceId) bypassed it
  and re-triggered cycles. Fetch the page row once, guard unconditionally, then
  resolve space/workspace.
- remove the dead PAGE_CONTENT_UPDATED subscription (it's a BullMQ job, never an
  EventEmitter event; body edits arrive via PAGE_UPDATED).
- fix the stale datasource comment (PageService DOES stamp 'git-sync' now).
- env getters: parseInt radix 10 + NaN/<=0 fallback for poll/debounce (+ max
  deletes), with 6 new environment.service.spec tests.

tsc clean; jest 723 pass; live cycle re-verified post-refactor (ran, push
applied, unflagged 92-page space untouched).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Owner

Code review — нативный двусторонний Docmost↔git Markdown-синк

Мульти-аспектное ревью (8 направлений: security, stability, conventions, documentation, regressions, test coverage, simplification, architecture). База develop (merge-base e5bc82c7), head 39edf911, объём 168 файлов (+26462/−548). Вендорный движок packages/git-sync/src (≈8500 строк, 431 тест) проверялся в точках сопряжения с интеграцией, не построчно.

Вердикт: Request changes (запросить доработку).
Фича архитектурно крепкая, граница доверия провенанса держится (проверено: actor:'git-sync' минтуется только in-process, клиентский JWT его не подделает — REST-декоратор коэрсит всё кроме 'agent' в 'user', а сервер 'git-sync'-токены не подписывает). Но: (1) самая критичная склейка — delete-cap, CAS Redis-лока, loop-guard листенера — поставляется без единого автотеста (подтверждено листингом каталога; PR прямо признаёт, что §13.3/§13.4 проверялись только вживую); (2) реальный зазор живучести лока (фиксированный TTL 5 мин без продления) → два писателя на один vault в multi-replica, ради которого лок и существует; (3) операторская конфигурация не задокументирована. Подтверждённой потери данных на happy-path нет, поэтому не «Block merge».


Critical

Нет. Зазор лока ниже — серьёзный, но условный warning, а не подтверждённый дефект happy-path.

Warnings

  • [stability] Redis leader-lock: фиксированный TTL 5 мин без продления/watchdoggit-sync.constants.ts:41 (использование services/git-sync.orchestrator.ts:96-124)
    GIT_SYNC_LOCK_TTL_MS = 5*60*1000 захардкожен (в отличие от poll/debounce/max-deletes, у которых есть env-override), продления внутри driveCycle нет. Полный цикл PULL→dry-PUSH→PUSH не ограничен по времени: на первичной синхронизации крупного спейса или большом батче он реально может превысить 5 мин. Тогда лок авто-истекает, следующий poll-tick другой реплики делает acquire() на тот же спейс и запускает конкурентный driveCycle по тому же vault (<DATA_DIR>/git-sync/<spaceId>, общий том — ровно сценарий, ради которого лок и нужен). In-process мьютекс (running) видит только свой процесс. Два писателя на один git-репозиторий → гонки index.lock, частичные коммиты, затёртое состояние веток. CAS-release защищает от удаления чужого лока, но не от самого конкурентного запуска. В single-replica деплое не воспроизводится; в multi-replica + крупный спейс приближается к critical.
    Fix: heartbeat-продление лока (PEXPIRE по CAS-Lua, пока цикл наш, на ~TTL/3) + сделать TTL конфигурируемым.

  • [test coverage] Delete-cap fail-safe не покрыт тестамиservices/git-sync.orchestrator.ts:305-338
    Самая safety-критичная новая логика (именно она не даёт несходящемуся vault массово soft-удалить реальные страницы) не тестируется ничем: ветки над/под порогом, нейтрализующий deletePage враппер и fail-safe при выбросе dry-run — ни одна не прогоняется. Off-by-one в сравнении или провал ветки выброса молча вернёт массовое удаление, и CI этого не поймает.
    Fix: unit-spec для driveCycle с замоканным runPush/vault/client на три ветки.

  • [test coverage] Acquire/release CAS лока + skip-лестница runOnce не покрытыservices/git-sync.orchestrator.ts:96-124
    acquire (SET NX PX), CAS-Lua release и пять ранних возвратов runOnce (disabled→no-service-user→in-process mutex→lock-held) — нетривиальная конкурентная логика без тестов. CAS-release — единственное, что мешает реплике удалить чужой лок после хэндовера по TTL.
    Fix: тест с фейковым Redis: значение лока = instanceId, no-op release при чужом значении, каждая ветка skip (включая release в finally при выбросе driveCycle).

  • [test coverage] page-change листенер (loop-guard + debounce) не покрытlisteners/page-change.listener.ts
    Ранний возврат при lastUpdatedSource === 'git-sync' — единственное, что рвёт эхо-петлю write→event→sync. Извлечение spaceId/workspaceId из разнородных событий и debounce-схлопывание бёрста — всё без тестов. Регрессия, уронившая loop-guard, создаст бесконечный цикл синхронизации, и CI смолчит.
    Fix: spec с fake timers на (a) git-sync-строка не планирует цикл, (b) обычная — планирует, (c) бёрст схлопывается в один runOnce, (d) disabled/missing short-circuit.

  • [documentation] 8 новых GIT_SYNC_* env-переменных отсутствуют в .env.exampleintegrations/environment/environment.validation.ts
    .env.example — де-факто операторская справка по конфигу (там документированы MCP_TOKEN, TRUST_PROXY, SHARE_AI_*). Новые переменные туда не добавлены, включая GIT_SYNC_SERVICE_USER_IDобязательную при GIT_SYNC_ENABLED=true (иначе падение на старте) — и GIT_SYNC_SSH_KEY_PATH/GIT_SYNC_REMOTE_TEMPLATE. Оператор не узнает о них, не читая исходники.
    Fix: блок «Git sync» в .env.example с дефолтами и пометкой про обязательность service-user-id.

Suggestions

  • [stability] driveCycle игнорирует runPush(...).aborted === 'merge-in-progress'orchestrator.ts:340-352. Конфликтный pull оставляет vault в mid-merge, push возвращает aborted без applied/failures, но статус читает только mode/failures → застрявший на конфликте спейс выглядит как успешный push с 0 ошибок. Fix: проверять pushResult.aborted, поднимать skipped:'merge-in-progress'.
  • [stability] pollTick без re-entrancy-guardorchestrator.ts:374-385. setInterval бьёт каждые 15с независимо от завершения прошлого тика; самоограничивается (per-space running быстро скипает), но под медленными циклами копит лишние enabledSpaces()-сканы БД и шум в логах. Fix: булев pollInFlight в try/finally.
  • [test coverage] jsonb-мердж gitSync проверяется только на границе мокаdatabase/repos/space/space.repo.ts:114-134. Инвариант «мердж не затирает соседние settings» живёт в сырой SQL, но space.service.spec.ts мокает репозиторий и проверяет лишь факт вызова. Fix: repo-тест на строке { sharing:{…}, gitSync:{ other:1 } }.
  • [test coverage] Приоритет провенанса agent > git-sync > user не тестируетсяcollaboration/extensions/persistence.extension.ts:134-146. Если git-sync-запись ошибочно пометится 'user', loop-guard примет её за правку человека → эхо-цикл. Fix: вынести резолв в чистый хелпер и покрыть три случая.
  • [test coverage] getGitSyncDataDir и isGitSyncEnabled без тестовenvironment.service.ts. Числовые геттеры покрыты, а композиция data-dir (override vs DATA_DIR, strip хвостового слеша) и .toLowerCase()==='true' — нет; регрессия в strip переместит все vault'ы.
  • [simplification] Мёртвый knob GIT_SYNC_SSH_KEY_PATH / getGitSyncSshKeyPath()environment.service.ts + environment.validation.ts. Ноль потребителей (remote-push пути нет). Удалить до появления фичи, что его использует.
  • [simplification] Плумбинг gitRemote мёртвorchestrator.ts:153-158. Движок на текущем пути settings.gitRemote не читает; шаблон + подстановка {spaceId} выглядят как живая фича, но не работают.
  • [simplification] Плейсхолдер-креды REST в buildSettings (docmostApiUrl/Email/Password) — мёртвая поверхность ради вендорного типа; «настоящие» на вид литералы провоцируют путаницу. Сделать optional/инертными сентинелами.
  • [documentation] Мёртвые дефолт-константы GIT_SYNC_DEBOUNCE_MS_DEFAULT/POLL_INTERVAL_MS_DEFAULTgit-sync.constants.ts:7-8,44,47. Нигде не импортируются; реальные дефолты — литералы в environment.service.ts. Комментарий утверждает, что они «back» поведение — неправда.
  • [documentation] 40 комментариев plan §N повисают — по всему интеграционному шву. Финальный коммит PR удаляет docs/git-sync-plan.md, на который ссылаются orchestrator.ts, gitmost-datasource.service.ts, git-sync.constants.ts и др. Проза корректна, но якорь удалён. Fix: сохранить урезанные «design notes» или заменить ссылки на самодостаточное обоснование.
  • [conventions] Случайно закоммичен ран-кэш vitest packages/git-sync/node_modules/.vite/vitest/<hash>/results.json (+ реальные блобы .bin/*). Закоммиченные build/** и symlink-node_modules сами по себе совпадают с прецедентом packages/mcp, но .vite-кэш — машинно-локальный мусор без аналога в прецеденте. Fix: рекурсивный node_modules/ и packages/*/node_modules/.vite в .gitignore, удалить results.json из индекса.
  • [conventions] handleGitSyncToggle глотает пойманную ошибку без console.errorapps/client/src/features/space/components/edit-space-form.tsx:47-54. Нарушает правило обработки ошибок из AGENTS.md (нотификацию покажет onError мутации, но raw-ошибка не логируется). Fix: console.error("Failed to toggle git sync for space", err).
  • [security] updateGitSyncSettings сплайсит prefKey через sql.rawdatabase/repos/space/space.repo.ts:114-134. Латентный SQL-инъекционный footgun: значение рядом корректно через sql.lit, а ключ — через sql.raw без экранирования. Не эксплуатируется (единственный вызов передаёт хардкод 'enabled') и это клон существующих updateCommentSettings/sharing-методов — то есть следование принятой конвенции, не новый паттерн. Но вызов с ключом из запроса откроет дыру. Fix: захардкодить путь внутри метода или allow-list для prefKey.

Test coverage (итог)

  • Хорошо покрыто: вендорный движок (431 vitest); gitmost-datasource.service.spec.ts содержательно покрывает рискованные пути (writeBody-провенанс, computeMovePosition, soft-delete, importPageMarkdown); числовые env-геттеры (NaN/zero/negative).
  • Без тестов (главный пробел): GitSyncOrchestrator целиком (delete-cap, CAS-лок, suppress-on-dry-run-throw, pollTick/dynamic interval) и PageChangeListener (loop-guard + debounce) — ровно те узлы, что PR признаёт проверенными «только вживую». Плюс не исполняются: jsonb-мердж gitSync (только мок), приоритет провенанса, getGitSyncDataDir/isGitSyncEnabled. §13.3 и §13.4 сознательно отложены.

Regressions

Чисто. Все правки разделяемых файлов (persistence.extension.ts, auth-provenance.decorator.ts, jwt-payload.ts, page.service.ts, space.service.ts/space.repo.ts/update-space.dto.ts, environment.*, app.module.ts, Dockerfile, history-item.tsx) аддитивны и обратносовместимы — 'user'/'agent' пути байт-идентичны базе, новые поля @IsOptional, jsonb-мердж сохраняет соседей, ScheduleModule.forRoot не дублируется.


Architecture & design (forward-looking, не блокирует merge)

Опции сохранены намеренно — это выбор для вас, не готовое решение.

1. Стратегия вендоринга: закоммиченные build/** + node_modules/** vs обычный workspace-пакет. Компилят (42 файла) и node_modules (31 запись) закоммичены; node_modules просочился, т.к. root .gitignore имеет только корневой /node_modules. Dockerfile копирует свежие packages/mcp/build и editor-ext/dist, но не git-sync/build → два источника правды + риск дрейфа build/** от src/**. CJS/ESM реален (module:CommonJS потребляет ESM-first marked@17).

  • A — обычный workspace-пакет (effort: m): удалить закоммиченные build/**+node_modules/**, рекурсивный gitignore, сборка через nx ^build, копировать git-sync/build в Dockerfile как mcp/editor-ext. Pros: единый источник правды, нет дрейфа. Cons: проверить nx build-граф и marked-interop под пайплайновым tsc.
  • B — оставить build/**, добавить CI-проверку tsc на дрейф; gitignore только node_modules/** (effort: s). Pros: «замороженный артефакт» + страховка. Cons: всё ещё два источника правды, асимметрия Dockerfile.
  • C — как есть, починить только gitignore (effort: s). Pros: ноль риска. Cons: дрейф и bloat остаются.
  • Рекомендация: A — совпадает с собственным прецедентом репо (mcp/editor-ext собираются, не коммитятся); node_modules/** стоит убрать в любом случае.

2. Модель single-writer: TTL лока vs длина цикла, без продления (пересекается с warning'ом выше).

  • A — heartbeat-продление лока (effort: s): PEXPIRE по CAS, пока цикл наш, ~TTL/3. Pros: прямо закрывает зазор, краш-холдер истекает по TTL. Cons: гасить таймер на всех путях выхода.
  • B — конфигурируемый TTL + жёсткий тайм-аут цикла (effort: s). Pros: связь TTL↔цикл — явный инвариант. Cons: mid-cycle abort оставляет vault на ветке docmost/mid-merge (ловится guard'ом).
  • C — задокументировать допущение (effort: s). Cons: окно двух писателей достижимо для крупных спейсов.
  • Рекомендация: A + дешёвая часть B (env-TTL).

3. Размещение инварианта delete-safety: «два прохода runPush» в оркестраторе vs в движке. Абсолютный cap — в оркестраторе через dry-run-подсчёт + monkey-patch { ...client, deletePage: noop }; движок владеет другим (дробным MASS_DELETE_FRACTION) guard'ом. Политика расщеплена на два слоя, второй проход опирается на тонкий side-effect (dry-run коммитит рабочее дерево в main).

  • A — перенести абсолютный cap в движок (maxDeletesPerCycle у runPush) (effort: m). Pros: один дом safety, один проход, нет monkey-patch, тестируется в vitest. Cons: шире форк от upstream.
  • B — заменить monkey-patch явным флагом runPush(deps,{ suppressDeletes:true }) (effort: s). Pros: убирает хрупкий спред-клиент, явный контракт. Cons: всё ещё два прохода.
  • C — закрепить связь dry-run→apply тестом движка (effort: s). Cons: monkey-patch и расщепление остаются.
  • Рекомендация: B как прагматичная середина; A — чище в долгую, но шире форк.

4. Связанность с внутренностями collab/persistence (writeBody, context: any). Нативная запись тела вручную реализует операцию 'replace' коллаб-хэндлера и зависит от трёх внутренних контрактов из других модулей, скоупленных только формой и комментарием; context — нетипизированный any. Самый хрупкий стык: правка full-body replace редактора или ключей провенанса в PersistenceExtension молча рассинхронит git-sync.

  • A — типизированный replaceDocumentBody(name, json, { actor, userId }) на CollaborationGateway, общий для редактора и git-sync (effort: m). Pros: одна реализация replace, типизированный провенанс. Cons: трогает collab за пределами git-sync.
  • B — типизировать контекст (CollabWriteContext) без консолидации (effort: s). Pros: дёшево, silent-break → ошибка компиляции. Cons: дублирование replace остаётся.
  • Рекомендация: минимум B сейчас, A — follow-up.

(Пятое предложение — движок уронил main(), и driveCycle вручную сшивает 9-шаговую pull-последовательность — слабейшее: один потребитель, хорошо закомментировано. Имеет смысл только если будете править движок по п.3 — тогда симметричный runPull в движке.)

Снятый ложный риск: «недетерминизм layout при коллизии имён → фантомные удаления» — layout.ts уже делает слоистую детерминированную дизамбигуацию (~slugId в рамках сиблингов, финальный full-path проход, глобально-уникальный pageId крайним средством); delete-cap — defense-in-depth поверх уже детерминированного layout.


🤖 Автоматическое мульти-аспектное ревью (code-review-orchestrator). Все находки сверены с исходниками; ключевые claim'ы (TTL лока, отсутствие spec оркестратора/листенера, sql.raw) перепроверены вручную.

## Code review — нативный двусторонний Docmost↔git Markdown-синк Мульти-аспектное ревью (8 направлений: security, stability, conventions, documentation, regressions, test coverage, simplification, architecture). База `develop` (merge-base `e5bc82c7`), head `39edf911`, объём 168 файлов (+26462/−548). Вендорный движок `packages/git-sync/src` (≈8500 строк, 431 тест) проверялся в точках сопряжения с интеграцией, не построчно. **Вердикт: Request changes (запросить доработку).** Фича архитектурно крепкая, граница доверия провенанса держится (проверено: `actor:'git-sync'` минтуется только in-process, клиентский JWT его не подделает — REST-декоратор коэрсит всё кроме `'agent'` в `'user'`, а сервер `'git-sync'`-токены не подписывает). Но: **(1)** самая критичная склейка — delete-cap, CAS Redis-лока, loop-guard листенера — поставляется **без единого автотеста** (подтверждено листингом каталога; PR прямо признаёт, что §13.3/§13.4 проверялись только вживую); **(2)** реальный зазор живучести лока (фиксированный TTL 5 мин без продления) → два писателя на один vault в multi-replica, ради которого лок и существует; **(3)** операторская конфигурация не задокументирована. Подтверждённой потери данных на happy-path нет, поэтому не «Block merge». --- ### Critical Нет. Зазор лока ниже — серьёзный, но условный warning, а не подтверждённый дефект happy-path. ### Warnings - **[stability] Redis leader-lock: фиксированный TTL 5 мин без продления/watchdog** — `git-sync.constants.ts:41` (использование `services/git-sync.orchestrator.ts:96-124`) `GIT_SYNC_LOCK_TTL_MS = 5*60*1000` захардкожен (в отличие от poll/debounce/max-deletes, у которых есть env-override), продления внутри `driveCycle` нет. Полный цикл PULL→dry-PUSH→PUSH не ограничен по времени: на первичной синхронизации крупного спейса или большом батче он реально может превысить 5 мин. Тогда лок авто-истекает, следующий poll-tick **другой реплики** делает `acquire()` на тот же спейс и запускает конкурентный `driveCycle` по тому же vault (`<DATA_DIR>/git-sync/<spaceId>`, общий том — ровно сценарий, ради которого лок и нужен). In-process мьютекс (`running`) видит только свой процесс. Два писателя на один git-репозиторий → гонки `index.lock`, частичные коммиты, затёртое состояние веток. CAS-release защищает от удаления чужого лока, но не от самого конкурентного запуска. В single-replica деплое не воспроизводится; в multi-replica + крупный спейс приближается к critical. _Fix:_ heartbeat-продление лока (`PEXPIRE` по CAS-Lua, пока цикл наш, на ~TTL/3) + сделать TTL конфигурируемым. - **[test coverage] Delete-cap fail-safe не покрыт тестами** — `services/git-sync.orchestrator.ts:305-338` Самая safety-критичная новая логика (именно она не даёт несходящемуся vault массово soft-удалить реальные страницы) не тестируется ничем: ветки над/под порогом, нейтрализующий `deletePage` враппер и fail-safe при выбросе dry-run — ни одна не прогоняется. Off-by-one в сравнении или провал ветки выброса молча вернёт массовое удаление, и CI этого не поймает. _Fix:_ unit-spec для `driveCycle` с замоканным `runPush`/vault/client на три ветки. - **[test coverage] Acquire/release CAS лока + skip-лестница `runOnce` не покрыты** — `services/git-sync.orchestrator.ts:96-124` `acquire` (SET NX PX), CAS-Lua `release` и пять ранних возвратов `runOnce` (disabled→no-service-user→in-process mutex→lock-held) — нетривиальная конкурентная логика без тестов. CAS-release — единственное, что мешает реплике удалить чужой лок после хэндовера по TTL. _Fix:_ тест с фейковым Redis: значение лока = instanceId, no-op release при чужом значении, каждая ветка skip (включая release в `finally` при выбросе `driveCycle`). - **[test coverage] page-change листенер (loop-guard + debounce) не покрыт** — `listeners/page-change.listener.ts` Ранний возврат при `lastUpdatedSource === 'git-sync'` — единственное, что рвёт эхо-петлю write→event→sync. Извлечение spaceId/workspaceId из разнородных событий и debounce-схлопывание бёрста — всё без тестов. Регрессия, уронившая loop-guard, создаст бесконечный цикл синхронизации, и CI смолчит. _Fix:_ spec с fake timers на (a) git-sync-строка не планирует цикл, (b) обычная — планирует, (c) бёрст схлопывается в один `runOnce`, (d) disabled/missing short-circuit. - **[documentation] 8 новых `GIT_SYNC_*` env-переменных отсутствуют в `.env.example`** — `integrations/environment/environment.validation.ts` `.env.example` — де-факто операторская справка по конфигу (там документированы `MCP_TOKEN`, `TRUST_PROXY`, `SHARE_AI_*`). Новые переменные туда не добавлены, включая `GIT_SYNC_SERVICE_USER_ID` — **обязательную** при `GIT_SYNC_ENABLED=true` (иначе падение на старте) — и `GIT_SYNC_SSH_KEY_PATH`/`GIT_SYNC_REMOTE_TEMPLATE`. Оператор не узнает о них, не читая исходники. _Fix:_ блок «Git sync» в `.env.example` с дефолтами и пометкой про обязательность service-user-id. ### Suggestions - **[stability] `driveCycle` игнорирует `runPush(...).aborted === 'merge-in-progress'`** — `orchestrator.ts:340-352`. Конфликтный pull оставляет vault в mid-merge, push возвращает `aborted` без `applied/failures`, но статус читает только `mode`/`failures` → застрявший на конфликте спейс выглядит как успешный push с 0 ошибок. _Fix:_ проверять `pushResult.aborted`, поднимать `skipped:'merge-in-progress'`. - **[stability] `pollTick` без re-entrancy-guard** — `orchestrator.ts:374-385`. `setInterval` бьёт каждые 15с независимо от завершения прошлого тика; самоограничивается (per-space `running` быстро скипает), но под медленными циклами копит лишние `enabledSpaces()`-сканы БД и шум в логах. _Fix:_ булев `pollInFlight` в try/finally. - **[test coverage] jsonb-мердж `gitSync` проверяется только на границе мока** — `database/repos/space/space.repo.ts:114-134`. Инвариант «мердж не затирает соседние `settings`» живёт в сырой SQL, но `space.service.spec.ts` мокает репозиторий и проверяет лишь факт вызова. _Fix:_ repo-тест на строке `{ sharing:{…}, gitSync:{ other:1 } }`. - **[test coverage] Приоритет провенанса `agent > git-sync > user` не тестируется** — `collaboration/extensions/persistence.extension.ts:134-146`. Если git-sync-запись ошибочно пометится `'user'`, loop-guard примет её за правку человека → эхо-цикл. _Fix:_ вынести резолв в чистый хелпер и покрыть три случая. - **[test coverage] `getGitSyncDataDir` и `isGitSyncEnabled` без тестов** — `environment.service.ts`. Числовые геттеры покрыты, а композиция data-dir (override vs `DATA_DIR`, strip хвостового слеша) и `.toLowerCase()==='true'` — нет; регрессия в strip переместит все vault'ы. - **[simplification] Мёртвый knob `GIT_SYNC_SSH_KEY_PATH` / `getGitSyncSshKeyPath()`** — `environment.service.ts` + `environment.validation.ts`. Ноль потребителей (remote-push пути нет). Удалить до появления фичи, что его использует. - **[simplification] Плумбинг `gitRemote` мёртв** — `orchestrator.ts:153-158`. Движок на текущем пути `settings.gitRemote` не читает; шаблон + подстановка `{spaceId}` выглядят как живая фича, но не работают. - **[simplification] Плейсхолдер-креды REST в `buildSettings`** (`docmostApiUrl/Email/Password`) — мёртвая поверхность ради вендорного типа; «настоящие» на вид литералы провоцируют путаницу. Сделать optional/инертными сентинелами. - **[documentation] Мёртвые дефолт-константы** `GIT_SYNC_DEBOUNCE_MS_DEFAULT`/`POLL_INTERVAL_MS_DEFAULT` — `git-sync.constants.ts:7-8,44,47`. Нигде не импортируются; реальные дефолты — литералы в `environment.service.ts`. Комментарий утверждает, что они «back» поведение — неправда. - **[documentation] 40 комментариев `plan §N` повисают** — по всему интеграционному шву. Финальный коммит PR удаляет `docs/git-sync-plan.md`, на который ссылаются `orchestrator.ts`, `gitmost-datasource.service.ts`, `git-sync.constants.ts` и др. Проза корректна, но якорь удалён. _Fix:_ сохранить урезанные «design notes» или заменить ссылки на самодостаточное обоснование. - **[conventions] Случайно закоммичен ран-кэш vitest** `packages/git-sync/node_modules/.vite/vitest/<hash>/results.json` (+ реальные блобы `.bin/*`). Закоммиченные `build/**` и symlink-`node_modules` сами по себе совпадают с прецедентом `packages/mcp`, но `.vite`-кэш — машинно-локальный мусор без аналога в прецеденте. _Fix:_ рекурсивный `node_modules/` и `packages/*/node_modules/.vite` в `.gitignore`, удалить `results.json` из индекса. - **[conventions] `handleGitSyncToggle` глотает пойманную ошибку без `console.error`** — `apps/client/src/features/space/components/edit-space-form.tsx:47-54`. Нарушает правило обработки ошибок из AGENTS.md (нотификацию покажет `onError` мутации, но raw-ошибка не логируется). _Fix:_ `console.error("Failed to toggle git sync for space", err)`. - **[security] `updateGitSyncSettings` сплайсит `prefKey` через `sql.raw`** — `database/repos/space/space.repo.ts:114-134`. Латентный SQL-инъекционный footgun: значение рядом корректно через `sql.lit`, а ключ — через `sql.raw` без экранирования. **Не эксплуатируется** (единственный вызов передаёт хардкод `'enabled'`) и это клон существующих `updateCommentSettings`/sharing-методов — то есть следование принятой конвенции, не новый паттерн. Но вызов с ключом из запроса откроет дыру. _Fix:_ захардкодить путь внутри метода или allow-list для `prefKey`. ### Test coverage (итог) - **Хорошо покрыто:** вендорный движок (431 vitest); `gitmost-datasource.service.spec.ts` содержательно покрывает рискованные пути (writeBody-провенанс, `computeMovePosition`, soft-delete, importPageMarkdown); числовые env-геттеры (NaN/zero/negative). - **Без тестов (главный пробел):** `GitSyncOrchestrator` целиком (delete-cap, CAS-лок, suppress-on-dry-run-throw, pollTick/dynamic interval) и `PageChangeListener` (loop-guard + debounce) — ровно те узлы, что PR признаёт проверенными «только вживую». Плюс не исполняются: jsonb-мердж `gitSync` (только мок), приоритет провенанса, `getGitSyncDataDir`/`isGitSyncEnabled`. §13.3 и §13.4 сознательно отложены. ### Regressions Чисто. Все правки разделяемых файлов (`persistence.extension.ts`, `auth-provenance.decorator.ts`, `jwt-payload.ts`, `page.service.ts`, `space.service.ts`/`space.repo.ts`/`update-space.dto.ts`, `environment.*`, `app.module.ts`, `Dockerfile`, `history-item.tsx`) аддитивны и обратносовместимы — `'user'`/`'agent'` пути байт-идентичны базе, новые поля `@IsOptional`, jsonb-мердж сохраняет соседей, `ScheduleModule.forRoot` не дублируется. --- ### Architecture & design (forward-looking, не блокирует merge) Опции сохранены намеренно — это выбор для вас, не готовое решение. **1. Стратегия вендоринга: закоммиченные `build/**` + `node_modules/**` vs обычный workspace-пакет.** Компилят (42 файла) и `node_modules` (31 запись) закоммичены; `node_modules` просочился, т.к. root `.gitignore` имеет только корневой `/node_modules`. Dockerfile копирует свежие `packages/mcp/build` и `editor-ext/dist`, но **не** `git-sync/build` → два источника правды + риск дрейфа `build/**` от `src/**`. CJS/ESM реален (`module:CommonJS` потребляет ESM-first `marked@17`). - *A — обычный workspace-пакет* (effort: m): удалить закоммиченные `build/**`+`node_modules/**`, рекурсивный gitignore, сборка через nx `^build`, копировать `git-sync/build` в Dockerfile как mcp/editor-ext. Pros: единый источник правды, нет дрейфа. Cons: проверить nx build-граф и marked-interop под пайплайновым tsc. - *B — оставить `build/**`, добавить CI-проверку `tsc` на дрейф; gitignore только `node_modules/**`* (effort: s). Pros: «замороженный артефакт» + страховка. Cons: всё ещё два источника правды, асимметрия Dockerfile. - *C — как есть, починить только gitignore* (effort: s). Pros: ноль риска. Cons: дрейф и bloat остаются. - **Рекомендация:** A — совпадает с собственным прецедентом репо (mcp/editor-ext собираются, не коммитятся); `node_modules/**` стоит убрать в любом случае. **2. Модель single-writer: TTL лока vs длина цикла, без продления** (пересекается с warning'ом выше). - *A — heartbeat-продление лока* (effort: s): `PEXPIRE` по CAS, пока цикл наш, ~TTL/3. Pros: прямо закрывает зазор, краш-холдер истекает по TTL. Cons: гасить таймер на всех путях выхода. - *B — конфигурируемый TTL + жёсткий тайм-аут цикла* (effort: s). Pros: связь TTL↔цикл — явный инвариант. Cons: mid-cycle abort оставляет vault на ветке `docmost`/mid-merge (ловится guard'ом). - *C — задокументировать допущение* (effort: s). Cons: окно двух писателей достижимо для крупных спейсов. - **Рекомендация:** A + дешёвая часть B (env-TTL). **3. Размещение инварианта delete-safety: «два прохода `runPush`» в оркестраторе vs в движке.** Абсолютный cap — в оркестраторе через dry-run-подсчёт + monkey-patch `{ ...client, deletePage: noop }`; движок владеет *другим* (дробным `MASS_DELETE_FRACTION`) guard'ом. Политика расщеплена на два слоя, второй проход опирается на тонкий side-effect (dry-run коммитит рабочее дерево в `main`). - *A — перенести абсолютный cap в движок* (`maxDeletesPerCycle` у `runPush`) (effort: m). Pros: один дом safety, один проход, нет monkey-patch, тестируется в vitest. Cons: шире форк от upstream. - *B — заменить monkey-patch явным флагом* `runPush(deps,{ suppressDeletes:true })` (effort: s). Pros: убирает хрупкий спред-клиент, явный контракт. Cons: всё ещё два прохода. - *C — закрепить связь dry-run→apply тестом движка* (effort: s). Cons: monkey-patch и расщепление остаются. - **Рекомендация:** B как прагматичная середина; A — чище в долгую, но шире форк. **4. Связанность с внутренностями collab/persistence (`writeBody`, `context: any`).** Нативная запись тела вручную реализует операцию `'replace'` коллаб-хэндлера и зависит от трёх внутренних контрактов из других модулей, скоупленных только формой и комментарием; `context` — нетипизированный `any`. Самый хрупкий стык: правка full-body replace редактора или ключей провенанса в `PersistenceExtension` молча рассинхронит git-sync. - *A — типизированный `replaceDocumentBody(name, json, { actor, userId })` на CollaborationGateway*, общий для редактора и git-sync (effort: m). Pros: одна реализация replace, типизированный провенанс. Cons: трогает collab за пределами git-sync. - *B — типизировать контекст* (`CollabWriteContext`) без консолидации (effort: s). Pros: дёшево, silent-break → ошибка компиляции. Cons: дублирование replace остаётся. - **Рекомендация:** минимум B сейчас, A — follow-up. _(Пятое предложение — движок уронил `main()`, и `driveCycle` вручную сшивает 9-шаговую pull-последовательность — слабейшее: один потребитель, хорошо закомментировано. Имеет смысл только если будете править движок по п.3 — тогда симметричный `runPull` в движке.)_ **Снятый ложный риск:** «недетерминизм layout при коллизии имён → фантомные удаления» — `layout.ts` уже делает слоистую детерминированную дизамбигуацию (`~slugId` в рамках сиблингов, финальный full-path проход, глобально-уникальный `pageId` крайним средством); delete-cap — defense-in-depth поверх уже детерминированного layout. --- <sub>🤖 Автоматическое мульти-аспектное ревью (code-review-orchestrator). Все находки сверены с исходниками; ключевые claim'ы (TTL лока, отсутствие spec оркестратора/листенера, `sql.raw`) перепроверены вручную.</sub>
Owner

Отчёт по тест-стратегии — gitmost PR #119 (feat/git-sync) — 2026-06-21

Область анализа — только изменения PR #119 (нативный двусторонний синк страниц Docmost ↔ git-папка
Markdown), а не весь монорепозиторий. PR: 168 файлов, +26462/−548. Код проанализирован в изолированном
git-worktree на HEAD ветки feat/git-sync (39edf91), база сравнения — develop (d4658d4).
По каждому из 5 модулей запускался отдельный субагент module-testability-analyst; все утверждения
«уже покрыто» перепроверены прогоном тестов и измерением покрытия (см. §7).

1. Исполнительное резюме

  • Проанализировано модулей: 5 (pure-конвертер lib, движок engine, серверная NestJS-интеграция,
    серверные точки-стыковки, клиентские точки-стыковки).
  • Предложено новых тестов (unit / integration / contract / E2E): 41 / 10 / 2 / 0 (всего ≈53).
    Пирамида соблюдена: unit ≈77 %, integration ≈19 %, contract 2 шт., E2E 0.
  • Отклонено как малоценные: ≈18 целей (DI-обвязка, константы, type-only union, class-validator-DTO,
    i18n-JSON, тривиальные env-геттеры, декоратор провенанса с недостижимым входом — см. §2 «НЕ тестировать»).
  • Покрытие сейчас (измерено):
    • пакет @docmost/git-sync82.5 % строк / 81.7 % statements / 68.6 % branch (431 тест зелёный +
      3 ожидаемых it.fails); движок 88–93 %, но lib проседает: docmost-schema.ts 47 %,
      markdown-converter.ts 71 %, markdown-to-prosemirror.ts 77 %, roundtrip-helpers.ts 31 %.
    • серверная интеграция git-sync — покрыт только gitmost-datasource.service.ts; оркестратор,
      листенер, контроллер и vault-registry = 0 % (спеков нет вовсе).
    • клиентские изменения — 0 %.
  • Прогноз после внедрения плана: пакет ~90 %+ строк; серверная интеграция — покрыты решающие ветки
    оркестратора/листенера/контроллера; провенанс-маркировка и валидация конфигурации защищены тестами.
  • ⚠ Блокирующая находка (вне тестов, см. §4): pnpm install --frozen-lockfile (дефолт CI) на PR
    падаетpnpm-lock.yaml не содержит зависимость @docmost/git-sync, хотя apps/server/package.json
    её объявляет. Без фикса лок-файла CI не соберётся и тесты не запустятся.

2. Рекомендации по модулям

Модуль 1 — packages/git-sync/src/lib (pure-конвертер ProseMirror↔Markdown)

Нижний слой пирамиды, без IO. Покрыт богато (canonicalize/diff/node-ops/markdown-document ≈95–100 %),
но измерение подтвердило бреши в конвертере и импорт-пути.

  • Unit-тесты добавить (8):
    • markdown-converter.ts:convertProseMirrorToMarkdownpageBreak не имеет case → узел, легально
      существующий в схеме (docmost-schema.ts L1009), молча исчезает при экспорте (потеря данных). Зафиксировать
      как round-trip-репро (при политике «документируем» — it.fails).
    • markdown-converter.ts:case "subpages" — эмитится литерал {{SUBPAGES}}, на импорте превращается в
      параграф текста (lossy round-trip); текущий golden-тест проверяет только строку эмиссии.
    • markdown-to-prosemirror.ts:preprocessCallouts — вложенные callout'ы и ::: внутри fenced-code
      (две ветки, не покрытые ни corpus, ни property-генератором).
    • markdown-to-prosemirror.ts:bridgeTaskLists — нумерованный чеклист <ol>→taskList (фантомный пустой
      orderedList) и негатив: смешанный список НЕ конвертится.
    • markdown-converter.ts:case "column"/column.width parseHTML — дробная ширина: дрейф number↔string на
      round-trip; case "details" с пустым detailsContent (схема разрешает block*).
  • НЕ тестировать: index.ts (barrel); определения TipTap-расширений в docmost-schema.ts
    (framework wiring — покрыто через round-trip converter-gate); sanitizeCssColor/clampCalloutType
    (косвенно покрыты); canonicalize/diff/node-ops/markdown-document (уже покрыты).

Модуль 2 — packages/git-sync/src/engine (reconcile/layout/pull/push/git)

Чистое ядро + IO-оболочка вокруг git CLI. Покрытие очень высокое; предлагаются только остаточные бреши
в высокорисковых классах дефектов.

  • Кандидаты в чистые функции / уже чистые, но без прямого теста:
    push.ts:parentFolderFile (линчпин классификации move↔rename, тестируется лишь косвенно);
    roundtrip-helpers.ts:firstDivergenceэкспортируется, но не используется (строки 45–76, 0 % покрытия):
    либо покрыть, либо удалить как мёртвый экспорт (открытый вопрос §7).
  • Unit-тесты добавить (6): parentFolderFile (корень/глубина/точки в имени); planReconciliation
    при коллизии цели одного move со старым путём другого (chained/swap-move, защита от потери данных);
    buildVaultLayout ветка last-resort по id (L135–139); firstDivergence (равенство/неравенство по путям);
    applyPushActions изоляция сбоя prefetch move (try/catch L644–672); applyPullActions с несколькими
    removeOldPath где один write падает (корректный ключ failedPageIds по pageId).
  • Integration-тесты добавить (3, реальный temp-git): VaultGit.diffNameStatus с rename, перемешанным
    с add/modify в одном диффе (выравнивание -z-токенов — иначе сдвиг путей и misclassify move→delete);
    парсинг C (copy)-строк (зависит от R1); VaultGit.commit — committer-identity не утекает в repo/global
    (GIT_COMMITTER_*, основа провенанса/loop-guard).
  • Contract-тест добавить (1): type-level guard формы GitSyncClient (createPage → {data:{id}},
    importPageMarkdown → {updatedAt?}) — ловит дрейф API серверного адаптера, из-за которого
    assignedPageId стал бы undefined и создания зациклились.
  • НЕ тестировать: index.ts (barrel), client.types.ts (типы), loop-guard.bodyHash/vaultGitEnv/
    buildCommitMessage/settings/stabilize (уже покрыты), внутренности recreateTransform (third-party).

Модуль 3 — apps/server/src/integrations/git-sync (NestJS-обвязка) — НАИВЫСШИЙ приоритет

Связывает движок с сервером: событие → PageChangeListener (debounce) → GitSyncOrchestrator.runOnce
(Redis-лидер-лок + in-proc mutex) → PULL/PUSH через GitmostDataSourceService + VaultRegistryService.
Покрыт только datasource; оркестратор/листенер/контроллер/registry — 0 % (подтверждено: §7).

  • Unit-тесты добавить (≈20):
    • Оркестратор runOnce/driveCycle: short-circuit при выключенном флаге; нет service-user; in-proc
      mutex (повторный вызов → in-progress); Redis-лок не взят → lock-held и mutex очищен; снятие лока +
      очистка mutex при throw в driveCycle (защита от «отравленного» спейса); guard merge-in-progress;
      порядок ensureRepo→ensureBranch('docmost')→checkout→applyPullActions (write-back не должен затирать
      локальные правки на main); delete-cap: превышение лимита → реальный runPush({apply}) с
      no-op deletePage (маркерный anti-data-loss тест); fail-safe при throw в dry-run; pass-through под
      лимитом; подстановка {spaceId} в remote-шаблон; идемпотентный onModuleInit/Destroy (поллинг-таймер).
    • Листенер handlePageEvent: gate по флагу; loop-guard lastUpdatedSource==='git-sync' → не
      планировать (ключевой тест против бесконечного эха); отсутствующая страница; приоритет резолва
      spaceId/pageId по вариантам формы события; debounce-коалесинг (всплеск → один runOnce); глотание
      ошибок (assert логирование, не тавтология).
    • VaultRegistry: нормализация trailing-slash в vaultPath; ленивый кэш (один VaultGit на спейс).
    • Контроллер: trigger блокирует не-админа (ForbiddenException, runOnce не вызван) — против
      неавторизованного ручного синка; admin использует workspace из контекста, не из тела; status под админом.
  • Integration-тест добавить (1): writeBody (datasource) на реальном Yjs-доке + реальных
    tiptapExtensions — ветка fragment.length>0 (очистка перед записью), которую unit-спек намеренно не
    исполняет; ловит дублирование тела при write-back. (Связка с editor-ext требует тех же моков, что в
    существующих спеках.)
  • НЕ тестировать: git-sync.module.ts (DI wiring), git-sync.constants.ts (константы),
    GitmostDataSourceService.bind (замыкание-обвязка; ветки в делегатах уже в спеке), TriggerGitSyncDto
    @IsUUID (class-validator), сами функции движка (third-party-вендор), примитивы ioredis.

Модуль 4 — серверные точки-стыковки (page.service, persistence.extension, space, environment)

  • Unit-тесты добавить (5): маркировка провенанса lastUpdatedSource:'git-sync' в page.service.ts
    в трёх ветках — create, update/rename, movePage (datasource-спек доказывает лишь передачу
    аргумента, но не что PageService на него реагирует; ошибка тут = бесконечный цикл синка);
    приоритет провенанса agent > git-sync > user в persistence.extension.ts + негатив: git-sync не
    пишет boundary-снапшот истории; environment.service:getGitSyncDataDir (DATA_DIR-fallback + strip
    trailing-slash + суффикс /git-sync).
  • Integration/contract-тесты добавить (2 + 1): environment.validation через validateSync
    GIT_SYNC_ENABLED='true' без GIT_SYNC_SERVICE_USER_ID → ошибка (главная непокрытая валидация: синк
    включён без атрибутируемого автора), @IsIn отвергает 'maybe'; space.service.updateSpace — audit-дельта
    только при реальном изменении флага, no-op не пишется; расширить corpus converter-gate (contract) —
    добавить узлы editor-ext, отсутствующие в текущем корпусе (mention, math, details, highlight, sub/sup,
    цвет/выравнивание) — гейт сейчас «слеп» к узлу, которого нет в фикстурах.
  • НЕ тестировать: jwt-payload.ts (расширение union — type-only), update-space.dto.ts (class-validator),
    auth-provenance.decorator.ts (резолвит только user/agent; вход git-sync через токен недостижим
    тест был бы тавтологией), app.module.ts (wiring), тривиальные env-геттеры-passthrough.

Модуль 5 — клиентские точки-стыковки (edit-space-form, history-item, space.types)

Реальный diff содержит только тумблер «Enable Git sync» (без полей repo URL/branch, вопреки описанию PR —
открытый вопрос §7) и значок провенанса в истории.

  • Integration-тесты добавить (6, Testing Library): значок git-sync в history-item показывается только
    при lastUpdatedSource==='git-sync' и НЕ при agent/user/undefined; провенанс атрибутируется верному
    автору и значок не кликабелен (в отличие от AiAgentBadge — частая регрессия copy-paste); начальное
    состояние тумблера из space.settings.gitSync.enabled ?? false; оптимистичный апдейт + корректный payload
    {spaceId, gitSyncEnabled}; откат при ошибке мутации (самый ценный — иначе пользователь думает, что
    синк включён); тумблер disabled при readOnly/isPending.
  • Кандидат в чистую функцию: isGitSyncEdit(source) / resolveProvenance(source) из history-item.tsx
    L156–157 — позволит заменить часть рендер-тестов дешёвым табличным unit (опционально).
  • НЕ тестировать: translation.json (i18n-данные), space.types.ts (типы), рендер Mantine
    Switch/Badge/Tooltip/иконок (third-party-презентация), точный текст перевода.

3. Сквозные аспекты

  • Contract-тесты: (а) type-level форма GitSyncClient между движком и серверным адаптером (Модуль 2);
    (б) расширение corpus converter-gate как контракт «схема editor-ext ↔ vendored-схема» (Модуль 4).
  • Property-based: уже применяется (markdown-roundtrip.property.test.ts, fast-check). Расширить
    арбитрари на pageBreak/subpages/вложенные callout'ы — именно туда, где property сейчас не доходит.
  • Test-data factories: для серверной интеграции нужна фабрика fake-VaultGit и fake-GitSyncClient
    (минимальные Pick<>-подмножества), плюс билдер событий page-change по всем вариантам формы.
  • Дымовой нагрузочный тест: не требуется на этом этапе (синк — фоновый, без публичного RPS-контура).

4. Обнаруженные антипаттерны и находки

  • ⚠ Лок-файл рассинхронизирован (CI-блокер): pnpm-lock.yaml не содержит @docmost/git-sync, а
    apps/server/package.json:43 объявляет "@docmost/git-sync":"workspace:*"pnpm install --frozen-lockfile падает (ERR_PNPM_OUTDATED_LOCKFILE). Проверено в чистом worktree. Предусловие для
    любого тест-прогона в CI.
  • Свежесть сборки для converter-gate: спек валидирует собранные @docmost/editor-ext и
    @docmost/git-sync (committed build/), а не исходники. CI обязан билдить оба пакета перед jest, иначе
    гарантия совместимости схемы устаревшая.
  • Глобальная мутация при импорте: markdown-to-prosemirror.ts L66–71 присваивает
    global.window/document как side-effect импорта (риск перекрёстного загрязнения тестов; причина, почему
    юниты импортируют конвертер напрямую, минуя barrel).
  • process.exit в коде под тестом: environment.validation.ts (validate()), engine/config-errors.ts
    (loadSettingsOrExit) — failure-путь невозможно ассертить in-process (см. R: тестировать validateSync).
  • Мутируемые in-proc-синглтоны без сброса: running:Set, debounce:Map, vaults:Map,
    last-pushed ref — каждый тест должен строить свежий инстанс (иначе order-dependent проход).
  • Глотание ошибок (намеренное — ассертить логирование): оркестратор (release/runOnce/pollTick/destroy),
    листенер (handlePageEvent), клиентский catch отката в edit-space-form.tsx:53.
  • sql.raw(prefKey) в space.repo.ts:updateGitSyncSettings — сейчас prefKey = константа 'enabled'
    (безопасно), но паттерн инъекционно-подобный: зафиксировать вызов на allowlist/константе.
  • Нестабильные тесты: не обнаружено (история CI не анализировалась; 3 it.fails в пакете — намеренные
    задокументированные баги round-trip, не флаки).

5. Необходимые рефакторинги перед написанием тестов

Все — опциональные (блокирующих нет; тесты пишутся и на текущем коде), кроме лок-файла из §4.

  • CI-предусловие: обновить pnpm-lock.yaml (pnpm install на ветке) — блокирует ВЕСЬ серверный
    тест-прогон в CI.
  • engine/git.ts: вынести -z-парсер diffNameStatus в чистую parseDiffNameStatusZ(raw) — разблокирует
    детерминированные тесты C-строк и malformed-tail (Модуль 2, integration).
  • engine/roundtrip-helpers.ts: решить судьбу firstDivergence (покрыть или удалить).
  • git-sync.orchestrator.ts: вынести decideSuppressDeletes(planned, max) (чистая) — упростит 3 delete-cap
    теста; опционально инъектировать функции движка/node:fs вместо module-level import.
  • persistence.extension.ts: вынести resolveSource(agentTouched, actor) — чистый unit вместо DB-сетапа.
  • environment.validation.ts: тестировать через validateSync(plainToInstance(...)), минуя process.exit.
  • history-item.tsx: вынести isGitSyncEdit(source) (клиентский unit вместо рендера).

6. План внедрения (по фазам, ROI-приоритет)

  • Фаза 1 (критично, защита от потери данных и циклов): (0) починить лок-файл; (1) листенер loop-guard +
    debounce; (2) оркестратор delete-cap + lock/mutex + порядок checkout/pull; (3) page.service провенанс в
    3 ветках + приоритет в persistence.extension; (4) environment.validation enabled-без-service-user.
  • Фаза 2 (корректность данных конвертера): pageBreak/subpages round-trip; ветки
    preprocessCallouts/bridgeTaskLists; parentFolderFile; planReconciliation swap-move;
    applyPushActions/applyPullActions сбой-изоляция.
  • Фаза 3 (контракты и стыки): type-level GitSyncClient; расширение corpus converter-gate;
    writeBody-integration; контроллер authz; VaultRegistry; клиентские 6 тестов; remote-шаблон.
  • Фаза 4 (полировка): diffNameStatus rename+add/modify и C; committer-identity; getGitSyncDataDir;
    audit-дельта space.service; решение по firstDivergence.

7. Источники и верификация

  • Отчёты 5 субагентов module-testability-analyst (по одному на модуль; прочитаны полностью).
  • Прогон тестов (независимая проверка claim'ов «уже покрыто»):
    • @docmost/git-sync (vitest 4.1.6): 30 файлов, 431 passed + 3 expected-fail — зелёный.
    • сервер (jest 30): спеки gitmost-datasource, git-sync-converter-gate, space.service,
      environment.service5 suites, 38 passed — зелёный.
  • Измерение покрытия (v8 / jest): пакет — 81.7 % stmts / 68.6 % branch / 82.5 % lines; пофайлово:
    docmost-schema.ts 47 %, markdown-converter.ts 71 %, markdown-to-prosemirror.ts 77 %,
    roundtrip-helpers.ts 31 %, движок 88–93 %. Сервер: оркестратор/листенер/контроллер/vault-registry —
    0 ссылок из спеков (подтверждённый 0 %); покрыт только datasource.
  • Фильтрация предложений: шаг 1 (дедуп между модулями) — pageBreak сведён к unit-уровню lib (U1),
    серверный converter-gate оставлен как комплементарный контракт схемы; шаг 2 (skip-list) — отброшено ≈18
    целей (wiring/константы/типы/DTO/i18n/тривиальные геттеры/недостижимый декоратор); шаг 3 (бюджет пирамиды) —
    reclass: engine I1/I2 и серверные unit оставлены в unit, реальные git/Yjs/рендер — в integration
    (unit 77 % ≥ 70 %, integration 19 % ≤ 20 %, E2E 0 ≤ 10); шаг 6 (adversarial) — каждый тест сформулирован
    так, чтобы падать при реалистичной незаметной поломке.

Открытые вопросы

  1. Лок-файл: обновить перед мержем (см. §4) — иначе CI красный.
  2. Pull-оркестрация: в пакете есть тестируемый runPush, но нет runPull — где живёт preflight
    pull-цикла (refuse-on-merge-in-progress перед pull) и протестирован ли он на уровне сервера?
  3. firstDivergence — есть ли продакшен-потребитель, или это мёртвый экспорт?
  4. Клиент: описание PR обещает поля repo URL/branch, в diff их нет — UI недокоммичен или урезан намеренно?
    Если валидатор git-URL появится — это тест №1 по ROI на клиенте.
  5. C (copy) в diffNameStatus — достижим ли в проде (rename-детект только -M)? Если нет, тесты C
    защитные, низкий приоритет.
# Отчёт по тест-стратегии — gitmost PR #119 (feat/git-sync) — 2026-06-21 Область анализа — **только изменения PR #119** (нативный двусторонний синк страниц Docmost ↔ git-папка Markdown), а не весь монорепозиторий. PR: 168 файлов, +26462/−548. Код проанализирован в изолированном git-worktree на HEAD ветки `feat/git-sync` (`39edf91`), база сравнения — `develop` (`d4658d4`). По каждому из 5 модулей запускался отдельный субагент `module-testability-analyst`; все утверждения «уже покрыто» перепроверены прогоном тестов и измерением покрытия (см. §7). ## 1. Исполнительное резюме - **Проанализировано модулей:** 5 (pure-конвертер `lib`, движок `engine`, серверная NestJS-интеграция, серверные точки-стыковки, клиентские точки-стыковки). - **Предложено новых тестов (unit / integration / contract / E2E): 41 / 10 / 2 / 0** (всего ≈53). Пирамида соблюдена: unit ≈77 %, integration ≈19 %, contract 2 шт., E2E 0. - **Отклонено как малоценные:** ≈18 целей (DI-обвязка, константы, type-only union, class-validator-DTO, i18n-JSON, тривиальные env-геттеры, декоратор провенанса с недостижимым входом — см. §2 «НЕ тестировать»). - **Покрытие сейчас (измерено):** - пакет `@docmost/git-sync` — **82.5 % строк / 81.7 % statements / 68.6 % branch** (431 тест зелёный + 3 ожидаемых `it.fails`); движок 88–93 %, но `lib` проседает: `docmost-schema.ts` 47 %, `markdown-converter.ts` 71 %, `markdown-to-prosemirror.ts` 77 %, `roundtrip-helpers.ts` 31 %. - серверная интеграция git-sync — покрыт **только** `gitmost-datasource.service.ts`; оркестратор, листенер, контроллер и vault-registry = **0 %** (спеков нет вовсе). - клиентские изменения — **0 %**. - **Прогноз после внедрения плана:** пакет ~90 %+ строк; серверная интеграция — покрыты решающие ветки оркестратора/листенера/контроллера; провенанс-маркировка и валидация конфигурации защищены тестами. - **⚠ Блокирующая находка (вне тестов, см. §4):** `pnpm install --frozen-lockfile` (дефолт CI) на PR **падает** — `pnpm-lock.yaml` не содержит зависимость `@docmost/git-sync`, хотя `apps/server/package.json` её объявляет. Без фикса лок-файла CI не соберётся и тесты не запустятся. ## 2. Рекомендации по модулям ### Модуль 1 — `packages/git-sync/src/lib` (pure-конвертер ProseMirror↔Markdown) Нижний слой пирамиды, без IO. Покрыт богато (canonicalize/diff/node-ops/markdown-document ≈95–100 %), но измерение подтвердило бреши в конвертере и импорт-пути. - **Unit-тесты добавить (8):** - `markdown-converter.ts:convertProseMirrorToMarkdown` — **`pageBreak` не имеет case** → узел, легально существующий в схеме (`docmost-schema.ts` L1009), молча исчезает при экспорте (потеря данных). Зафиксировать как round-trip-репро (при политике «документируем» — `it.fails`). - `markdown-converter.ts:case "subpages"` — эмитится литерал `{{SUBPAGES}}`, на импорте превращается в параграф текста (lossy round-trip); текущий golden-тест проверяет только строку эмиссии. - `markdown-to-prosemirror.ts:preprocessCallouts` — вложенные callout'ы и `:::` **внутри fenced-code** (две ветки, не покрытые ни corpus, ни property-генератором). - `markdown-to-prosemirror.ts:bridgeTaskLists` — нумерованный чеклист `<ol>`→taskList (фантомный пустой orderedList) и негатив: смешанный список НЕ конвертится. - `markdown-converter.ts:case "column"`/`column.width` parseHTML — дробная ширина: дрейф number↔string на round-trip; `case "details"` с пустым `detailsContent` (схема разрешает `block*`). - **НЕ тестировать:** `index.ts` (barrel); определения TipTap-расширений в `docmost-schema.ts` (framework wiring — покрыто через round-trip converter-gate); `sanitizeCssColor/clampCalloutType` (косвенно покрыты); canonicalize/diff/node-ops/markdown-document (уже покрыты). ### Модуль 2 — `packages/git-sync/src/engine` (reconcile/layout/pull/push/git) Чистое ядро + IO-оболочка вокруг git CLI. Покрытие очень высокое; предлагаются только остаточные бреши в высокорисковых классах дефектов. - **Кандидаты в чистые функции / уже чистые, но без прямого теста:** `push.ts:parentFolderFile` (линчпин классификации move↔rename, тестируется лишь косвенно); `roundtrip-helpers.ts:firstDivergence` — **экспортируется, но не используется** (строки 45–76, 0 % покрытия): либо покрыть, либо удалить как мёртвый экспорт (открытый вопрос §7). - **Unit-тесты добавить (6):** `parentFolderFile` (корень/глубина/точки в имени); `planReconciliation` при коллизии цели одного move со старым путём другого (chained/swap-move, защита от потери данных); `buildVaultLayout` ветка last-resort по id (L135–139); `firstDivergence` (равенство/неравенство по путям); `applyPushActions` изоляция сбоя prefetch move (try/catch L644–672); `applyPullActions` с несколькими `removeOldPath` где один write падает (корректный ключ `failedPageIds` по pageId). - **Integration-тесты добавить (3, реальный temp-git):** `VaultGit.diffNameStatus` с rename, перемешанным с add/modify в одном диффе (выравнивание `-z`-токенов — иначе сдвиг путей и misclassify move→delete); парсинг `C` (copy)-строк (зависит от R1); `VaultGit.commit` — committer-identity не утекает в repo/global (`GIT_COMMITTER_*`, основа провенанса/loop-guard). - **Contract-тест добавить (1):** type-level guard формы `GitSyncClient` (`createPage → {data:{id}}`, `importPageMarkdown → {updatedAt?}`) — ловит дрейф API серверного адаптера, из-за которого `assignedPageId` стал бы undefined и создания зациклились. - **НЕ тестировать:** `index.ts` (barrel), `client.types.ts` (типы), `loop-guard.bodyHash`/`vaultGitEnv`/ `buildCommitMessage`/`settings`/`stabilize` (уже покрыты), внутренности `recreateTransform` (third-party). ### Модуль 3 — `apps/server/src/integrations/git-sync` (NestJS-обвязка) — НАИВЫСШИЙ приоритет Связывает движок с сервером: событие → `PageChangeListener` (debounce) → `GitSyncOrchestrator.runOnce` (Redis-лидер-лок + in-proc mutex) → PULL/PUSH через `GitmostDataSourceService` + `VaultRegistryService`. Покрыт **только** datasource; оркестратор/листенер/контроллер/registry — 0 % (подтверждено: §7). - **Unit-тесты добавить (≈20):** - *Оркестратор* `runOnce`/`driveCycle`: short-circuit при выключенном флаге; нет service-user; in-proc mutex (повторный вызов → `in-progress`); Redis-лок не взят → `lock-held` и mutex очищен; снятие лока + очистка mutex при throw в `driveCycle` (защита от «отравленного» спейса); guard merge-in-progress; **порядок** `ensureRepo→ensureBranch('docmost')→checkout→applyPullActions` (write-back не должен затирать локальные правки на `main`); **delete-cap**: превышение лимита → реальный `runPush({apply})` с no-op `deletePage` (маркерный anti-data-loss тест); fail-safe при throw в dry-run; pass-through под лимитом; подстановка `{spaceId}` в remote-шаблон; идемпотентный `onModuleInit/Destroy` (поллинг-таймер). - *Листенер* `handlePageEvent`: gate по флагу; **loop-guard** `lastUpdatedSource==='git-sync'` → не планировать (ключевой тест против бесконечного эха); отсутствующая страница; приоритет резолва spaceId/pageId по вариантам формы события; **debounce-коалесинг** (всплеск → один `runOnce`); глотание ошибок (assert логирование, не тавтология). - *VaultRegistry*: нормализация trailing-slash в `vaultPath`; ленивый кэш (один `VaultGit` на спейс). - *Контроллер*: `trigger` блокирует не-админа (`ForbiddenException`, `runOnce` не вызван) — против неавторизованного ручного синка; admin использует workspace из контекста, не из тела; `status` под админом. - **Integration-тест добавить (1):** `writeBody` (datasource) на реальном Yjs-доке + реальных `tiptapExtensions` — ветка `fragment.length>0` (очистка перед записью), которую unit-спек намеренно не исполняет; ловит дублирование тела при write-back. (Связка с editor-ext требует тех же моков, что в существующих спеках.) - **НЕ тестировать:** `git-sync.module.ts` (DI wiring), `git-sync.constants.ts` (константы), `GitmostDataSourceService.bind` (замыкание-обвязка; ветки в делегатах уже в спеке), `TriggerGitSyncDto` `@IsUUID` (class-validator), сами функции движка (third-party-вендор), примитивы ioredis. ### Модуль 4 — серверные точки-стыковки (`page.service`, `persistence.extension`, `space`, `environment`) - **Unit-тесты добавить (5):** маркировка провенанса `lastUpdatedSource:'git-sync'` в `page.service.ts` в трёх ветках — `create`, `update`/rename, `movePage` (datasource-спек доказывает лишь *передачу* аргумента, но не что PageService на него *реагирует*; ошибка тут = бесконечный цикл синка); приоритет провенанса `agent > git-sync > user` в `persistence.extension.ts` + негатив: git-sync **не** пишет boundary-снапшот истории; `environment.service:getGitSyncDataDir` (DATA_DIR-fallback + strip trailing-slash + суффикс `/git-sync`). - **Integration/contract-тесты добавить (2 + 1):** `environment.validation` через `validateSync` — `GIT_SYNC_ENABLED='true'` без `GIT_SYNC_SERVICE_USER_ID` → ошибка (главная непокрытая валидация: синк включён без атрибутируемого автора), `@IsIn` отвергает `'maybe'`; `space.service.updateSpace` — audit-дельта только при реальном изменении флага, no-op не пишется; **расширить corpus converter-gate** (contract) — добавить узлы editor-ext, отсутствующие в текущем корпусе (mention, math, details, highlight, sub/sup, цвет/выравнивание) — гейт сейчас «слеп» к узлу, которого нет в фикстурах. - **НЕ тестировать:** `jwt-payload.ts` (расширение union — type-only), `update-space.dto.ts` (class-validator), `auth-provenance.decorator.ts` (резолвит только `user`/`agent`; вход `git-sync` через токен **недостижим** — тест был бы тавтологией), `app.module.ts` (wiring), тривиальные env-геттеры-passthrough. ### Модуль 5 — клиентские точки-стыковки (`edit-space-form`, `history-item`, `space.types`) Реальный diff содержит **только** тумблер «Enable Git sync» (без полей repo URL/branch, вопреки описанию PR — открытый вопрос §7) и значок провенанса в истории. - **Integration-тесты добавить (6, Testing Library):** значок git-sync в `history-item` показывается только при `lastUpdatedSource==='git-sync'` и НЕ при `agent`/`user`/undefined; провенанс атрибутируется верному автору и значок **не** кликабелен (в отличие от `AiAgentBadge` — частая регрессия copy-paste); начальное состояние тумблера из `space.settings.gitSync.enabled ?? false`; оптимистичный апдейт + корректный payload `{spaceId, gitSyncEnabled}`; **откат при ошибке мутации** (самый ценный — иначе пользователь думает, что синк включён); тумблер disabled при `readOnly`/`isPending`. - **Кандидат в чистую функцию:** `isGitSyncEdit(source)` / `resolveProvenance(source)` из `history-item.tsx` L156–157 — позволит заменить часть рендер-тестов дешёвым табличным unit (опционально). - **НЕ тестировать:** `translation.json` (i18n-данные), `space.types.ts` (типы), рендер Mantine `Switch/Badge/Tooltip`/иконок (third-party-презентация), точный текст перевода. ## 3. Сквозные аспекты - **Contract-тесты:** (а) type-level форма `GitSyncClient` между движком и серверным адаптером (Модуль 2); (б) расширение corpus converter-gate как контракт «схема editor-ext ↔ vendored-схема» (Модуль 4). - **Property-based:** уже применяется (`markdown-roundtrip.property.test.ts`, fast-check). Расширить арбитрари на `pageBreak`/`subpages`/вложенные callout'ы — именно туда, где property сейчас не доходит. - **Test-data factories:** для серверной интеграции нужна фабрика fake-`VaultGit` и fake-`GitSyncClient` (минимальные `Pick<>`-подмножества), плюс билдер событий page-change по всем вариантам формы. - **Дымовой нагрузочный тест:** не требуется на этом этапе (синк — фоновый, без публичного RPS-контура). ## 4. Обнаруженные антипаттерны и находки - **⚠ Лок-файл рассинхронизирован (CI-блокер):** `pnpm-lock.yaml` не содержит `@docmost/git-sync`, а `apps/server/package.json:43` объявляет `"@docmost/git-sync":"workspace:*"` → `pnpm install --frozen-lockfile` падает (`ERR_PNPM_OUTDATED_LOCKFILE`). Проверено в чистом worktree. **Предусловие для любого тест-прогона в CI.** - **Свежесть сборки для converter-gate:** спек валидирует *собранные* `@docmost/editor-ext` и `@docmost/git-sync` (committed `build/`), а не исходники. CI обязан билдить оба пакета перед jest, иначе гарантия совместимости схемы устаревшая. - **Глобальная мутация при импорте:** `markdown-to-prosemirror.ts` L66–71 присваивает `global.window/document` как side-effect импорта (риск перекрёстного загрязнения тестов; причина, почему юниты импортируют конвертер напрямую, минуя barrel). - **`process.exit` в коде под тестом:** `environment.validation.ts` (`validate()`), `engine/config-errors.ts` (`loadSettingsOrExit`) — failure-путь невозможно ассертить in-process (см. R: тестировать `validateSync`). - **Мутируемые in-proc-синглтоны без сброса:** `running:Set`, `debounce:Map`, `vaults:Map`, `last-pushed` ref — каждый тест должен строить свежий инстанс (иначе order-dependent проход). - **Глотание ошибок (намеренное — ассертить логирование):** оркестратор (release/runOnce/pollTick/destroy), листенер (`handlePageEvent`), клиентский `catch` отката в `edit-space-form.tsx:53`. - **`sql.raw(prefKey)` в `space.repo.ts:updateGitSyncSettings`** — сейчас `prefKey` = константа `'enabled'` (безопасно), но паттерн инъекционно-подобный: зафиксировать вызов на allowlist/константе. - **Нестабильные тесты:** не обнаружено (история CI не анализировалась; 3 `it.fails` в пакете — намеренные задокументированные баги round-trip, не флаки). ## 5. Необходимые рефакторинги перед написанием тестов Все — **опциональные** (блокирующих нет; тесты пишутся и на текущем коде), кроме лок-файла из §4. - **CI-предусловие:** обновить `pnpm-lock.yaml` (`pnpm install` на ветке) — блокирует ВЕСЬ серверный тест-прогон в CI. - `engine/git.ts`: вынести `-z`-парсер `diffNameStatus` в чистую `parseDiffNameStatusZ(raw)` — разблокирует детерминированные тесты `C`-строк и malformed-tail (Модуль 2, integration). - `engine/roundtrip-helpers.ts`: решить судьбу `firstDivergence` (покрыть или удалить). - `git-sync.orchestrator.ts`: вынести `decideSuppressDeletes(planned, max)` (чистая) — упростит 3 delete-cap теста; опционально инъектировать функции движка/`node:fs` вместо module-level import. - `persistence.extension.ts`: вынести `resolveSource(agentTouched, actor)` — чистый unit вместо DB-сетапа. - `environment.validation.ts`: тестировать через `validateSync(plainToInstance(...))`, минуя `process.exit`. - `history-item.tsx`: вынести `isGitSyncEdit(source)` (клиентский unit вместо рендера). ## 6. План внедрения (по фазам, ROI-приоритет) - **Фаза 1 (критично, защита от потери данных и циклов):** (0) починить лок-файл; (1) листенер loop-guard + debounce; (2) оркестратор delete-cap + lock/mutex + порядок checkout/pull; (3) `page.service` провенанс в 3 ветках + приоритет в `persistence.extension`; (4) `environment.validation` enabled-без-service-user. - **Фаза 2 (корректность данных конвертера):** `pageBreak`/`subpages` round-trip; ветки `preprocessCallouts`/`bridgeTaskLists`; `parentFolderFile`; `planReconciliation` swap-move; `applyPushActions`/`applyPullActions` сбой-изоляция. - **Фаза 3 (контракты и стыки):** type-level `GitSyncClient`; расширение corpus converter-gate; `writeBody`-integration; контроллер authz; `VaultRegistry`; клиентские 6 тестов; remote-шаблон. - **Фаза 4 (полировка):** `diffNameStatus` rename+add/modify и `C`; committer-identity; `getGitSyncDataDir`; audit-дельта `space.service`; решение по `firstDivergence`. ## 7. Источники и верификация - **Отчёты 5 субагентов** `module-testability-analyst` (по одному на модуль; прочитаны полностью). - **Прогон тестов (независимая проверка claim'ов «уже покрыто»):** - `@docmost/git-sync` (vitest 4.1.6): **30 файлов, 431 passed + 3 expected-fail** — зелёный. - сервер (jest 30): спеки `gitmost-datasource`, `git-sync-converter-gate`, `space.service`, `environment.service` — **5 suites, 38 passed** — зелёный. - **Измерение покрытия (v8 / jest):** пакет — 81.7 % stmts / 68.6 % branch / 82.5 % lines; пофайлово: `docmost-schema.ts` 47 %, `markdown-converter.ts` 71 %, `markdown-to-prosemirror.ts` 77 %, `roundtrip-helpers.ts` 31 %, движок 88–93 %. Сервер: оркестратор/листенер/контроллер/vault-registry — **0 ссылок из спеков** (подтверждённый 0 %); покрыт только datasource. - **Фильтрация предложений:** шаг 1 (дедуп между модулями) — `pageBreak` сведён к unit-уровню `lib` (U1), серверный converter-gate оставлен как комплементарный контракт схемы; шаг 2 (skip-list) — отброшено ≈18 целей (wiring/константы/типы/DTO/i18n/тривиальные геттеры/недостижимый декоратор); шаг 3 (бюджет пирамиды) — reclass: engine I1/I2 и серверные unit оставлены в unit, реальные git/Yjs/рендер — в integration (unit 77 % ≥ 70 %, integration 19 % ≤ 20 %, E2E 0 ≤ 10); шаг 6 (adversarial) — каждый тест сформулирован так, чтобы падать при реалистичной незаметной поломке. ### Открытые вопросы 1. **Лок-файл:** обновить перед мержем (см. §4) — иначе CI красный. 2. **Pull-оркестрация:** в пакете есть тестируемый `runPush`, но нет `runPull` — где живёт preflight pull-цикла (refuse-on-merge-in-progress перед pull) и протестирован ли он на уровне сервера? 3. **`firstDivergence`** — есть ли продакшен-потребитель, или это мёртвый экспорт? 4. **Клиент:** описание PR обещает поля repo URL/branch, в diff их нет — UI недокоммичен или урезан намеренно? Если валидатор git-URL появится — это тест №1 по ROI на клиенте. 5. **`C` (copy) в `diffNameStatus`** — достижим ли в проде (rename-детект только `-M`)? Если нет, тесты `C` — защитные, низкий приоритет.
vvzvlad added 1 commit 2026-06-21 17:51:47 +03:00
Implements the test cases called out in the PR #119 review threads
(code-review, test-strategy report, red-team) — TESTS ONLY, no production
code changes.

packages/git-sync (vitest):
- lib converter/markdown gaps: pageBreak data-loss (it.fails repro),
  subpages lossy round-trip, nested/fenced callouts, ol->taskList bridge,
  column.width number<->string drift, empty details.
- engine units: parentFolderFile, planReconciliation swap/chained move,
  buildVaultLayout last-resort-by-id, firstDivergence, applyPushActions /
  applyPullActions failure isolation.
- real temp-git integration: diffNameStatus -z rename+add/modify
  alignment, copy-line behavior, per-invocation committer identity (no
  leak into repo/global config).
- ENFORCED type-level GitSyncClient contract via vitest typecheck over a
  *.test-d.ts file (tsconfig.vitest.json; build tsconfig untouched).

apps/server (jest):
- orchestrator: delete-cap neutralization + fail-safe, Redis lock / mutex
  skip ladder + release-on-throw, merge guard, pull/push order, remote
  template substitution, poll lifecycle.
- page-change listener: loop-guard, debounce coalescing, id resolution,
  error swallowing.
- vault registry, controller authz (trigger + status), env
  validation/getters, page.service git-sync provenance stamping,
  persistence precedence (agent > git-sync > user) + no boundary snapshot,
  space.service audit-delta, space.repo jsonb-merge, converter-gate corpus
  extension (mention/math/details/marks).

apps/client (vitest + testing-library):
- history-item git-sync badge: render gating + non-clickable.
- edit-space-form toggle: initial state, optimistic payload, rollback on
  error, disabled states.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Owner
  1. Зелёный CI ≠ то, что крутится в проде.
    Движок синка коммитится в р��позиторий уже собранным (папка packages/git-sync/build/), и именно она исполняется. А тесты (pnpm -r test) и CI гоняют исходники (src/), причём CI этот build даже не пересобирает. Итог: можно поправить баг в src, тесты зелёные, а в прод уезжает старый скомпилированный код. Тихий рассинхрон. Лечение: либо собирать пакет в CI/Docker, либо вообще не коммитить build/ (и заодно выкинуть закоммиченные node_modules — там симлинки с зашитым чужим путём /home/claude/...). (проверено руками)

Конечно блядь не коммитить build, что за чушь блядь.

> 2. Зелёный CI ≠ то, что крутится в проде. > Движок синка коммитится в р��позиторий уже собранным (папка packages/git-sync/build/), и именно она исполняется. А тесты (pnpm -r test) и CI гоняют исходники (src/), причём CI этот build даже не пересобирает. Итог: можно поправить баг в src, тесты зелёные, а в прод уезжает старый скомпилированный код. Тихий рассинхрон. Лечение: либо собирать пакет в CI/Docker, либо вообще не коммитить build/ (и заодно выкинуть закоммиченные node_modules — там симлинки с зашитым чужим путём /home/claude/...). (проверено руками) Конечно блядь не коммитить build, что за чушь блядь.
Owner
  1. Синк может затереть то, что человек печатает прямо сейчас.
    Запись из git в страницу делается через полную замену тела документа (удалить всё → вставить новое), а не через нормальный merge совместного редактирования. Если в момент синка кто-то редактирует эту страницу в браузере — его правки могут потеряться. Отдельно: если конвертация падает на «плохой» странице после того, как тело уже очищено, страница останется с пустым телом. На сайте с 5 одновременно пишущими людьми это реально. Минимум — не писать в страницу, у которой есть активная сессия редактирования, и обернуть конвертацию так, чтобы падение не оставляло пустоту.

запись делать через мерж

> 5. Синк может затереть то, что человек печатает прямо сейчас. > Запись из git в страницу делается через полную замену тела документа (удалить всё → вставить новое), а не через нормальный merge совместного редактирования. Если в момент синка кто-то редактирует эту страницу в браузере — его правки могут потеряться. Отдельно: если конвертация падает на «плохой» странице после того, как тело уже очищено, страница останется с пустым телом. На сайте с 5 одновременно пишущими людьми это реально. Минимум — не писать в страницу, у которой есть активная сессия редактирования, и обернуть конвертацию так, чтобы падение не оставляло пустоту. запись делать через мерж
Owner
  1. Каждый цикл синка молча портит часть контента.
    Конвертер Markdown↔редактор теряет данные на ряде блоков, и это происходит на каждом синке, без всякого злоумышленника:

Содержимое ячеек таблиц схлопывается в одну строку (кодоблок, список, несколько абзацев внутри ячейки → плоский текст, переводы строк теряются).
У картинок теряются размеры/выравнивание.
Блоки math, mentions, columns, details, drawio, excalidraw, сноски вообще не покрыты тестами совместимости — а значит могут молча выпадать.
Для вики/блога это прямая потеря оформления у пользователей. Минимум — честно ограничить, какие блоки поддержаны, и не синкать остальные.

надо поддержать вообще все, блядь, что за хуйня? конвертер туда-обратно не должен ничего портить, сука. написать на это тесты, проверить ВСЕ элементы

> 4. Каждый цикл синка молча портит часть контента. > Конвертер Markdown↔редактор теряет данные на ряде блоков, и это происходит на каждом синке, без всякого злоумышленника: > > Содержимое ячеек таблиц схлопывается в одну строку (кодоблок, список, несколько абзацев внутри ячейки → плоский текст, переводы строк теряются). > У картинок теряются размеры/выравнивание. > Блоки math, mentions, columns, details, drawio, excalidraw, сноски вообще не покрыты тестами совместимости — а значит могут молча выпадать. > Для вики/блога это прямая потеря оформления у пользователей. Минимум — честно ограничить, какие блоки поддержаны, и не синкать остальные. надо поддержать **вообще все**, блядь, что за хуйня? конвертер туда-обратно не должен ничего портить, сука. написать на это тесты, проверить ВСЕ элементы
vvzvlad added 1 commit 2026-06-21 19:59:33 +03:00
Expose each git-sync-enabled space as a clonable/pushable git repo over HTTP,
so `git clone https://<user>:<pass>@<host>/git/<spaceId>.git` works and external
pushes flow back into Docmost pages — gitmost itself acts as the git host (no
external GitHub/Gitea, no SSH).

Transport: shell out to `git http-backend` (CGI; git is already in the runtime
image) which implements the full smart-HTTP protocol (info/refs, upload-pack,
receive-pack, protocol v2). A raw Fastify route `/git/*` (mounted at the root,
outside the `/api` prefix) bridges the request/response to the CGI; passthrough
content-type parsers for the git media types stream the raw body to stdin.

Reuse the existing engine: clients push the vault's `main` branch, whose commits
beyond `refs/docmost/last-pushed` the engine already reconciles into Docmost.

- http/git-http.service.ts — auth (HTTP Basic -> AuthService.verifyUserCredentials),
  self-resolved workspace (DomainMiddleware does not run for this raw route),
  per-space gating (global + per-space gitSync flags, 404 hides existence),
  CASL authz (Read=fetch, Manage=push), dispatch.
- http/git-http-backend.service.ts — spawn `git http-backend`, binary-safe CGI
  response parsing (Status/headers/body), stream to the socket.
- http/git-http.helpers.ts — pure path parse, service->kind mapping, gate decision
  (unit-tested); rejects literal and percent-encoded path traversal.
- orchestrator: extract reusable withSpaceLock (CAS-guarded lock heartbeat so a
  long push cannot let the lock expire mid-cycle) and add ingestExternalPush
  (receive-pack + Docmost cycle under one lock; 503 on contention).
- vault-registry: ensureServable() — ensureRepo + idempotent receive.denyCurrentBranch
  =updateInstead / denyNonFastForwards / http.receivepack / http.uploadpack.
- env: GIT_SYNC_HTTP_ENABLED (defaults to GIT_SYNC_ENABLED) + validation.
- main.ts: register the /git/* route and the git content-type parsers.

Tests: pure helpers, CGI parsing, and the GitHttpService handler (auth/gate/authz
+ workspace resolution). Server tsc + git-sync/env suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost force-pushed feat/git-sync from c30b771c58 to 58498cf231 2026-06-22 01:12:52 +03:00 Compare
Ghost added 1 commit 2026-06-23 06:50:37 +03:00
Coder↔reviewer design loop (9 rounds, reviewer verdict: exhaustive) produced
92 specs; implemented +123 tests (465 -> 588 passing). The new round-trip
coverage exposed three genuine data-loss bugs in the Markdown<->ProseMirror
converter, all now FIXED (round-trip is lossless for these):

1. pageBreak was lost on export (no converter case -> rendered to "" and the
   node vanished). Now emits <div data-type="pageBreak"></div>, which the schema
   parses back -> round-trips.
2. A block image between blocks left an empty <p> artifact after import-hoisting,
   producing a phantom blank-gap diff on every sync. markdownToProseMirror now
   strips content-less paragraphs after generateJSON — with a schema-validity
   guard that keeps the obligatory single empty paragraph in `content: "block+"`
   containers (tableCell/tableHeader/blockquote/column/callout/doc), so empty
   cells/quotes never become an invalid `content: []`.
3. The `code` mark combined with another mark was not byte-stable (emitted nested
   HTML that the schema's `code` `excludes:"_"` collapsed on import). The
   converter now emits code-only when `code` co-occurs, matching the editor.

New coverage spans media/diagram/details/columns/math/mention attribute
round-trips, converter emission branches, git error paths, and engine decision
branches. A dedicated test pins the empty-container schema validity (the review
catch on the bug-2 fix).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost added 1 commit 2026-06-23 06:54:54 +03:00
The server requires @docmost/git-sync (main: ./build/index.js) at runtime, but
the installer stage copied only editor-ext and mcp — so the image built fine and
then crashed on startup with `Cannot find module '@docmost/git-sync'`. Copy the
package's freshly-built build/ + package.json, mirroring the mcp/editor-ext COPY
lines. (Addresses review finding #1 on PR #119.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost added 1 commit 2026-06-23 06:56:41 +03:00
This branch commits packages/git-sync/build/ and the server/Docker consume it,
so the stale build/ would otherwise ship WITHOUT the round-trip data-loss fixes
in 7d39c16b. Rebuilt via tsc (only the two changed modules). NOTE: not committing
build/ at all (review finding #2) is the proper fix, pending the CI/Docker
build-orchestration change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost added 1 commit 2026-06-23 11:40:42 +03:00
Review finding #5: the git -> page body write (writeBody) did a full-body replace
(delete-all + re-insert) on the shared Yjs doc. Applied while a human is editing
the page, it discarded their in-flight changes; and TiptapTransformer.toYdoc ran
AFTER the fragment was cleared, so a conversion failure could leave the page with
an empty body.

Fixes:
- Active-session guard: CollaborationGateway.getActiveEditorCount(documentName)
  reports live human (websocket) editor sessions for a doc, excluding server-side
  direct connections. writeBody now throws ActiveEditSessionError when an editor
  is connected. The engine's push loop already isolates each importPageMarkdown in
  try/catch and does not advance the loop-guard on failure, so the write is simply
  retried on the next poll once the editor disconnects — never a clobber.
- Crash-safe conversion: build the replacement Yjs update BEFORE opening the
  connection / clearing the fragment, so a transform failure can never leave the
  body empty.

Also updates the server-side converter gate spec to the corrected round-trip
shape: the block-image hoist no longer leaves a leading empty paragraph (the
git-sync converter fix in 7d39c16b, now reaching the built package).

A true merge of git content into a live Yjs session is out of scope (it needs a
real 3-way text merge with no shared update lineage); deferring the write while a
page is being edited is the safe, owner-approved minimum.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost added 1 commit 2026-06-23 11:44:55 +03:00
Review finding #2: packages/git-sync/build/ (the COMPILED engine) and the
package's node_modules/ were committed. Prod executed the committed build/ while
CI/tests ran src/ and never rebuilt it — so a fix in src/ could pass tests while
stale compiled code shipped (a silent src/prod skew). The committed node_modules
were pnpm symlinks with a baked machine-local store path (/home/claude/...),
useless and misleading for everyone else.

- git rm --cached packages/git-sync/{build,node_modules} (42 + 31 files).
- .gitignore: ignore packages/*/node_modules/ and packages/git-sync/build/.
- Build the package where it is actually consumed: apps/server `pretest` now
  builds @docmost/git-sync (its suite imports the built build/index.js), and the
  CI Test workflow gains an explicit "Build git-sync" step. The Dockerfile builder
  already runs `pnpm build` (nx builds the package) and now COPYs the fresh build/.

Verified: wiped build/, rebuilt via `pnpm --filter @docmost/git-sync build`, then
the server converter gate (26/26, imports the rebuilt package) and the git-sync
suite (588 passed) both pass against the freshly-built, non-committed output.

NOTE: packages/mcp/ has the same committed-build/node_modules pattern (pre-existing,
out of this PR's scope) and should get the same treatment in a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost added 1 commit 2026-06-23 15:21:28 +03:00
Supersedes the active-session "defer" guard with a real merge (review #5 —
"запись делать через мерж", not skip-while-editing).

writeBody no longer does delete-all + re-insert (which discarded a concurrent
editor's in-flight changes on every sync). It now diffs the live body against the
incoming git body at TOP-LEVEL BLOCK granularity (LCS over a canonical structural
serialization) and applies only the minimal inserts/deletes:
- a block a human is editing is left UNTOUCHED when git changed a DIFFERENT block;
- an unchanged resync is a complete 0-op write;
- Yjs CRDT-merges the minimal ops with concurrent edits.

New yjs-body-merge.ts (mergeXmlFragments + cloneXmlNode + diffBlocks) is pure-Yjs
and unit-tested with real Y.Docs (8 tests): identical->0 ops, edit-one-block keeps
the other block instances, append/delete keep neighbours, marks survive the
cross-doc clone. Crash-safety kept: the incoming doc is built before the
connection opens, so a transform failure can't empty the body.

Removed: the ActiveEditSessionError defer path and the now-unused
CollaborationGateway.getActiveEditorCount.

Honest limitation: this is a 2-way merge — for a block BOTH sides changed since the
last sync, git wins (no common ancestor to decide). A full 3-way merge would need
the last-synced base plumbed from the engine; the dominant cases (unchanged
resync, edits to different blocks) are now lossless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost added 2 commits 2026-06-23 15:50:26 +03:00
Upgrades the 2-way body merge to a real diff3 three-way merge (review #5), so a
block ONLY the human changed is KEPT when git changed a DIFFERENT block — the
2-way merge would revert it to git's stale version.

Engine: the push update loop reads the last-synced pre-image
(`git.showFileAtRef(refs/docmost/last-pushed, path)`) and passes it as the
optional `baseMarkdown` to `client.importPageMarkdown` (the common ancestor).

Server: gitmost-datasource converts base+incoming, and writeBody runs a block-
level diff3 (new three-way-merge.ts `diff3Plan`): live-only change -> keep live,
git-only change -> take git, both-changed -> git wins (conflict policy), inserts/
deletes from either side preserved. Without a base (createPage) it falls back to
the 2-way merge. Crash-safety unchanged (docs built before the connection opens).

Tests: three-way-merge.spec.ts (14 — every diff3 case incl. the cross-block
preservation and conflict policy), yjs-body-merge 3-way (real Y.Docs: human's
block instance preserved while git's block is applied), plus an engine test that
the base is forwarded from showFileAtRef. Existing push assertions updated for the
new base arg. git-sync 589 pass; server merge/datasource/gate 62 pass; typecheck
clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Same hygiene fix as git-sync (review #2), applied to packages/mcp which had the
identical pre-existing problem: committed build/ (20 files) + node_modules (28,
pnpm symlinks with a baked /home/claude store path).

- git rm --cached packages/mcp/{build,node_modules}.
- .gitignore: add packages/mcp/build/ (packages/*/node_modules/ already covers it).
- Build where consumed: apps/server `pretest` and the CI Test workflow now build
  @docmost/mcp too. The Dockerfile builder already runs `pnpm build` (nx builds
  mcp) and already COPYs packages/mcp/build into the runtime image.

Verified: wiped build/, rebuilt via `pnpm --filter @docmost/mcp build`; the mcp
server suites (96 tests) pass against the freshly-built, non-committed output.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost added 5 commits 2026-06-24 01:11:32 +03:00
Addresses the documentation/convention warnings from the #119 review:
- .env.example: add the GIT-SYNC block (9 GIT_SYNC_* vars with defaults), noting
  GIT_SYNC_SERVICE_USER_ID is required when sync is enabled.
- yjs-body-merge.ts: translate the Russian review note in the docstring to
  English (comments-only-in-English rule).
- persistence.extension.ts: correct the stale "git-sync writes are full-body
  replaces" rationale — a git-sync write is now a block-level merge into the live
  doc, which is why it is debounced like a human edit rather than snapshotted.
- history-item.tsx: the GitSyncBadge version is created on the PUSH path (writing
  the git body back into the doc), not by the pull — fix the comment.
- edit-space-form.tsx: log the raw error in the git-sync toggle catch instead of
  swallowing it (AGENTS.md).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two stability warnings from the #119 review:

1. delete-cap no longer drops deletions forever. When planned deletes exceed
   GIT_SYNC_MAX_DELETES_PER_CYCLE the apply client's deletePage now THROWS
   instead of resolving to a no-op. A throw is recorded by the engine as a
   per-page failure, so `refs/docmost/last-pushed` is NOT advanced past the
   commit that dropped the files — the next cycle re-diffs from the un-advanced
   ref and re-plans the same deletes (a transient over-cap is retried, not
   silently dropped and then recreated by the next pull). Previously a resolving
   no-op let the engine count `deleted++` with no failure, advance the ref, and
   never replay the deletions.

2. git-sync soft-delete and restore now stamp provenance. deletePage routes
   GIT_SYNC_PROVENANCE through pageService.removePage, and restorePage stamps
   lastUpdatedSource='git-sync' on the restore update — so the page-change
   listener's loop-guard (skip when lastUpdatedSource==='git-sync') recognizes
   both as its own writes instead of scheduling a wasted echo cycle. Done via a
   backward-compatible optional `lastUpdatedSource` param on
   pageRepo.removePage/restorePage (omitted for ordinary user deletes/restores).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The two-way block diff (yjs-body-merge.diffBlocks) and the three-way merge
planner (three-way-merge.lcsPairs) built the identical backward-filled LCS DP
table inline. Extract it to lcs.ts (buildLcsTable); each caller keeps its own
traceback. No behavior change — merge specs unchanged and green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The debounce map value carried `workspaceId`, but the scheduled cycle closes over
the `workspaceId` argument directly — the field was written and never read.
Replace the entry struct with `Map<string, NodeJS.Timeout>` (the timer handle is
all the map tracks). No behavior change. (page-change.listener.spec is in the
repo's pre-existing set of jest suites that can't load locally via the
react-dom/tiptap transform chain — unaffected by this change; tsc clean.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The implementation spec docs/git-sync-plan.md was removed as completed, but ~44
code comments still cited it as "plan §N". Strip those citations (comments only),
keeping each comment grammatical. The vendored engine's own "SPEC §N" references
point at a different, still-present spec and are left untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost added 2 commits 2026-06-24 01:23:55 +03:00
The per-space single-writer lock — Redis CAS leader lock (SET NX PX, DEL-CAS and
PEXPIRE-CAS Lua), the in-process mutex, the per-process instanceId and the
heartbeat — lived inline in GitSyncOrchestrator. Extract it into a dedicated
@Injectable() SpaceLockService exposing one narrow surface, withSpaceLock(spaceId,
fn), so the lock is the orchestrator's only Redis-lock touch-point and is testable
in isolation. The orchestrator now injects SpaceLockService and both consumers
(runOnce, ingestExternalPush) go through spaceLock.withSpaceLock — behavior
unchanged (same sentinel returns, same 503-on-lock-held contract). Orchestrator
drops 591→472 lines.

Adds space-lock.service.spec.ts asserting the lock SEMANTICS against a fake Redis
(the test-coverage warning from the review): the SET NX/PX args, the DEL-CAS and
PEXPIRE-CAS Lua + ARGV[1]=instanceId, plus the lock-held / in-progress / throw-
still-releases paths. The orchestrator spec is unchanged in count and stays green
(it now builds the real SpaceLockService over its mock Redis).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes the test-coverage warning that the smart-HTTP push ingest path was
unexercised. Adds 5 cases: receive-pack streams BEFORE the Docmost cycle; a
held lock throws GitSyncLockHeldError and runs neither the receive-pack nor the
cycle; a post-push cycle error is swallowed (the push is durable, poll retries)
while the lock is still released; a missing service user runs the receive-pack
but skips the immediate cycle; and a globally-disabled git-sync refuses without
touching the lock.

(The 503/Retry-After mapping in git-http.service is the sibling warning; its spec
is in the repo's pre-existing set of jest suites that can't load locally via the
react-dom/tiptap transform chain, so that case is left for CI.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost added 4 commits 2026-06-24 02:13:48 +03:00
The /git smart-HTTP host 404'd EVERY fetch and push: PATH_INFO was built as
`/<spaceId>.git/<subpath>`, so `git http-backend` resolved the repo at
`<GIT_PROJECT_ROOT>/<spaceId>.git` — which does not exist. The vault is a NON-bare
working repo (the engine needs a working tree) at `<dataDir>/<spaceId>`, so the
CGI repo path must be `<spaceId>` (git http-backend serves the `.git` inside).
The URL's conventional `.git` suffix is already stripped to `spaceId` by
parseGitPath; re-appending it for PATH_INFO was the bug.

Found by standing up a full e2e stand (real Postgres/Redis + server + a real git
clone/push over the /git remote): clone and push both 404'd until this fix, after
which a clone → edit → push round-trips the change all the way into the Docmost
page.

Also extracts the CGI-env construction into a pure, exported `buildGitBackendCgiEnv`
and adds unit tests (the env build was previously untested — the gap this bug hid
in): a regression guard pinning PATH_INFO to `/<spaceId>/<subpath>` (no `.git`),
plus method/query/content-type/remote-user forwarding and the conditional
GIT_PROTOCOL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The reconcile choreography (ensureRepo -> merge-check -> ensureBranch ->
checkout('docmost') -> pull -> push) was hand-rolled in the app orchestrator's
driveCycle, duplicating an order the vendored engine owns and could drift from on
upgrade — the failure mode is data clobber. Lift it into @docmost/git-sync as a
single entry point, `runCycle(deps)`. The orchestrator now calls runCycle and
keeps only the lock (its caller) and the gitmost-specific delete-cap POLICY,
injected as the `resolveApplyClient` hook (the engine does the dry-run, hands the
hook the planned delete count — Infinity if planning failed — and uses whatever
client it returns for the apply). driveCycle drops from ~150 lines to ~30.

Tests:
- engine test/cycle.test.ts: composition (merge-in-progress short-circuit;
  ensureRepo->ensureBranch->checkout staging order before the pull; the cap hook
  is consulted with the planned count; no dry-run when no hook).
- engine test/cycle-roundtrip.test.ts: runCycle against a REAL VaultGit in a temp
  repo with a faked Docmost client — a git-originated CREATE flows pull->push and
  the assigned pageId is written back; an unresolved merge short-circuits before
  any client call.
- orchestrator spec rewired to mock runCycle and assert the wiring + the
  resolveApplyClient cap policy (the engine-internal cycle-order/merge tests moved
  to the engine).

Validated end to end on a live stand (real Postgres/Redis + server): a git clone
-> edit -> push over the /git remote round-trips the change into the Docmost page
through the refactored cycle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A runnable end-to-end suite that drives a LIVE git-sync stand over the real /git
remote — the integration counterpart to the unit tests. 10 checks across the full
feature:
- the auth/authz gate: no creds -> 401, wrong password -> 401, unknown space ->
  404 (existence never revealed), valid creds on a sync space -> 200;
- fetch: git clone over HTTP returns the vault markdown;
- push: a git-side edit propagates into the Docmost page;
- Docmost -> git: a page created via the API materializes as a vault file;
- delete: `git rm` + push soft-deletes the Docmost page (Trash);
- 3-way merge: a new git edit is added without clobbering prior page content.

Parameterized via env (SERVER/SPACE_ID/EMAIL/PASSWORD/DB_CONTAINER) and isolates
its own test page. It boots nothing — see the header for the stand prerequisites
(GIT_SYNC_ENABLED + a per-space gitSync flag + a service user). This is the suite
that caught the smart-HTTP PATH_INFO 404 bug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost added 1 commit 2026-06-24 02:36:56 +03:00
Output of a generate→critique subagent pass on "what the feature's tests do NOT
cover", implemented + verified against the live stand (20/20). Complements the
basic two-way suite. Covers:

- protocol shape: unknown service subpath -> 400; unknown content-type -> 415
  (global allowlist); PUT/DELETE on pack endpoints -> 400;
- path-traversal: `..%2f..`, `%2e%2e%2f`, bare `.git` space-id -> 400/404, no
  escape, never a file leak;
- authz boundaries: a gitSync-DISABLED space -> 404 (existence hidden) and flips
  to 200 when enabled; a READER member can fetch (200) but is FORBIDDEN to push
  (403); a NON-member of an enabled space gets 403 (NOT 404 — the critic caught a
  wrong generator assumption here; pinned as a contract);
- concurrency: a push while the per-space Redis lock is held -> 503 + Retry-After,
  and the receive-pack does NOT mutate the vault;
- idempotency: repeated no-op cycles never churn `main` / `refs/docmost/last-pushed`;
- data-loss guard (PR #119): deleting MORE than GIT_SYNC_MAX_DELETES_PER_CYCLE is
  HELD — none trashed AND last-pushed does not advance past the delete commit
  (retry-safe, not silently dropped).

Auto-creates/tears down its fixtures (reader/non-member users, a 2nd space) and
resets the vault cache on exit so re-runs and the basic suite stay green. Needs
the vault dir + Redis container reachable (see header). A structural rename/move
case was intentionally left to the engine unit suite (git rename-similarity on
meta-only fixture pages is a fixture artifact, not a feature bug).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost added 1 commit 2026-06-24 02:54:42 +03:00
The push / 3-way-merge cases edited the FIRST real `.md` in the vault, leaving
`E2E-PUSH-*` / `E2E-MERGE-*` marker headings accumulating in a real page, and the
Docmost->git case left its created page in the Trash. Now the suite creates a
dedicated `E2E-SyncTarget-*` page and targets only that, and a teardown
hard-deletes every `E2E-*` fixture page and converges the vault on exit — so runs
never mutate real content and leave the stand clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost added 4 commits 2026-06-24 03:48:14 +03:00
CRITICAL data-loss bug: creating pages in Docmost (which start UNTITLED) and then
typing a title could soft-delete OTHER pages. Untitled pages all serialize to the
`_` fallback filename; the layout disambiguates them (`_.md`, `_ ~slug.md`).
Retitling one frees the bare `_` and another untitled page's file relocates into
it. git's rename detection (`-M`) can't see the move (the tiny meta-only files are
too dissimilar), so `git diff` reports it as DELETE(old) + ADD/MODIFY(new). The
push took the DELETE literally and trashed a live page.

Root cause is that the push trusted git's path-level rename heuristic for page
IDENTITY. Identity is the pageId. Fix: before emitting any delete, coalesce by
pageId — a pageId that is BOTH deleted (pre-image) AND present on the surviving
side (current meta of an ADD or a MODIFY, since a relocation into an occupied path
shows as M) is one page that MOVED, classified as a rename/move and NEVER a delete.

Reproduced + verified on a live stand: 4 untitled pages + retitle one trashed a
different page before; after the fix, retitling one (and stress-retitling all)
trashes nothing. Engine suite 598 green; 3 new computePushActions cases (ghost
D+A move -> rename; real delete still deletes; unrelated D+A stay delete+update).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reproduces the browser bug at the API level: create several untitled pages (all
collapse to the `_` fallback name), retitle one, sync — assert NO page is
trashed and all survive. Caught the data-loss bug fixed in 4376c5a6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to 4376c5a6, found by a real BROWSER e2e (the flow the in-diff fix
missed). When the layout reshuffle's two halves land in SEPARATE sync cycles, the
later cycle's diff has only the DELETE of the old path — the matching add was
already pushed — so in-diff D+A coalescing can't see it, and the live page was
still trashed.

Robust fix on the identity invariant the reviewer (and the user) called out: a
page EXISTS iff its pageId is in the vault, regardless of filename. runPush now
collects the pageIds present at ANY path in the current `main` tree and passes
them to computePushActions; a deleted file whose pageId is still tracked
elsewhere is a MOVE, never a deletion. (Built only when the diff has deletes.)

Adds apps/server/test/git-sync-browser-e2e.cjs — a Playwright test that drives the
REAL Docmost web UI: log in, create several untitled pages, type a title, sync,
assert NOTHING is trashed. Reproduced the data loss before this fix; 5/5 green and
stable after. Engine suite 600 green (+2 computePushActions cases:
pageId-still-present -> skip; pageId-gone -> real delete).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost added 1 commit 2026-06-24 03:54:53 +03:00
The shell e2e suites defaulted to the General space and created/edited pages
there, polluting real content (and, when several enabled spaces raised poll
contention, flaking on 503s). Now each suite creates its OWN throwaway,
git-sync-enabled space at setup, runs everything against it, and deletes the
space (+ its vault) on exit. Set SPACE_ID explicitly to opt into an existing
space. Also gives the basic suite the 503-retry push helper the advanced one
already had. Verified isolated: basic 12/12, advanced 23/23, no spaces/users/
pages left behind, the real space untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost added 7 commits 2026-06-24 04:49:06 +03:00
All service metadata moves into a single `.gitmost/index.json` sidecar; the `.md`
files become clean markdown (Obsidian & any editor work directly). The page tree
mirrors the folder structure (folder = parent page; the parent's body lives in
`<Folder>/index.md`); collisions disambiguate by a `~<slugId>` filename suffix
with identity tracked by pageId in the index (safe renames, never delete+create —
backed by 5133bb34). Bare files/folders from a third-party editor are adopted into
pages. Includes the migration path off the current `docmost:meta`-in-file format
and a phased plan (each phase gated by engine unit tests + the browser e2e +
isolated shell e2e). Agreed with the owner 2026-06-24.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pure read/write/lookup for the vault sidecar index that will hold page identity
(pageId) + collision token (slugId) keyed by file path, so the .md files can be
clean markdown. parseVaultIndex is tolerant (missing/garbage/bad entries degrade
to empty/skipped — never crashes a cycle); serializeVaultIndex is deterministic
(sorted keys -> stable diffs, no churn). Lookups (pageIdAt, pathForPageId reverse,
trackedPageIds) + mutations (set/remove/move). NOT wired into pull/push yet — no
behavior change. 5 unit tests; engine suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Captures the design discussion: a path-keyed sidecar is NOT a safe source of
truth (a git-undetected rename loses the page), so the id must travel WITH the
file — either as a slugId suffix in the filename (B) or a minimal YAML frontmatter
`id:` (C); both robust, B/C is the open UX decision (author leans C for clean
names). The sidecar may remain an optional path->id cache. Adds phase 6 — link
sync between notes: Docmost links are by pageId (survive rename), vault markdown
links are by path (rewrite on rename, Obsidian-style); independent of B/C and the
format phases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pivot the thin-meta design to "the vault IS a native Obsidian vault": clean
markdown + a minimal YAML frontmatter `gitmost_id:` (the durable pageId, travels
with the file so identity survives any move); folders mirror the page tree with
the parent's body as a folder-note `<Folder>/<Folder>.md` (LostPaul Folder Notes
convention); links as `[[wikilinks]]` (basename-resolved → reparent never breaks a
link, only retitle does); collisions disambiguated Obsidian-style; `.obsidian/`
and non-page files left untouched (no .gitignore). Verified the conventions
against the Obsidian/Folder-Notes docs.

Replaces the abandoned `.gitmost/index.json` sidecar (path-keyed → fragile to
git-undetected renames; the in-file id is self-sufficient): removes vault-index.ts.
Adds lib/page-file.ts — parsePageFile/serializePageFile (frontmatter id + clean
body) with a LEGACY `docmost:meta` fallback for migration. 6 unit tests; engine
suite green. Not yet wired into pull/push — no behavior change. Design doc
rewritten to the native-Obsidian format.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per owner: test data, no migration. parsePageFile no longer reads the old
docmost:meta block — a file without a gitmost_id frontmatter is simply un-tracked
(adopt). Vaults are a cache: rm -rf on the transition, rebuilt native from
Docmost. Simplifies the format work (no fallback). Doc updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Native-Obsidian structure: a page WITH children now lives at its folder-note
<name>/<name>.md (LostPaul Folder Notes convention) with its children alongside;
a leaf stays <name>.md. Folder-notes claim their canonical path before a
same-named child, so the child (a leaf) is the one disambiguated, never the
folder-note — a folder X/ always contains its own note X.

Format-agnostic and safe in isolation: only the destination PATH changes, the
file content/serialization is untouched, so an existing parent relocates via the
move-by-id path (no delete). The frontmatter format flip (pull+push) is next.

6 new layout unit tests (leaf / parent / nested / child-named-as-parent /
twin-parents / childless). 611 engine tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PULL now serializes each page as the native-Obsidian format (serializePageFile:
a minimal gitmost_id frontmatter + the fixpoint markdown body) instead of the
heavy docmost:meta envelope. title/parent/space are derived (filename / folder /
repo), so only the pageId is persisted. readExisting recovers identity from the
gitmost_id frontmatter (parsePageFile) instead of docmost:meta.

Extracted stabilizePageBody() (the export->import->export fixpoint, no meta) so
the native writer and the legacy serializer share the same deterministic body —
re-pulls of an unchanged page stay byte-identical (loop-guard).

Tests: read-existing fixtures rewritten to gitmost_id; apply-pull asserts the
written text is native frontmatter and carries NO docmost:meta (regression
guard). 611 engine tests green.

NOTE: PUSH still reads docmost:meta — the end-to-end cycle is intentionally NOT
runnable until phase 3 (PUSH reads frontmatter + derives title/parent from path)
lands; no vault is wiped/deployed until then.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost added 1 commit 2026-06-24 05:04:52 +03:00
PUSH now consumes the native-Obsidian format end-to-end:
- identity from the gitmost_id frontmatter (parsePageFile), not docmost:meta;
- title from the FILENAME, parentPageId from the enclosing folder's folder-note
  (parentFolderFile is now FOLDER-NOTE aware: a child's parent is dir/dir.md, and
  a folder-note's own parent is one level up), spaceId from the run (every vault
  file belongs to the vault's space);
- CREATE derives title/parent/space from path + run and writes the assigned
  pageId back as gitmost_id frontmatter (serializePageFile);
- UPDATE pushes the STRIPPED body (current + 3-way-merge base), so the frontmatter
  never leaks into Docmost content; the loop-guard hashes the body.

The PURE delete-sensitive classifier (computePushActions/classifyRenameMoves) is
UNCHANGED — only the injected IO resolvers (metaAt, parent, create write-back)
switched source. nativeMeta always carries the run spaceId, so the legacy
'create-without-spaceId' skip no longer fires through runPush.

Tests rewritten to native fixtures + folder-note parent paths; the noop case is
now a child under a renamed parent folder (filename=title, so a path-only-noop
needs an ancestor rename). parentFolderFile tests cover leaf/folder-note/nested/
dotted. 612 engine tests green; engine rebuilt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost added 1 commit 2026-06-24 05:08:19 +03:00
Ghost added 1 commit 2026-06-24 05:14:49 +03:00
Self-review of phase 3 caught a data-corruption regression: nativeMeta always
supplies the run's spaceId, so the planner's 'create-without-spaceId' skip — which
had doubled as the only filter for non-page files — went dead. An ADDED
.obsidian/*.json, attachment, or dotfile (committed to the vault, no .gitignore)
would then be classified as a CREATE: a junk Docmost page, plus a gitmost_id
frontmatter written INTO the file, corrupting it.

Fix: isPageFile(path) — a .md file with NO dot-segment anywhere — and filter the
diff to page files at the very top of computePushActions, BEFORE any
classification, so non-page A/M/D/R are ignored (design §Адопция). 2 unit tests
pin it (.obsidian/json, attachment, dotfile, dot-segment, .md dotfile all ignored;
real pages still created). 614 engine tests green.

Also: refreshed stale docmost:meta comments to gitmost_id (review SUGGESTION), and
documented the deferred adoption frontmatter-preservation gap (review WARNING) in
page-file.ts + the design doc (do NOT roll native onto a real vault with Obsidian
properties until phase 4 round-trips them).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost added 2 commits 2026-06-24 13:25:40 +03:00
The stray commit 0bff612c "rm all 7" deleted README.md, README.ru.md,
CHANGELOG.md and AGENTS.md (956 lines) on this branch only — AGENTS.md is the
canonical contributor guide the whole workflow relies on. Restored all four from
develop's canonical versions so the merge cannot wipe them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses the stability + test-coverage warnings from the #119 review:

- git-http-backend.service.ts: add `'error'` handlers to child.stdout/stderr. An
  EventEmitter 'error' with no listener (e.g. EPIPE when the client aborts
  mid-response) is rethrown by Node as an uncaught exception and crashes the
  process; now swallowed + logged (never echoed to the client).
- TEST INFRA: a jest setupFile shims `navigator`/`MessageChannel` for the `node`
  testEnvironment. react-dom@18 reads `navigator` at module-init (pulled in via
  @docmost/editor-ext -> @tiptap/react), so every spec transitively importing the
  conversion engine — including git-http.service.spec.ts — previously FAILED TO
  LOAD ("navigator is not defined") and ran ZERO tests. With the shim those specs
  now run (git-sync integration: 11 suites / 133 tests green).
- git-http.service.spec.ts: cover the 503 lock-held push path — `ingestExternalPush`
  rejecting `GitSyncLockHeldError` -> 503 + Retry-After + "git-sync busy, retry",
  no double header write (+ the already-headers-sent no-rewrite path).
- git-http-backend.service.spec.ts: unit-test run() — child 'error'/'close' before
  headers -> 500; normal CGI parse+stream; stdout/stderr 'error' (EPIPE) swallowed;
  synchronous spawn throw -> 500.
- page-change.listener.ts: implement OnModuleDestroy to clearTimeout all pending
  debounce timers on shutdown (+ test).
- .env.example: vaults are non-bare working repos, not "bare repos".

(Docs deleted by the stray commit were restored in 9cdbce54.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost force-pushed feat/git-sync from b4bb7a538a to 9f6478a0ed 2026-06-24 13:48:50 +03:00 Compare
Ghost added 1 commit 2026-06-24 14:23:54 +03:00
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>
Ghost force-pushed feat/git-sync from a093bc8331 to 46109ad4a8 2026-06-24 16:54:10 +03:00 Compare
vvzvlad added the featureepic labels 2026-06-24 20:49:06 +03:00
vvzvlad changed title from feat(git-sync): native two-way Docmost↔git Markdown sync (Phases A–D, live-verified) to WIP: feat(git-sync): native two-way Docmost↔git Markdown sync (Phases A–D, live-verified) 2026-06-25 23:59:34 +03:00
vvzvlad added 1 commit 2026-06-26 00:08:24 +03:00
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>
Owner

Адресовал ревью #1571 — коммит 68515ef9 (запушен в feat/git-sync)

Все полные сьюты зелёные: движок 605 passed (+1 expected-fail, 0 type errors), сервер 193 passed / 15 suites. Каждое изменение прошло отдельный ревью-цикл (вердикт APPROVE).

🛑 Must fix

  1. Идемпотентный CREATE. Перед createPage теперь читаю живое дерево Docmost и при совпадении по (parentPageId, title) адоптирую существующую страницу (write-back id + importPageMarkdown) вместо повторного создания — сбой write-back между createPage и коммитом больше не плодит дубликаты. Лукап только по complete:true дереву (иначе fallback на createPage; нативный клиент всегда complete). Покрыто: retry-adopt тест + regression-lock на ветку complete:false (мутационно проверен — без guard'а падает ровно он).
  2. Русские комментарии → английский по всему движку. Значение notesHeading = "Примечания переводчика" в lib/diff.ts:255 — доменное, не трогал.
  3. packages/git-sync/package.json description → ссылка переуказана на docs/backlog/git-sync-thin-meta.md.
  4. Битая ссылка в docs/backlog/ai-chat-tool-definitions-duplicated.mdgit-sync-thin-meta.md.
  5. AGENTS.md: git-sync добавлен пятым workspace-пакетом (таблица + счётчик) и заметка про синхронизацию схемы обновлена на три зеркала (editor-ext каноничен + mcp + git-sync).

Warnings

  • Таймаут на git http-backend. Watchdog (GIT_SYNC_BACKEND_TIMEOUT_MS, дефолт 120с) убивает зависший дочерний процесс и шлёт чистый 500; таймер чистится в close/error, resolve() ровно один раз — зависший/заглохший клиент больше не держит space-lock бесконечно. Заодно добавил таймаут на единый git-чокпоинт движка (runRaw в engine/git.ts).
  • Логгер вместо console в applyPullActions (пробрасывается из cycle) — критичные для потери данных диагностики pull снова идут через структурированный Nest-логгер со spaceId.

Suggestions

  • Удалён мёртвый standalone-CLI код: parseArgs/PushParsedArgs, parseSettings/envSchema, loadSettingsOrExit + файл config-errors.ts + мёртвые спеки и экспорты из index.ts (тип Settings, BOT_AUTHOR_* и трейлеры Docmost-Sync-Source сохранены).
  • Phase-B+ методы датасорса (listRecentSince/listTrash/restorePage) — оставил в контракте GitSyncClient, закрыл юнит-тестами через client-seam.
  • TOCTOU в space-lock: running.add теперь синхронно до await acquire (in-process слот резервируется раньше Redis-acquire; release только при успешном acquire, cleanup во внешнем finally).

Test coverage

  • Провенанс removePage — обе ветки: git-sync → source 'git-sync', обычный юзер → undefined.
  • ensureServable — тест на все 4 git config записи, включая security-критичную receive.denyNonFastForwards=true (защита от force-push на main).

Architecture (forward-looking)

  • Schema-drift guard. Добавил snapshot-тест поверхности схемы git-sync (имена node/mark + наборы ключей атрибутов): любой дрейф теперь громко падает в CI и требует ручной сверки с @docmost/editor-ext; + provenance-заголовок в docmost-schema.ts. Сделал самостоятельный snapshot (а не кросс-импорт editor-ext — кросс-репрезентационная сверка двух разных форматов хрупка); цель «превратить молчаливый инвариант в громкий CI-fail» достигнута.

Бонус

  • pnpm-lock.yaml: добавлен недостающий workspace-линк @docmost/git-sync под apps/serverpnpm install --frozen-lockfile (дефолт CI) снова проходит.
## Адресовал ревью #1571 — коммит `68515ef9` (запушен в `feat/git-sync`) Все полные сьюты зелёные: движок **605 passed** (+1 expected-fail, 0 type errors), сервер **193 passed / 15 suites**. Каждое изменение прошло отдельный ревью-цикл (вердикт APPROVE). ### 🛑 Must fix 1. ✅ **Идемпотентный CREATE.** Перед `createPage` теперь читаю живое дерево Docmost и при совпадении по `(parentPageId, title)` **адоптирую** существующую страницу (write-back id + `importPageMarkdown`) вместо повторного создания — сбой write-back между `createPage` и коммитом больше не плодит дубликаты. Лукап только по `complete:true` дереву (иначе fallback на `createPage`; нативный клиент всегда complete). Покрыто: retry-adopt тест + regression-lock на ветку `complete:false` (мутационно проверен — без guard'а падает ровно он). 2. ✅ **Русские комментарии → английский** по всему движку. Значение `notesHeading = "Примечания переводчика"` в `lib/diff.ts:255` — доменное, не трогал. 3. ✅ `packages/git-sync/package.json` description → ссылка переуказана на `docs/backlog/git-sync-thin-meta.md`. 4. ✅ Битая ссылка в `docs/backlog/ai-chat-tool-definitions-duplicated.md` → `git-sync-thin-meta.md`. 5. ✅ `AGENTS.md`: git-sync добавлен пятым workspace-пакетом (таблица + счётчик) и заметка про синхронизацию схемы обновлена на **три** зеркала (`editor-ext` каноничен + `mcp` + `git-sync`). ### Warnings - ✅ **Таймаут на `git http-backend`.** Watchdog (`GIT_SYNC_BACKEND_TIMEOUT_MS`, дефолт 120с) убивает зависший дочерний процесс и шлёт чистый 500; таймер чистится в `close`/`error`, `resolve()` ровно один раз — зависший/заглохший клиент больше не держит space-lock бесконечно. Заодно добавил таймаут на единый git-чокпоинт движка (`runRaw` в `engine/git.ts`). - ✅ **Логгер вместо `console`** в `applyPullActions` (пробрасывается из `cycle`) — критичные для потери данных диагностики pull снова идут через структурированный Nest-логгер со `spaceId`. ### Suggestions - ✅ Удалён мёртвый standalone-CLI код: `parseArgs`/`PushParsedArgs`, `parseSettings`/`envSchema`, `loadSettingsOrExit` + файл `config-errors.ts` + мёртвые спеки и экспорты из `index.ts` (тип `Settings`, `BOT_AUTHOR_*` и трейлеры `Docmost-Sync-Source` сохранены). - ✅ Phase-B+ методы датасорса (`listRecentSince`/`listTrash`/`restorePage`) — оставил в контракте `GitSyncClient`, закрыл юнит-тестами через client-seam. - ✅ TOCTOU в `space-lock`: `running.add` теперь синхронно **до** `await acquire` (in-process слот резервируется раньше Redis-acquire; release только при успешном acquire, cleanup во внешнем finally). ### Test coverage - ✅ Провенанс `removePage` — обе ветки: git-sync → source `'git-sync'`, обычный юзер → `undefined`. - ✅ `ensureServable` — тест на все 4 `git config` записи, включая security-критичную `receive.denyNonFastForwards=true` (защита от force-push на `main`). ### Architecture (forward-looking) - ✅ **Schema-drift guard.** Добавил snapshot-тест поверхности схемы git-sync (имена node/mark + наборы ключей атрибутов): любой дрейф теперь **громко** падает в CI и требует ручной сверки с `@docmost/editor-ext`; + provenance-заголовок в `docmost-schema.ts`. Сделал самостоятельный snapshot (а не кросс-импорт editor-ext — кросс-репрезентационная сверка двух разных форматов хрупка); цель «превратить молчаливый инвариант в громкий CI-fail» достигнута. ### Бонус - `pnpm-lock.yaml`: добавлен недостающий workspace-линк `@docmost/git-sync` под `apps/server` — `pnpm install --frozen-lockfile` (дефолт CI) снова проходит.
Owner

🤖 Автоматический отчёт по тест-стратегии (test-suite-orchestrator), scoped к этому PR. Сгенерировано Claude Code.

Отчёт по тест-стратегии — gitmost / PR #119 (git-sync) — 2026-06-26

Область анализа — только изменения PR #119 «native two-way Docmost↔git Markdown sync»
(ветка feat/git-syncdevelop, 198 файлов, +29586/−9843). Не весь монорепозиторий.
Анализ выполнен по worktree .claude/worktrees/pr119-fixes (HEAD 46109ad4).
PR уже несёт обширные тесты; цель отчёта — найти оставшиеся пробелы, слабые/тавтологичные
тесты и закрыть осознанно отложенные автором §13.3 (интеграция datasource↔БД) и §13.4 (e2e в репо).

1. Исполнительное резюме

  • Проанализировано модулей: 8 (по одному субагенту module-testability-analyst на модуль).
  • Предложено тестов (после дедупликации и фильтров): ≈46 — unit/component 32 · integration 9 · contract 2 · E2E 3.
  • Отклонено/слито как малоценные или дублирующие: ≈25 «сырых» предложений аналитиков.
  • Соблюдение пирамиды: unit ≈ 70 % ✓; E2E = 3 (абсолютный лимит ≤ 10) ✓; integration ≈ 20 % с осознанным небольшим перевесом (фича критична к целостности данных — см. §6).
  • Покрытие сейчас (независимо проверено):
    • @docmost/git-sync (vitest): 604 pass + 1 expected-fail, 42 файла — подтверждено локальным прогоном. (Цифра в описании PR «431 pass» устарела.)
    • Серверный jest (заявлено 723 pass) и клиентский vitest не удалось перепрогнать в worktree: там нет полной установки зависимостей (нет бинаря jest; React не слинкован в client → даже существующий тест ai-agent-badge.test.tsx падает с null (reading 'useState')). Это артефакт окружения, не дефект тестов PR. Источник истины по этим слоям — CI.
  • Прогноз: чистые слои (lib/engine ~85–90 % по оценке аналитиков) уже сильны; главный незакрытый риск — поведенческое покрытие шва datasource↔БД ≈ 0 % (всё замокано) и гейт §13.1 покрывает ~половину видов узлов схемы. Внедрение плана выводит эти две зоны из «слепых».

2. Рекомендации по модулям

M1. packages/git-sync/src/lib — чистые конвертеры (ProseMirror↔Markdown)

Покрыт лучше всех (605 тестов). Шов схемы §13.1.

  • Unit добавить:
    • markdown-converter.ts:convertProseMirrorToMarkdowntop-level image теряет width/height/align (внутри columns imageToHtml их сохраняет — асимметрия). Зафиксировать md1 и docsCanonicallyEqual===false. Ловит «починили один путь — забыли второй».
    • тот же символ, ветка default (L584-586): неизвестный узел верхнего уровня молча сводится к тексту детей. Pin деградации без падения.
    • глубина рекурсии: подтверждён RangeError (~5000 уровней), нет depth-guard/try-catch (в отличие от diffDocs). Зафиксировать границу либо завести guard (R7).
    • идемпотентность экранирования в атрибутах (math) на 3 циклах — нет накопления &&amp;.
    • node-ops.ts:insertNodeRelative — два throw (append tableRow на верхнем уровне; anchor вне tableRow) не покрыты.
    • node-ops.ts:replaceNodeById/deleteNodeByIdдублирующийся attrs.id: оба заменяются/удаляются, без рекурсии в подставленный узел.
    • diff.ts:diffDocs — детерминизм порядка changes (иначе фантомный git-churn).
  • НЕ тестировать: docmost-schema parseHTML/renderHTML (третья сторона — TipTap); index.ts ре-экспорт; sanitizeCssColor/clampCalloutType (уже исчерпывающе); page-file determinism-by-double-call (тавтология).

M2. packages/git-sync/src/engine — reconcile/layout/pull/push/cycle/VaultGit

  • Unit добавить (высокий ROI):
    • cycle.ts:runCyclefail-safe delete-cap: при исключении в dry-run в hook должен уйти Infinity (отложить удаления). Сейчас тестируется только deletes===0. Это ровно та ветка, что защищает от инцидента «9 реальных страниц soft-удалены».
    • layout.ts:buildVaultLayoutдетерминизм при перестановке входа (два сиблинга с одинаковым заголовком; orphan-bucket; folder-note vs child). Корневая причина «фантомных absence-удалений» (автор её НЕ чинил) — pin инвариант «стабильно под перестановкой» (не «правильное имя»).
    • push.ts:isPageFile.MD (верхний регистр) → false: намеренно ли (case-insensitive ФС)? 1 кейс + open-question.
  • Integration: см. M3/M8 (real-git temp-repo уже есть: run-push-realgit.test.ts, git.test.ts).
  • НЕ тестировать: приватные VaultGit.run/isRepo; loop-guard.bodyHash (обёртка SHA, покрыта); stabilize.* (композиция конвертеров, покрыта ниже). Политика cap (значение, off-by-one, cap=0) живёт в оркестраторе — см. M3, не дублировать здесь.

M3. apps/server/.../git-sync/services + listeners — оркестрация и доменные сервисы

  • Unit добавить:
    • lcs.ts:buildLcsTable — нет своего спека (off-by-one таблицы DP).
    • three-way-merge.ts:diff3Plan — провенанс/якоря из live (сохранение инстансов блоков) + дублирующиеся ключи блоков (повторяющиеся пустые абзацы — реальный случай).
    • yjs-body-merge.ts:mergeXmlFragments3Way — base==live==target (0 операций), вложенные блоки (list>item), стабильность serializeXmlNode.
    • git-sync.orchestrator.ts:enabledSpaces/pollTickстрогий per-space opt-in (НЕТ all-spaces fallback) + изоляция: падение одного спейса не ломает остальные. Регрессия здесь — тихая (всё в catch).
    • page-change.listener.tsнезависимость таймеров по спейсам + перевзвод debounce после срабатывания.
  • Integration (это и есть отложенный §13.3):
    • GitmostDataSourceService.writeBody против реального collab + БД: провенанс git-sync/serviceUser, корректность тела, 3-way: правка человека выживает. Текущий unit-спек тавтологичен (мокает TiptapTransformer, ассерт length>0).
    • CRUD vs реальная БД: createPage (атрибуция serviceUser), deletePage (soft, не force), move/rename (fractional position после последнего сиблинга), restore.
    • SQL-семантика: listRecentSince (> а не >=), listTrash (deletedAt is null), computeMovePosition (collate('C') — сейчас даже не ассертится).
    • SpaceLockServiceконтеншн двух «реплик» против реального Redis/ioredis-mock: CAS-release НЕ удаляет чужой лок; потеря лока по TTL. Центральный инвариант single-writer.
  • Contract: форма GitSyncClient (bind()) ↔ интерфейс движка (ловит дрейф шва).
  • НЕ тестировать: bind() closure-passthrough; onModuleInit регистрация интервала (framework wiring, один тест есть); REST-плейсхолдеры buildSettings.
  • Антипаттерны: тавтологичный datasource-спек (мок возвращает ассертируемое); as any каст User/DTO в renamePage (L314) прячет дрейф контракта PageService; catch-проглатывание в pollTick/listener.

M4. apps/server/.../git-sync/http — Git Smart-HTTP backend (безопасность!)

  • Unit добавить (security):
    • parseGitPathencoded/NUL traversal в сегменте spaceId: regex %2e|%2f применяется только к subpath, НЕ к first/spaceId (а именно он выбирает репозиторий и идёт в findById/PATH_INFO). HIGH.
    • GitHttpService.handleBOLA: кросс-spaces/кросс-workspace: findById вернул null/другой spaceId → 404, backend не запущен, наружу уходит resolved space.id, не сырой из URL. HIGH (нужен R2).
    • handleCASL createForUser бросает / не-credential ошибка auth → 403/401, не 500 и не fail-open.
    • resolveServiceKind — спуфинг service/регистр метода: write не должен классифицироваться как read.
    • decideGitHttpGateserviceKind=null без креденшелов → 401 раньше 400 (оракул статуса).
    • buildGitBackendCgiEnv + parseCgiResponse — инъекция в CGI-env/заголовки (CR/LF, response splitting).
    • runPOST-тело стримится в stdin + client-abort/EPIPE (весь stdin-путь не тестируется — все тесты GET).
  • Integration: реальный git http-backend в temp-репо — path-scoping/LFI (краденый PATH_INFO не выходит за GIT_PROJECT_ROOT); round-trip info/refs; большой pack стримом; watchdog (SIGTERM→SIGKILL).
  • НЕ тестировать: типы; headerValue passthrough; DI-обвязка; mcp-auth.helpers (свой спек).
  • Антипаттерн: мок findById игнорирует аргументы → изоляция «проверяется» только по call-args (тавтология); непокрытые catch-ветки безопасности.

M5. apps/server/.../git-sync wiring + environment (конфиг/контроллер)

  • Unit добавить: isGitSyncHttpEnabled (ветка наследования от master-switch); getGitSyncBackendTimeoutMs (NaN/0/отриц → дефолт — тот же класс бага parseInt/NaN, иначе watchdog не сработает и лок держится вечно); GIT_SYNC_HTTP_ENABLED @IsIn (опечатка булевого env у оператора).
  • Integration: GitSyncController через реальный JwtAuthGuard + ValidationPipe(whitelist) — 401 без JWT / 403 не-админ / 200 админ; @IsUUID() spaceId не вырезается whitelist-пайпом (прежний баг «vault назван undefined»). Unit-спек контроллера хорош, но guard'ы он не проверяет.
  • НЕ тестировать: git-sync.module.ts (DI wiring); git-sync.constants.ts (литералы — change-detector); приватный assertAdmin (покрыт через публичные); passthrough-геттеры без ветвления.
  • loadGitSync (memo/retry) — опционально, и только если шов импорта тестируем без рефактора R6.

M6. Серверные core-правки (provenance / page·space services·repos / collab persistence)

  • Unit добавить: auth-provenance.decorator.ts:agentSourceFieldsветка 'git-sync' опускает chat-ключ (иначе фантомный lastUpdatedAiChatId, ломается loop-guard) — это буквально новая строка PR, на уровне хелпера не покрыта. space.service.ts:updateSpace — gitSync пишется независимо от соседних флагов (sharing/comments) в одной транзакции.
  • Integration (real PG): space.repo.ts:updateGitSyncSettingsjsonb-merge НЕ затирает соседей (sharing, вложенный gitSync.*); текущий спек лишь toContain("COALESCE") (change-detector, не доказывает merge). page.repo.ts:removePage/restorePagelastUpdatedSource='git-sync' проставляется на всё поддерево (рекурсивный UPDATE) — иначе echo-цикл в git.
  • Contract (важно): §13.1 gate — добить корпус. Конвертер обрабатывает ~50 видов узлов, гейт прогоняет ~25. Без покрытия: attachment/audio/video/youtube/drawio/excalidraw/embed/pdf (HTML-путь с data-width/align), columns/column, subpages/pageBreak/hardBreak/underline/comment. Узлы editor-ext htmlEmbed/status/footnote/page-embed/transclusion конвертер даже не упоминает — задокументировать их статус (зелёный / known-divergence / намеренно не экспортируется). Высший приоритет для контракта схемы.
  • НЕ тестировать: thin-passthrough query-helpers репозиториев (withSpace/withCreator/...); jwt-payload.ts/update-space.dto.ts (типы/декораторы — третья сторона; поведение whitelist живёт в ValidationPipe, покрывается интеграцией M5).
  • Антипаттерн: SQL-string-ассерты space.repo.spec (ломаются на апгрейде Kysely, merge не доказывают); jest.spyOn(require(...)) без restoreAllMocks.

M7. Клиент (React) — тоггл per-space + бейдж истории

Оба новых теста (history-item.test.tsx, edit-space-form.test.tsx) — поведенческие (ассертят payload и условный рендер по source, есть adversarial-контраст), не тавтологичные снапшоты. Главный пробел — минимальность payload.

  • Component добавить: тоггл шлёт ровно {spaceId, gitSyncEnabled} без name/slug/description (риск затирания настроек); тоггл OFF шлёт false; git-sync-badge с authorName=undefined не рендерит литерал "undefined".
  • Integration: EditSpaceForm + реальный useUpdateSpaceMutation + QueryClientProvideronSuccess merge кэша ({...space, ...data}) не теряет settings.sharing/comments.
  • НЕ тестировать / E2E: визуальные props бейджа; внутренности Mantine; permission-derivation (живёт в родителе). E2E здесь не нужен (журнал покрыт на server/full-stack уровне).
  • Мелкий смелл: .toBeTruthy() после getByText (предпочесть toBeInTheDocument); связка с CSS-классом Mantine.

M8. E2E-харнесс / CI / runtime

3 скрипта (git-sync-e2e.sh, …-advanced.sh, …-browser-e2e.cjs) ассертят, но не CI-runnable (живой стенд, sleep 2, доступ к host-$VAULT_DIR, Redis-инъекция лока) и не вызываются ни из одного workflow — это и есть §13.4. Готовые «кирпичи» уже есть: test/integration/{global-setup,db}.ts + jest-integration.json (миграции docmost_test, pgvector) и real-git паттерн в пакете.

  • E2E (committed, детерминированные): см. §сквозные.
  • CI-пробелы: test.yml гоняет только pnpm -r test (unit/spec). Нет services: postgres/redis → 5 существующих .int-spec.ts тоже не идут в CI; нет шага test:int/test:e2e; нет gate по покрытию.
  • НЕ тестировать: jest.setup.ts, .env.example, Dockerfile apt-get git (smoke — в релиз-пайплайн); …browser-e2e.cjs оставить ручным (покрыто детерминированно на data-слое).

3. Сквозные аспекты

  • Contract-тесты: (1) §13.1 gate — расширить корпус до всех видов узлов (M6); (2) форма GitSyncClient ↔ движок (M3). Между сервисами per se контрактов нет — «контракт» здесь это git Smart-HTTP wire-протокол (покрывается e2e ниже).
  • Property-based: уже применяется (markdown-roundtrip.property.test.ts, fast-check) — образцово; расширять только при добавлении новых видов узлов.
  • E2E user journeys (всего 3, лимит ≤10):
    • E1 — committed round-trip (§13.4): git clone → правка .mdgit push → тело страницы обновилось. Главный незакрытый пробел.
    • E2 — conflict-marker gating (§9): vault в состоянии mid-merge → push заблокирован, маркеры <<<<<<< никогда не доезжают в Docmost (push.ts:1199-1209).
    • E3 — git-wire authz: anon→401, reader→403, member/admin→200 для clone/push (+ disabled-space→404 vs non-member→403).
    • (Параллельный push под удержанным локом → 503 + Retry-After + неизменный SHA vault — демотирован в integration, т.к. маппинг 503 покрываем ниже e2e.)
  • Test-data factories: переиспользовать test/integration/db.ts (createWorkspace/User/Space/Page); добавить сидер для GIT_SYNC_SERVICE_USER_ID и хелпер «сконструировать конфликтный vault» (паттерн из git.test.ts:371).

4. Обнаруженные антипаттерны

  • Over-mock / тавтологии: gitmost-datasource.service.spec (тело/SQL не доказываются — мок возвращает ассертируемое); space.repo.spec (SQL-toContain, change-detector); git-http.service.spec (findById игнорирует аргументы → BOLA-слепота).
  • Непокрытые security-catch: git-http.service (CASL throw, не-credential auth) — тихий fail-open-риск.
  • God-функция без guard: convertProseMirrorToMarkdown (~860 строк, нет depth-limit → RangeError).
  • Order-dependence by construction: layout.buildVaultLayout («первый побеждает») детерминирован только при стабильном порядке входа от listSpaceTree — корневая причина фантомных удалений.
  • as any-касты, прячущие контракт: gitmost-datasource.service.ts:314 (User/DTO в PageService).
  • Гигиена: jest.spyOn(require(...)) без restoreAllMocks; sleep/waitForTimeout и привязка к host-FS в e2e-скриптах (flaky, не CI-runnable).
  • Order-dependent тест: git-sync-e2e.sh шаг merge зависит от предыдущего шага push (общие маркеры).

5. Необходимые рефакторинги перед написанием тестов

  • R1 — убрать stub TiptapTransformer.toYdoc в unit-спеке datasource; ассерты тела перенести в integration (I1), на unit оставить только выбор ветки merge. Блокирует: M3-unit writeBody, I1.
  • R2 — мок spaceRepo.findById должен учитывать (spaceId, workspaceId) (фикс харнеса, не кода). Блокирует: M4 BOLA-тест.
  • R3 — bootable тест-сервер с инъекцией temp-GIT_SYNC_DATA_DIR + docmost_test на эфемерном порту. Блокирует: E1–E3.
  • R4 — хелпер «детерминированно сконструировать конфликтный/mid-merge vault». Блокирует: E2.
  • R5 (CI) — добавить services: postgres + redis в test.yml, подключить test:int (и e2e-lane). Разблокирует и 5 уже существующих .int-spec.ts.
  • R6 (опц.) — вынести extractRest/extractQueryString в helpers (фаззинг URL); вынести шов импорта loadGitSync.
  • R7 (опц.) — depth-guard в convertProseMirrorToMarkdown (если решено делать конвертер устойчивым, а не только задокументировать предел).

6. План внедрения (по фазам, ROI-обоснование)

  • Фаза 1 — дешёвые unit высокого риска (data-loss / security), без инфраструктуры.
    Engine fail-safe delete-cap (Infinity); layout детерминизм под перестановкой; orchestrator strict-opt-in/изоляция; agentSourceFields 'git-sync'; клиентский minimal-payload; HTTP-security unit'ы (traversal в spaceId, authz fail-open; BOLA после R2); конфиг-геттеры NaN/@IsIn. Плюс расширение §13.1 gate (контракт схемы). ROI: ловит именно классы багов, уже воспроизведённые вживую (потеря 9 страниц, vault «undefined», feedback-loop), правкой только тестов.
  • Фаза 2 — интеграционный фундамент (закрыть §13.3).
    R5 (CI: PG+Redis) → datasource↔БД: writeBody+3-way, CRUD (soft-delete/position/restore), SQL-семантика, space.repo jsonb non-clobber, page.repo source на поддереве, SpaceLock против реального Redis, контроллер под реальным guard+ValidationPipe. ROI: выводит из «слепых» единственный нетривиальный шов и инвариант single-writer.
  • Фаза 3 — E2E и остаток.
    R3/R4 → E1 committed round-trip (§13.4), E2 conflict-marker gating (§9), E3 git-wire authz; HTTP real-git path-scoping (LFI); параллельный push→503 (integration); добор lib/converter-unit'ов (image-asymmetry, depth, escaping, node-ops, diff order).
  • Примечание по бюджету пирамиды: integration ≈ 20 % с небольшим осознанным перевесом — фича критична к целостности данных, а §13.3 (поведение datasource↔БД) сейчас ≈ 0 %; правило явно допускает корректировку под контекст проекта. unit ≥ 70 % и E2E ≤ 10 соблюдены.

7. Источники

  • Отчёты 8 субагентов module-testability-analyst (M1–M8), прочитаны и сверены.
  • Независимая проверка покрытия: pnpm --filter @docmost/git-sync test604 pass + 1 expected-fail (42 файла). Серверный jest и клиентский vitest в worktree не запускаются (нет полной установки зависимостей: нет jest-бинаря; React не слинкован → падает и существующий ai-agent-badge.test.tsx) — это окружение, не дефект; источник истины — CI.
  • Фильтрация предложений: шаг 1 (cross-module dedup) слил datasource-integration M3≡M8, e2e-503 M3≡M8, authz-журнал M4≡M8; шаг 2 (skip-list) убрал DI-wiring, типы/DTO-декораторы, thin-репо-passthrough, литералы-константы; шаг 3 (пирамида) демотировал 503-журнал из E2E в integration; шаг 6 (adversarial) отбросил page-file determinism-by-double-call и removePage-mapping (уже покрыт). Итог: ~71 «сырых» → ≈46 выживших.

8. Открытые вопросы (требуют подтверждения у автора PR)

  1. Достижим ли 3-way baseMarkdown-путь? importPageMarkdown(…, baseMarkdown) объявлен и writeBody на нём ветвится, но ни один вызов в области PR не передаёт base. Если движок его не подаёт — весь 3-way (включая mergeXmlFragments3Way) сейчас мёртв в проде, хотя юнит-тестирован.
  2. Детерминизм порядка listSpaceTree — от него зависит корректность buildVaultLayout (иначе нужна сортировка по slugId/id в layout).
  3. parseGitPath: намеренно ли guard %2e|%2f не применяется к сегменту spaceId? Как выглядит уже декодированный Fastify путь, доходящий до парсера?
  4. editor-ext узлы вне switch конвертера (htmlEmbed/status/footnote/page-embed/transclusion) — намеренно не экспортируются в Markdown или просто не гейтятся?
  5. CI: допускает ли раннер services: postgres/redis? Без этого test:int (и новые datasource-тесты, и 5 существующих) в CI не идут.
> 🤖 Автоматический отчёт по тест-стратегии (test-suite-orchestrator), scoped к этому PR. Сгенерировано Claude Code. # Отчёт по тест-стратегии — gitmost / PR #119 (git-sync) — 2026-06-26 > Область анализа — **только изменения PR #119** «native two-way Docmost↔git Markdown sync» > (ветка `feat/git-sync` → `develop`, 198 файлов, +29586/−9843). Не весь монорепозиторий. > Анализ выполнен по worktree `.claude/worktrees/pr119-fixes` (HEAD `46109ad4`). > PR уже несёт обширные тесты; цель отчёта — найти **оставшиеся пробелы**, **слабые/тавтологичные** > тесты и закрыть осознанно отложенные автором §13.3 (интеграция datasource↔БД) и §13.4 (e2e в репо). ## 1. Исполнительное резюме - **Проанализировано модулей:** 8 (по одному субагенту `module-testability-analyst` на модуль). - **Предложено тестов (после дедупликации и фильтров): ≈46** — unit/component **32** · integration **9** · contract **2** · E2E **3**. - **Отклонено/слито как малоценные или дублирующие:** ≈25 «сырых» предложений аналитиков. - **Соблюдение пирамиды:** unit ≈ 70 % ✓; E2E = 3 (абсолютный лимит ≤ 10) ✓; integration ≈ 20 % с осознанным небольшим перевесом (фича критична к целостности данных — см. §6). - **Покрытие сейчас (независимо проверено):** - `@docmost/git-sync` (vitest): **604 pass + 1 expected-fail, 42 файла** — подтверждено локальным прогоном. (Цифра в описании PR «431 pass» устарела.) - Серверный jest (заявлено 723 pass) и клиентский vitest **не удалось перепрогнать в worktree**: там нет полной установки зависимостей (нет бинаря `jest`; React не слинкован в client → даже **существующий** тест `ai-agent-badge.test.tsx` падает с `null (reading 'useState')`). Это артефакт окружения, **не дефект тестов PR**. Источник истины по этим слоям — CI. - **Прогноз:** чистые слои (lib/engine ~85–90 % по оценке аналитиков) уже сильны; главный незакрытый риск — **поведенческое покрытие шва datasource↔БД ≈ 0 %** (всё замокано) и **гейт §13.1 покрывает ~половину видов узлов схемы**. Внедрение плана выводит эти две зоны из «слепых». --- ## 2. Рекомендации по модулям ### M1. `packages/git-sync/src/lib` — чистые конвертеры (ProseMirror↔Markdown) Покрыт лучше всех (605 тестов). Шов схемы §13.1. - **Unit добавить:** - `markdown-converter.ts:convertProseMirrorToMarkdown` — **top-level `image` теряет width/height/align** (внутри columns `imageToHtml` их сохраняет — асимметрия). Зафиксировать `md1` и `docsCanonicallyEqual===false`. Ловит «починили один путь — забыли второй». - тот же символ, ветка `default` (L584-586): **неизвестный узел верхнего уровня** молча сводится к тексту детей. Pin деградации без падения. - **глубина рекурсии**: подтверждён `RangeError` (~5000 уровней), нет depth-guard/try-catch (в отличие от `diffDocs`). Зафиксировать границу либо завести guard (R7). - **идемпотентность экранирования** в атрибутах (`math`) на 3 циклах — нет накопления `&`→`&amp;`. - `node-ops.ts:insertNodeRelative` — два `throw` (append `tableRow` на верхнем уровне; anchor вне `tableRow`) не покрыты. - `node-ops.ts:replaceNodeById/deleteNodeById` — **дублирующийся `attrs.id`**: оба заменяются/удаляются, без рекурсии в подставленный узел. - `diff.ts:diffDocs` — детерминизм порядка `changes` (иначе фантомный git-churn). - **НЕ тестировать:** `docmost-schema` parseHTML/renderHTML (третья сторона — TipTap); `index.ts` ре-экспорт; `sanitizeCssColor`/`clampCalloutType` (уже исчерпывающе); `page-file` determinism-by-double-call (тавтология). ### M2. `packages/git-sync/src/engine` — reconcile/layout/pull/push/cycle/VaultGit - **Unit добавить (высокий ROI):** - `cycle.ts:runCycle` — **fail-safe delete-cap**: при исключении в dry-run в hook должен уйти `Infinity` (отложить удаления). Сейчас тестируется только `deletes===0`. Это ровно та ветка, что защищает от инцидента «9 реальных страниц soft-удалены». - `layout.ts:buildVaultLayout` — **детерминизм при перестановке входа** (два сиблинга с одинаковым заголовком; orphan-bucket; folder-note vs child). Корневая причина «фантомных absence-удалений» (автор её НЕ чинил) — pin инвариант «стабильно под перестановкой» (не «правильное имя»). - `push.ts:isPageFile` — `.MD` (верхний регистр) → false: намеренно ли (case-insensitive ФС)? 1 кейс + open-question. - **Integration:** см. M3/M8 (real-git temp-repo уже есть: `run-push-realgit.test.ts`, `git.test.ts`). - **НЕ тестировать:** приватные `VaultGit.run/isRepo`; `loop-guard.bodyHash` (обёртка SHA, покрыта); `stabilize.*` (композиция конвертеров, покрыта ниже). **Политика cap (значение, off-by-one, cap=0) живёт в оркестраторе** — см. M3, не дублировать здесь. ### M3. `apps/server/.../git-sync/services` + `listeners` — оркестрация и доменные сервисы - **Unit добавить:** - `lcs.ts:buildLcsTable` — нет своего спека (off-by-one таблицы DP). - `three-way-merge.ts:diff3Plan` — провенанс/якоря из `live` (сохранение инстансов блоков) + **дублирующиеся ключи блоков** (повторяющиеся пустые абзацы — реальный случай). - `yjs-body-merge.ts:mergeXmlFragments3Way` — base==live==target (0 операций), вложенные блоки (list>item), стабильность `serializeXmlNode`. - `git-sync.orchestrator.ts:enabledSpaces/pollTick` — **строгий per-space opt-in (НЕТ all-spaces fallback)** + изоляция: падение одного спейса не ломает остальные. Регрессия здесь — тихая (всё в `catch`). - `page-change.listener.ts` — **независимость таймеров по спейсам** + перевзвод debounce после срабатывания. - **Integration (это и есть отложенный §13.3):** - `GitmostDataSourceService.writeBody` против **реального collab + БД**: провенанс `git-sync`/serviceUser, корректность тела, **3-way: правка человека выживает**. Текущий unit-спек **тавтологичен** (мокает `TiptapTransformer`, ассерт `length>0`). - CRUD vs реальная БД: `createPage` (атрибуция serviceUser), `deletePage` (**soft, не force**), `move/rename` (fractional position после последнего сиблинга), `restore`. - SQL-семантика: `listRecentSince` (`>` а не `>=`), `listTrash` (`deletedAt is null`), `computeMovePosition` (`collate('C')` — сейчас даже не ассертится). - `SpaceLockService` — **контеншн двух «реплик» против реального Redis/ioredis-mock**: CAS-release НЕ удаляет чужой лок; потеря лока по TTL. Центральный инвариант single-writer. - **Contract:** форма `GitSyncClient` (`bind()`) ↔ интерфейс движка (ловит дрейф шва). - **НЕ тестировать:** `bind()` closure-passthrough; `onModuleInit` регистрация интервала (framework wiring, один тест есть); REST-плейсхолдеры `buildSettings`. - **Антипаттерны:** тавтологичный datasource-спек (мок возвращает ассертируемое); `as any` каст `User`/DTO в `renamePage` (L314) прячет дрейф контракта PageService; `catch`-проглатывание в `pollTick`/listener. ### M4. `apps/server/.../git-sync/http` — Git Smart-HTTP backend (безопасность!) - **Unit добавить (security):** - `parseGitPath` — **encoded/NUL traversal в сегменте spaceId**: regex `%2e|%2f` применяется только к `subpath`, НЕ к `first`/spaceId (а именно он выбирает репозиторий и идёт в `findById`/PATH_INFO). **HIGH.** - `GitHttpService.handle` — **BOLA: кросс-spaces/кросс-workspace**: `findById` вернул null/другой spaceId → 404, backend не запущен, наружу уходит **resolved** `space.id`, не сырой из URL. **HIGH** (нужен R2). - `handle` — `CASL createForUser` бросает / не-credential ошибка auth → 403/401, **не 500 и не fail-open**. - `resolveServiceKind` — спуфинг `service`/регистр метода: write не должен классифицироваться как read. - `decideGitHttpGate` — `serviceKind=null` без креденшелов → 401 раньше 400 (оракул статуса). - `buildGitBackendCgiEnv` + `parseCgiResponse` — инъекция в CGI-env/заголовки (CR/LF, response splitting). - `run` — **POST-тело стримится в stdin** + client-abort/EPIPE (весь stdin-путь не тестируется — все тесты GET). - **Integration:** реальный `git http-backend` в temp-репо — **path-scoping/LFI** (краденый PATH_INFO не выходит за GIT_PROJECT_ROOT); round-trip `info/refs`; большой pack стримом; watchdog (SIGTERM→SIGKILL). - **НЕ тестировать:** типы; `headerValue` passthrough; DI-обвязка; `mcp-auth.helpers` (свой спек). - **Антипаттерн:** мок `findById` игнорирует аргументы → изоляция «проверяется» только по call-args (тавтология); непокрытые `catch`-ветки безопасности. ### M5. `apps/server/.../git-sync` wiring + `environment` (конфиг/контроллер) - **Unit добавить:** `isGitSyncHttpEnabled` (ветка наследования от master-switch); `getGitSyncBackendTimeoutMs` (NaN/0/отриц → дефолт — тот же класс бага parseInt/NaN, иначе watchdog не сработает и лок держится вечно); `GIT_SYNC_HTTP_ENABLED` `@IsIn` (опечатка булевого env у оператора). - **Integration:** `GitSyncController` через реальный `JwtAuthGuard` + `ValidationPipe(whitelist)` — 401 без JWT / 403 не-админ / 200 админ; **`@IsUUID() spaceId` не вырезается whitelist-пайпом** (прежний баг «vault назван undefined»). Unit-спек контроллера хорош, но guard'ы он не проверяет. - **НЕ тестировать:** `git-sync.module.ts` (DI wiring); `git-sync.constants.ts` (литералы — change-detector); приватный `assertAdmin` (покрыт через публичные); passthrough-геттеры без ветвления. - `loadGitSync` (memo/retry) — опционально, и только если шов импорта тестируем без рефактора R6. ### M6. Серверные core-правки (provenance / page·space services·repos / collab persistence) - **Unit добавить:** `auth-provenance.decorator.ts:agentSourceFields` — **ветка `'git-sync'` опускает chat-ключ** (иначе фантомный `lastUpdatedAiChatId`, ломается loop-guard) — это буквально новая строка PR, на уровне хелпера не покрыта. `space.service.ts:updateSpace` — gitSync пишется независимо от соседних флагов (sharing/comments) в одной транзакции. - **Integration (real PG):** `space.repo.ts:updateGitSyncSettings` — **jsonb-merge НЕ затирает соседей** (`sharing`, вложенный `gitSync.*`); текущий спек лишь `toContain("COALESCE")` (change-detector, не доказывает merge). `page.repo.ts:removePage/restorePage` — `lastUpdatedSource='git-sync'` проставляется **на всё поддерево** (рекурсивный UPDATE) — иначе echo-цикл в git. - **Contract (важно):** **§13.1 gate — добить корпус.** Конвертер обрабатывает ~50 видов узлов, гейт прогоняет ~25. Без покрытия: `attachment/audio/video/youtube/drawio/excalidraw/embed/pdf` (HTML-путь с `data-width/align`), `columns/column`, `subpages/pageBreak/hardBreak/underline/comment`. Узлы editor-ext `htmlEmbed/status/footnote/page-embed/transclusion` конвертер даже не упоминает — задокументировать их статус (зелёный / known-divergence / намеренно не экспортируется). **Высший приоритет для контракта схемы.** - **НЕ тестировать:** thin-passthrough query-helpers репозиториев (`withSpace/withCreator/...`); `jwt-payload.ts`/`update-space.dto.ts` (типы/декораторы — третья сторона; поведение whitelist живёт в `ValidationPipe`, покрывается интеграцией M5). - **Антипаттерн:** SQL-string-ассерты `space.repo.spec` (ломаются на апгрейде Kysely, merge не доказывают); `jest.spyOn(require(...))` без `restoreAllMocks`. ### M7. Клиент (React) — тоггл per-space + бейдж истории Оба новых теста (`history-item.test.tsx`, `edit-space-form.test.tsx`) — **поведенческие** (ассертят payload и условный рендер по source, есть adversarial-контраст), не тавтологичные снапшоты. Главный пробел — **минимальность payload**. - **Component добавить:** тоггл шлёт **ровно `{spaceId, gitSyncEnabled}`** без name/slug/description (риск затирания настроек); тоггл OFF шлёт `false`; `git-sync-badge` с `authorName=undefined` не рендерит литерал "undefined". - **Integration:** `EditSpaceForm` + реальный `useUpdateSpaceMutation` + `QueryClientProvider` — `onSuccess` merge кэша (`{...space, ...data}`) **не теряет** `settings.sharing/comments`. - **НЕ тестировать / E2E:** визуальные props бейджа; внутренности Mantine; permission-derivation (живёт в родителе). E2E здесь не нужен (журнал покрыт на server/full-stack уровне). - Мелкий смелл: `.toBeTruthy()` после `getByText` (предпочесть `toBeInTheDocument`); связка с CSS-классом Mantine. ### M8. E2E-харнесс / CI / runtime 3 скрипта (`git-sync-e2e.sh`, `…-advanced.sh`, `…-browser-e2e.cjs`) **ассертят, но не CI-runnable** (живой стенд, `sleep 2`, доступ к host-`$VAULT_DIR`, Redis-инъекция лока) и **не вызываются ни из одного workflow** — это и есть §13.4. Готовые «кирпичи» уже есть: `test/integration/{global-setup,db}.ts` + `jest-integration.json` (миграции `docmost_test`, pgvector) и real-git паттерн в пакете. - **E2E (committed, детерминированные):** см. §сквозные. - **CI-пробелы:** `test.yml` гоняет только `pnpm -r test` (unit/spec). **Нет `services: postgres/redis`** → 5 существующих `.int-spec.ts` тоже не идут в CI; нет шага `test:int`/`test:e2e`; нет gate по покрытию. - **НЕ тестировать:** `jest.setup.ts`, `.env.example`, `Dockerfile apt-get git` (smoke — в релиз-пайплайн); `…browser-e2e.cjs` оставить ручным (покрыто детерминированно на data-слое). --- ## 3. Сквозные аспекты - **Contract-тесты:** (1) §13.1 gate — расширить корпус до всех видов узлов (M6); (2) форма `GitSyncClient` ↔ движок (M3). Между сервисами per se контрактов нет — «контракт» здесь это git Smart-HTTP wire-протокол (покрывается e2e ниже). - **Property-based:** уже применяется (`markdown-roundtrip.property.test.ts`, fast-check) — образцово; расширять только при добавлении новых видов узлов. - **E2E user journeys (всего 3, лимит ≤10):** - **E1 — committed round-trip (§13.4):** `git clone` → правка `.md` → `git push` → тело страницы обновилось. Главный незакрытый пробел. - **E2 — conflict-marker gating (§9):** vault в состоянии mid-merge → push заблокирован, маркеры `<<<<<<<` **никогда** не доезжают в Docmost (`push.ts:1199-1209`). - **E3 — git-wire authz:** anon→401, reader→403, member/admin→200 для clone/push (+ disabled-space→404 vs non-member→403). - (Параллельный push под удержанным локом → 503 + Retry-After + неизменный SHA vault — **демотирован в integration**, т.к. маппинг 503 покрываем ниже e2e.) - **Test-data factories:** переиспользовать `test/integration/db.ts` (`createWorkspace/User/Space/Page`); добавить сидер для `GIT_SYNC_SERVICE_USER_ID` и хелпер «сконструировать конфликтный vault» (паттерн из `git.test.ts:371`). --- ## 4. Обнаруженные антипаттерны - **Over-mock / тавтологии:** `gitmost-datasource.service.spec` (тело/SQL не доказываются — мок возвращает ассертируемое); `space.repo.spec` (SQL-`toContain`, change-detector); `git-http.service.spec` (`findById` игнорирует аргументы → BOLA-слепота). - **Непокрытые security-`catch`:** `git-http.service` (CASL throw, не-credential auth) — тихий fail-open-риск. - **God-функция без guard:** `convertProseMirrorToMarkdown` (~860 строк, нет depth-limit → `RangeError`). - **Order-dependence by construction:** `layout.buildVaultLayout` («первый побеждает») детерминирован только при стабильном порядке входа от `listSpaceTree` — корневая причина фантомных удалений. - **`as any`-касты, прячущие контракт:** `gitmost-datasource.service.ts:314` (`User`/DTO в PageService). - **Гигиена:** `jest.spyOn(require(...))` без `restoreAllMocks`; `sleep`/`waitForTimeout` и привязка к host-FS в e2e-скриптах (flaky, не CI-runnable). - Order-dependent тест: `git-sync-e2e.sh` шаг merge зависит от предыдущего шага push (общие маркеры). ## 5. Необходимые рефакторинги перед написанием тестов - **R1** — убрать stub `TiptapTransformer.toYdoc` в unit-спеке datasource; ассерты тела перенести в integration (I1), на unit оставить только выбор ветки merge. Блокирует: M3-unit `writeBody`, I1. - **R2** — мок `spaceRepo.findById` должен учитывать `(spaceId, workspaceId)` (фикс харнеса, не кода). Блокирует: M4 BOLA-тест. - **R3** — bootable тест-сервер с инъекцией temp-`GIT_SYNC_DATA_DIR` + `docmost_test` на эфемерном порту. Блокирует: E1–E3. - **R4** — хелпер «детерминированно сконструировать конфликтный/mid-merge vault». Блокирует: E2. - **R5 (CI)** — добавить `services: postgres + redis` в `test.yml`, подключить `test:int` (и e2e-lane). Разблокирует и 5 уже существующих `.int-spec.ts`. - **R6 (опц.)** — вынести `extractRest/extractQueryString` в helpers (фаззинг URL); вынести шов импорта `loadGitSync`. - **R7 (опц.)** — depth-guard в `convertProseMirrorToMarkdown` (если решено делать конвертер устойчивым, а не только задокументировать предел). ## 6. План внедрения (по фазам, ROI-обоснование) - **Фаза 1 — дешёвые unit высокого риска (data-loss / security), без инфраструктуры.** Engine fail-safe delete-cap (`Infinity`); `layout` детерминизм под перестановкой; orchestrator strict-opt-in/изоляция; `agentSourceFields 'git-sync'`; клиентский minimal-payload; HTTP-security unit'ы (traversal в spaceId, authz fail-open; BOLA после R2); конфиг-геттеры NaN/`@IsIn`. **Плюс расширение §13.1 gate** (контракт схемы). ROI: ловит именно классы багов, уже воспроизведённые вживую (потеря 9 страниц, vault «undefined», feedback-loop), правкой только тестов. - **Фаза 2 — интеграционный фундамент (закрыть §13.3).** R5 (CI: PG+Redis) → datasource↔БД: writeBody+3-way, CRUD (soft-delete/position/restore), SQL-семантика, `space.repo` jsonb non-clobber, `page.repo` source на поддереве, `SpaceLock` против реального Redis, контроллер под реальным guard+ValidationPipe. ROI: выводит из «слепых» единственный нетривиальный шов и инвариант single-writer. - **Фаза 3 — E2E и остаток.** R3/R4 → E1 committed round-trip (§13.4), E2 conflict-marker gating (§9), E3 git-wire authz; HTTP real-git path-scoping (LFI); параллельный push→503 (integration); добор lib/converter-unit'ов (image-asymmetry, depth, escaping, node-ops, diff order). - **Примечание по бюджету пирамиды:** integration ≈ 20 % с небольшим осознанным перевесом — фича критична к целостности данных, а §13.3 (поведение datasource↔БД) сейчас ≈ 0 %; правило явно допускает корректировку под контекст проекта. unit ≥ 70 % и E2E ≤ 10 соблюдены. ## 7. Источники - Отчёты **8** субагентов `module-testability-analyst` (M1–M8), прочитаны и сверены. - Независимая проверка покрытия: `pnpm --filter @docmost/git-sync test` → **604 pass + 1 expected-fail (42 файла)**. Серверный jest и клиентский vitest в worktree не запускаются (нет полной установки зависимостей: нет `jest`-бинаря; React не слинкован → падает и существующий `ai-agent-badge.test.tsx`) — это окружение, не дефект; источник истины — CI. - Фильтрация предложений: шаг 1 (cross-module dedup) слил datasource-integration M3≡M8, e2e-503 M3≡M8, authz-журнал M4≡M8; шаг 2 (skip-list) убрал DI-wiring, типы/DTO-декораторы, thin-репо-passthrough, литералы-константы; шаг 3 (пирамида) демотировал 503-журнал из E2E в integration; шаг 6 (adversarial) отбросил `page-file` determinism-by-double-call и `removePage`-mapping (уже покрыт). Итог: ~71 «сырых» → **≈46** выживших. ## 8. Открытые вопросы (требуют подтверждения у автора PR) 1. **Достижим ли 3-way `baseMarkdown`-путь?** `importPageMarkdown(…, baseMarkdown)` объявлен и `writeBody` на нём ветвится, но ни один вызов в области PR не передаёт base. Если движок его не подаёт — весь 3-way (включая `mergeXmlFragments3Way`) сейчас **мёртв в проде**, хотя юнит-тестирован. 2. **Детерминизм порядка `listSpaceTree`** — от него зависит корректность `buildVaultLayout` (иначе нужна сортировка по slugId/id в layout). 3. **`parseGitPath`**: намеренно ли guard `%2e|%2f` не применяется к сегменту spaceId? Как выглядит уже декодированный Fastify путь, доходящий до парсера? 4. **editor-ext узлы вне switch конвертера** (`htmlEmbed/status/footnote/page-embed/transclusion`) — намеренно не экспортируются в Markdown или просто не гейтятся? 5. **CI**: допускает ли раннер `services: postgres/redis`? Без этого `test:int` (и новые datasource-тесты, и 5 существующих) в CI не идут.
Ghost force-pushed feat/git-sync from 68515ef947 to 142ed3a825 2026-06-26 00:20:58 +03:00 Compare
Ghost added 1 commit 2026-06-26 01:29:32 +03:00
A 10-agent red-team pass on the two-way Docmost<->git sync surfaced 16 ranked
findings (9 others triaged out as already-defended). Wrote a reproduction test
per finding (each asserts the CORRECT behavior, so it fails on the bug), then
fixed the production code so every repro goes green. All confirmed bugs:

Round-trip data loss (markdown-converter.ts + docmost-schema.ts mirror):
- #1 editor-ext node types silently dropped on export — ported the 8 missing
  canon nodes (footnoteReference/footnotesList/footnoteDefinition, htmlEmbed,
  status, pageEmbed, transclusionSource/Reference) into the git-sync schema
  mirror and added converter cases that emit their schema-matching HTML instead
  of flattening unknown nodes to '' (this was the critical data-loss flagged in
  review #1679: footnotes/htmlEmbed lost on sync). Snapshot surface updated.
- #2 top-level image lost width/height/align/attachmentId — now emits an HTML
  <img> (like video/diagrams) when it carries layout attrs; bare images stay
  ![](src). Image node parses width/height as strings so they re-import.
- #3 code block containing a ``` fence corrupted on round-trip — outer fence is
  now widened to (longest-inner-backtick-run + 1).
- #16 deep nesting threw RangeError (page never synced) — added a depth guard
  (MAX_NODE_DEPTH=400) so the converter never overflows the stack.

Push/layout/cycle (engine):
- #4 disambiguation ' ~slugId' suffix corrupted Docmost titles + order-dependent
  layout — deterministic, order-independent sibling disambiguation; suffix is
  stripped from a path-derived title ONLY when the new name is exactly the old
  title plus the suffix (never a genuine retitle ending in ' ~token').
- #6 retry-adopt by (parent,title) clobbered the wrong duplicate-title sibling —
  ambiguous (parent,title) is no longer adopted (falls back to fresh create).
- #12 a new child under a new parent was created at ROOT — creates are ordered
  parent-before-child with an in-memory created-id map for parent resolution.
- #13 git conflict markers could reach Docmost — bodies are scanned and the
  marker lines stripped (a '=======' line is only treated as a conflict
  separator inside a <<<<<<< ... >>>>>>> block, so setext headings are safe).
- #15 a divergent `docmost` mirror was escalated by runPush but dropped by
  runCycle — RunCycleResult now forwards divergentDocmost to the orchestrator.

Server (merge / lock / provenance):
- #9 3-way merge lost a human's block edit when git inserted an adjacent block —
  finer-grained diff3 region merge (via lcs) preserves non-overlapping human
  edits; genuine same-block conflicts still resolve git-wins.
- #10 single-writer race — module-static liveLocks closes the same-process TOCTOU
  window, and a heartbeat refresh that cannot confirm the lock now aborts the
  cycle at its next write checkpoint (cooperative AbortSignal threaded through
  runCycle). Cross-process fencing tokens remain a follow-up.
- #14 sticky-agent provenance overrode an explicit actor='git-sync' write,
  blinding the listener loop-guard — resolveSource now lets an explicit actor
  win over the sticky-agent fallback (explicit agent still wins).

Verified: git-sync vitest 617 pass (+1 expected-fail), server unit jest 1541
pass, server tsc clean. A review pass over the fixes caught and corrected a
title-suffix over-strip, an inert abort signal, a document-wide conflict-marker
strip, and two leaf-atom content-holes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added 1 commit 2026-06-26 01:34:28 +03:00
Address the non-red-team documentation/cleanup items from review #1679:
- Document the GIT_SYNC_BACKEND_TIMEOUT_MS watchdog (git http-backend) in
  .env.example and add it to the environment validation schema — it was used
  (getGitSyncBackendTimeoutMs, default 120000) but undocumented/unvalidated.
- Remove the dead GIT_SYNC_DEBOUNCE_MS_DEFAULT / GIT_SYNC_POLL_INTERVAL_MS_DEFAULT
  exports (never imported; environment.service is the single source of defaults).
- Redirect the dangling `plan §X.Y` comment references to issue #194 (the
  git-sync spec moved there when docs/git-sync-plan.md was deleted by this PR).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added 1 commit 2026-06-26 01:57:12 +03:00
Red-team #13 (conflict markers reaching Docmost) is now a per-space policy
exposed as a UI toggle, instead of a hardcoded behavior. New boolean
`gitSync.autoMergeConflicts` (default FALSE), mirroring the existing per-space
`gitSync.enabled` flag end-to-end (jsonb space settings -> update-space DTO ->
space.service -> client types -> space settings form switch):

- OFF (default, safe): a page whose committed body still has unresolved git
  conflict markers is NOT pushed — it is recorded as a per-page push FAILURE
  ("unresolved conflict markers — resolve in git first"). Recording a failure
  (not a soft skip) deliberately HOLDS refs/docmost/last-pushed so the conflict
  commit is never marked pushed and a later pull cannot clobber the user's
  in-progress resolution; the page retries until the conflict is resolved in git.
- ON: the marker lines are stripped and both sides' content is pushed (the prior
  behavior), so the conflict becomes visible/fixable inside Docmost.

The engine Settings carries `autoMergeConflicts`; runPush threads it into the
update AND create paths. The orchestrator's buildSettings reads the per-space
flag from jsonb (strict opt-in like `enabled`, default false).

Tests: redteam-push-cycle #13 rewritten (default -> not pushed + failure + refs
held; ON -> strip-and-push); space.service + edit-space-form + orchestrator
specs extended. git-sync vitest 618, server jest space+git-sync 163, client
edit-space-form 11, server/client tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added 1 commit 2026-06-26 03:21:33 +03:00
A git push is a two-request exchange: GET info/refs?service=git-receive-pack
(ref advertisement) then POST git-receive-pack (the pack). The git-HTTP host
classified BOTH as serviceKind 'write' and routed both through
ingestExternalPush, which takes the per-space lock and runs a FULL Docmost
reconcile cycle. So the read-only info/refs advertisement held the lock while a
cycle ran, and the client's immediately-following POST git-receive-pack collided
with that still-running cycle and got 503 — deterministically, every push (and
Obsidian Git's "scan" failed for the same reason, since it probes push
capability via the same receive-pack info/refs).

Fix: only the actual pack-receiving write (POST git-receive-pack) runs under the
lock + cycle. Everything else streams the http-backend directly with no lock and
no cycle — a fetch/clone (read) AND the write-AUTHORIZED but read-only
info/refs?service=git-receive-pack advertisement. Authz is unchanged (the gate
still requires write permission for receive-pack refs); only the side effect of
running a cycle on a read-only request is removed.

Verified end-to-end on a live stand: clone, then `git push` of a new file lands
the page in Docmost (was 503 on every push before). Regression test added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added 1 commit 2026-06-26 03:41:55 +03:00
subpages exported to the literal `{{SUBPAGES}}`, which has no markdown/HTML
inverse, so on re-import it came back as a plain paragraph holding the visible
text "{{SUBPAGES}}" — the embed rendered as that literal string on the page
after a sync (round-trip data loss, seen live). It now emits the schema-matching
`<div data-type="subpages">` like every other embed node, so the schema's
parseHTML rebuilds the subpages node. Also dropped the leaf-atom content-hole
in the subpages renderHTML.

New committed regression coverage:
- packages/git-sync/test/roundtrip-all-nodes.test.ts — exhaustive serialize ->
  deserialize round trip for ALL 40 node/mark types; each asserts the node/mark
  survives and no `{{...}}` literal leaks. This is the test that caught subpages.
- §13.1 gate (git-sync-converter-gate.spec.ts): subpages added to the green
  corpus (round-trips through the REAL server schema).
- Corrected two PR-authored tests that asserted the old {{SUBPAGES}} loss as
  "by design" — they now assert the fixed round trip.

Also folds in review #1679 coverage-gap tests (no prod change): orchestrator
pollTick/enabledSpaces, datasource 3-way merge dispatch, page.repo
last_updated_source provenance SQL.

git-sync vitest 659 (+1 expected-fail), server tsc clean, server specs green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added 2 commits 2026-06-26 03:59:48 +03:00
Found proactively by deepening the round-trip test from node-TYPE survival to
ATTRIBUTE fidelity (distinctive attr values per node). Two real losses (the
other 3 candidates — mathInline/mathBlock/pageEmbed — were verified to be
correct; the probe had used wrong attr names):

- subpages `recursive`: the converter emitted a bare div and the schema mirror
  didn't model the attr, so a recursive subpages reverted to non-recursive on a
  round trip. Now emits `data-recursive="true"` and the mirror parses it back
  (matching @docmost/editor-ext).
- details `open`: the `open` (collapsed/expanded) state lives on the details
  node, but the converter emitted the `<details>` wrapper from the summary case
  without it, so the state was dropped. The wrapper now carries `open`.

The round-trip test now also asserts attribute fidelity (12 cases) so these are
locked. Schema-surface snapshot updated for the new subpages attr.

git-sync vitest 671 (+1 expected-fail), §13.1 gate 27.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The delete cap (GIT_SYNC_MAX_DELETES_PER_CYCLE, default 5) was a defense-in-depth
guard that SUPPRESSED a cycle's deletions when the planned count exceeded the
limit. In practice it was a crutch over engine correctness that also blocked
legitimate deletes: deleting a folder with many child pages is a normal action,
and git-sync deletes are SOFT (Trash, reversible), so a blocking limit has little
upside and real downside. There is also no user-facing surface to "confirm" a
large delete from a background sync — the only channel is the operator log.

So: drop the cap entirely. Deletes apply unconditionally; every cycle already
logs its full push plan, per-action `delete: <pageId>` lines, and completion
counts through the engine `log`, so what was deleted (and what was skipped) is
always recorded. Engine correctness (the reconcile/layout/round-trip tests) is
what prevents phantom deletions — not a blocking cap.

Removed: orchestrator `resolveApplyClient` cap hook + `maxDeletes`,
`getGitSyncMaxDeletesPerCycle`, the `GIT_SYNC_MAX_DELETES_PER_CYCLE` env/validation/.env.example,
and the cap tests. (The engine's generic optional `resolveApplyClient` hook is
left as an unused extension point.)

server tsc clean, git-sync + environment jest 174.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added 1 commit 2026-06-26 04:22:52 +03:00
Callouts now export as Obsidian's blockquote-callout syntax — `> [!type]` opener
plus a `>`-prefixed body — so they render as real callouts when the vault is
opened in Obsidian, instead of `:::type` (Docusaurus-style) which Obsidian shows
as a plain blockquote.

- Export (markdown-converter `case "callout"`): `> [!type]` + each body line
  blockquote-prefixed (a blank line becomes a bare `>` so the callout is not
  split). Nested callouts naturally become `> > [!type]`.
- Import (preprocessCallouts): a new branch recognizes `> [!type]` openers and
  the contiguous `>`-prefixed body, strips one blockquote level and recurses (so
  nested callouts work), emitting the same callout div the `:::` path produces.
  The legacy `:::type` parser is KEPT so existing vaults keep importing. A plain
  blockquote (no `[!type]`) stays a blockquote.

Tests: 4 converter golden tests updated to the new `> [!type]` output; 4 new
import tests (simple, nested, round-trip, plain-blockquote-untouched). The §13.1
gate still round-trips callout losslessly through the real server schema.
git-sync vitest 675 (+1 expected-fail), gate 27.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added 3 commits 2026-06-26 08:26:58 +03:00
The block-level body merge keyed each block by its full attribute set,
including the per-block UniqueID the editor stamps on every heading/paragraph.
A body arriving from git is parsed from clean markdown and carries no block
ids, so a live block (id present) never matched the same block coming from git
(no id). The three-way merge's LCS could not anchor on it, and an incoming
block with no matching anchor — content inserted at the TOP of the page — was
re-added on every push/pull cycle: a non-convergent, unbounded duplication loop.

Exclude the volatile 'id' attribute from the block comparison key
(serializeXmlNode) so blocks compare by content across the git round-trip.
The merge keeps the live block INSTANCE (and its id, and any in-flight edit)
for an anchor — picks are by index, not key — so identity is preserved while
reconciliation becomes idempotent. Mirrors canonicalize.ts, which already
strips the regenerated block id from the round-trip idempotency comparison.

Adds a RED-before-fix repro modelling the live-id vs git-no-id asymmetry and
asserting no block growth across cycles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A git push to a page with an OPEN editor was silently reverted: the git
commit landed and the DB body updated, but the page in the browser stayed
on the old content and the editor's next autosave overwrote the git change.

Root cause (distributed, not in the merge): writeBody applied the body
merge via collabGateway.openDirectConnection on whichever instance/process
runs git-sync (the api/worker). When an editor is connected to a DIFFERENT
collab instance/process, that opens a SEPARATE, detached Y.Doc. The merge
landed in the detached doc + DB, but the live editor's Y.Doc never received
the Yjs update; its debounced autosave then persisted its STALE state over
the DB, reverting the git change (and, for concurrent edits to different
paragraphs, losing the git side). In one process the bug is invisible
because the direct connection already shares the editor's doc.

Fix: route the body write through the existing custom-event channel (the
same mechanism comment-marks and updatePageContent use) so the merge runs
on the instance that OWNS the live doc. Its update is then broadcast to
every connection (Document.handleUpdate) and the editor's CRDT converges on
the merged result. New CollaborationGateway.writePageBody dispatches to a
new gitSyncWriteBody handler (builds incoming/base docs before opening the
connection — crash-safe — then 3-way/2-way merges into the live fragment);
without redis it runs locally on the single (owning) instance. writeBody
now just forwards the converted ProseMirror bodies + service userId.

Evidence:
- git-ingest-convergence.spec.ts: deterministic two-Y.Doc repro. PATH B
  (undelivered update) asserts the LOSS (the bug); PATH A (update delivered,
  as the owner-routed write does) asserts the git change SURVIVES and that
  concurrent edits to different paragraphs both survive.
- collaboration.handler.git-sync.spec.ts: exercises the real gitSyncWriteBody
  against a shared doc wired to a connected "editor" doc (models the
  owning-instance broadcast) — editor converges, concurrent edit preserved,
  crash-safe on transform failure.
- gitmost-datasource.service.spec.ts: writeBody now routes via writePageBody
  (RED before this change — it called openDirectConnection).

Honest scope: the failure is cross-instance; full multi-instance convergence
needs a live Hocuspocus + redis and is not provable in a unit test, so the
convergence invariant is captured at the Yjs update-exchange level.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a git-sync body write (gitSyncWriteBody) is routed to the collab instance
that owns the doc, the handler runs remotely inside handleRedisMessage and CAN
throw (markdown->ProseMirror transform). Previously the throw was uncaught: the
customEventComplete reply was never published, so the origin's writePageBody
promise only rejected after customEventTTL (~30s) as a generic 'TIMEOUT', and an
unhandledRejection escaped the async messageBuffer listener on the owning
instance.

Now the owner wraps handleEventLocally in try/catch and, on throw, publishes a
customEventComplete carrying an `error` field on the same correlation channel.
The origin's pendingReplies holds {resolve, reject} and rejects promptly with the
real Error. The TTL TIMEOUT remains as the fallback for a genuinely lost reply.
The no-throw and local (same-instance) paths are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added 2 commits 2026-06-26 15:54:35 +03:00
The live Yjs document materializes the editor-schema default `indent: 0` on
every paragraph/heading (and on the paragraph inside every list item, callout,
and table cell), but a body re-imported from git — parsed from clean markdown —
carries no indent attribute. So every live block's merge key differed from the
same block coming back from git: the three-way merge could anchor on nothing,
and the trailing unit that git's export already contained (but the merge could
not match against the byte-identical live tail) was re-appended on every
reconcile cycle. Each grown export then diverged from the last-pushed base by one
more unit — a self-sustaining, unbounded whole-body duplication loop with no
client connected.

The prior fix (0c7b73f7) excluded the volatile block `id` from the key, which
was necessary but not sufficient: `indent: 0` is a CONTENT attribute the editor
stamps as a default, so it was never stripped. Normalize editor-materialized
schema defaults (`indent: 0`) out of the block key — only the default value, so a
genuine `indent: 2` still diffs and lands — so a live block compares equal to its
git-round-tripped twin and the resync is a true no-op.

Regression test (yjs-body-merge.idempotency.spec.ts) encodes the invariant on a
body of byte-identical units (heading + paragraph + callout + table with empty
cells): a live fragment carrying indent:0 + ids merged against the git-derived
fragment (neither) with a stale-by-one base applies 0 ops and does not grow — RED
before, GREEN after.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The point-fix (7a7b840e) excluded only `indent: 0` via a hardcoded one-attribute
denylist (`DEFAULT_KEY_ATTRS`) applied solely to ELEMENT attributes. The same
divergence recurs for every attribute whose editor-ext (server) schema default the
LIVE Yjs doc materializes (`TiptapTransformer.toYdoc(tiptapExtensions)`) but the
git round-trip does not: the engine's `markdownToProseMirror` emits those attrs as
explicit `null` (verified live: link mark `internal: null`, heading/paragraph
`indent: null`), which `y-prosemirror` then drops — so the same block keys
differently on the two sides, the three-way merge anchors on nothing, and the body
is re-appended every reconcile cycle (unbounded, no client connected). The denylist
also could not reach MARK attributes at all (marks are serialized raw in the
XmlText delta), so the link mark's `internal` mismatch survived.

Replace the denylist with a normalization derived from the ACTUAL ProseMirror
schema (`getSchema(tiptapExtensions)`, memoized): in `serializeXmlNode`, drop any
ELEMENT attribute whose value equals its node's schema default (or is
null/undefined), and normalize each XmlText delta op's MARK attributes the same way
against `schema.marks[name].spec.attrs`. The volatile block `id` stays excluded and
genuine non-default values (a real `indent: 2`, `align: "left"`, `link.href`,
highlight color) stay in the key. This is general — it covers indent, image.align,
link.internal, highlight.colorName, youtube/pdf and any future node/mark — not
another per-attribute denylist. Schema build is wrapped so a degenerate test stub
(`tiptapExtensions: []`) degrades to dropping only null/undefined.

Tests: new `yjs-body-merge.schema-defaults.spec.ts` models image/link/highlight
both hand-built and through the REAL `TiptapTransformer.toYdoc` materialization
(live defaults vs engine-style explicit nulls, base stale-by-one) — RED before
(4 ops / growth), GREEN after (0 ops). Existing idempotency + open-editor
convergence suites still pass (261 server collab+git-sync tests, tsc clean).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Owner

Code review (delta) — PR #119: native two-way Docmost↔git Markdown sync

Вердикт: Approve with comments. Скоуп-дельта чистая по security/stability; блокеров для слияния нет. Главное к исправлению — две асимметрии потери данных в конвертере (open у вложенного details; см. ниже) и пробелы тестов на новую safety-логику (abort-on-lost-lock) и на round-trip значений htmlEmbed/footnote/transclusion — это WIP, поэтому помечаю как сильные рекомендации, а не как Block.

Скоуп: ДЕЛЬТА-ревью. DELTA review. The last multi-aspect review (Request changes) was against head 68515ef9. Since then the branch was rebased and gained ~65 new commits. This review is SCOPED to the new work in the git-sync ENGINE + server integration seam (packages/git-sync/src + apps/server/src/integrations/git-sync), excluding develop-merge noise in unrelated paths. Review ONLY these scoped changes. (~23 файлов, +2372/−324). Аспекты: security, stability, regressions, test-coverage (параллельные ревьюеры + judge).

Статус прошлых блокеров

  • HTML-embed терялся на round-trip — адресовано: добавлен case htmlEmbed + base64-хелперы encode/decodeHtmlEmbedSource. Но фактическое сохранение ЗНАЧЕНИЯ не покрыто тестом (см. Test coverage).
  • footnotes/transclusion терялись — адресовано на уровне конвертера (новые cases). Покрытие — только выживание типа, не тела/атрибутов (см. Test coverage).
  • details open state — частично: top-level case теперь несёт open, но raw-HTML путь для вложенного details — нет (новая асимметрия, см. Must fix).
  • delete-cap (defense-in-depth от фантомных удалений) — СОЗНАТЕЛЬНО снят в driveCycle; задокументирован как намеренный (удаления soft/Trash, всегда логируются). Engine-конвергенция остаётся единственной защитой (см. Non-blocking).

Must fix before merge

  • [regressions] Пробросить open через raw-HTML путь detailsToHtmlpackages/git-sync/src/lib/markdown-converter.ts:858-860 top-level case теперь эмитит <details${open}> и схема парсит open обратно (docmost-schema.ts:471-476), но detailsToHtml (используется для details внутри columns/column/spanned-ячейки) эмитит голый <details>open: true у вложенного details молча теряется на каждом round-trip. Это та же потеря данных, что только что починили сверху, теперь рассогласованная между двумя путями. Fix: в detailsToHtml эмитить атрибут из node.attrs?.open (<details${node.attrs?.open ? " open" : ""}>…), как в top-level case.

  • **[regressions] warning — per-cycle delete-cap зафиксировать отказ ** — apps/server/src/integrations/git-sync/services/git-sync.orchestrator.ts:253-300 дельта убрала хук resolveApplyClient, который прошлый ревью добавил как защиту от bulk фантомных удалений при неконвергентном vault; engine хук всё ещё объявляет (cycle.ts:48,154-170), но оркестратор его не передаёт — кэп инертен. Изменение задокументировано и осознанно (удаления soft→Trash, всегда логируются), поэтому не блокер. удалить мёртвую обвязку resolveApplyClient из engine, чтобы не подразумевать контроль, который никогда не срабатывает.

Test coverage

  • [test-coverage] Покрыть abort-on-lost-lock (refreshLock abort + cycle throwIfAborted)space-lock.service.ts:105-118,160-171 + cycle.ts:128,174 новая safety-логика без единого теста: refreshLock() вызывает controller?.abort() на CAS-miss (res !== 1) И на ошибке Redis (catch); withSpaceLock() создаёт AbortController и прокидывает signal в fn; cycle.ts зовёт signal?.throwIfAborted() перед ОБЕИМИ деструктивными фазами. Heartbeat-тест проверяет только happy-path (что refresh eval отработал). Регрессия, тихо убравшая abort() (вернувшая blind-write race), оставила бы тесты зелёными. Fix: тест, где heartbeat-refresh CAS-промахивается (fake redis eval → 0) пока fn в полёте → assert signal aborted; sibling-кейс, где eval бросает; кейс в cycle.test.ts с pre-aborted signal → assert runCycle бросает до applyPullActions/runPush.
  • [test-coverage] Проверить выживание ЗНАЧЕНИЯ htmlEmbed source на полном round-trip + unit-тест encode/decodeHtmlEmbedSourcemarkdown-converter.ts:686-707 roundtrip-all-nodes.test.ts проверяет только types.has('htmlEmbed'), redteam — только not.toBe(''). Что <b>hi</b> реально возвращается после convert→import — не утверждается; хелперы без прямого unit-теста. Баг, эмитящий пустой/битый data-source, прошёл бы. Fix: round-trip doc с htmlEmbed source: '<b>hi</b>' (+ height), импорт обратно, assert attrs.source === '<b>hi</b>' и height сохранён; отдельный unit на UTF-8 симметрию и '' → ''.
  • [test-coverage] Проверить выживание тела footnote-определения + footnotesList, не только referencemarkdown-converter.ts:708-714 единственный фикстур (roundtrip-all-nodes.test.ts:70-74) строит footnotesList+footnoteDefinition, но утверждает только primary='footnoteReference'. Не проверяется, что узлы определения переживают импорт, что текст заметки 'note' сохранён, что reference.id ссылается на definition.id. Fix: расширить round-trip — после import убедиться, что есть footnotesList/footnoteDefinition, текст 'note' присутствует, и id совпадают.
  • [test-coverage] Добавить round-trip для атрибутов transclusionReference (sourcePageId/transclusionId)markdown-converter.ts:731-746 transclusionReference (атом, несущий потеряемые id) не имеет round-trip теста вовсе; единственный фикстур использует transclusionSource с НЕВЕРНЫМ именем атрибута (pageId вместо схемного id) и пустым content (а схема требует +, т.е. может не пере-распарситься). Fix: round-trip для transclusionReference с { sourcePageId, transclusionId } (assert оба переживают); починить фикстур transclusionSource на attrs.id хотя бы с одним блочным потомком.
  • [test-coverage] suggestion — Проверить, что флаг autoMergeConflicts (strict opt-in) доходит до settingsgit-sync.orchestrator.ts:116-138 buildSettings добавил per-space read settings->'gitSync'->>'autoMergeConflicts' = 'true' (всё кроме литерала 'true' → false) в settings.autoMergeConflicts. Spec хардкодит stub { autoMergeConflicts: false } и не утверждает поле на deps.settings; SQL-тест проверяет только предикат enabled. Downstream skip/strip покрыт в redteam-push-cycle, так что пробел умеренный. Fix: тест со stub executeTakeFirst → { autoMergeConflicts: true } (assert deps.settings.autoMergeConflicts === true) и кейс с missing row (assert default false).
## Code review (delta) — PR #119: native two-way Docmost↔git Markdown sync **Вердикт: Approve with comments.** Скоуп-дельта чистая по security/stability; блокеров для слияния нет. Главное к исправлению — две асимметрии потери данных в конвертере (open у вложенного details; см. ниже) и пробелы тестов на новую safety-логику (abort-on-lost-lock) и на round-trip значений htmlEmbed/footnote/transclusion — это WIP, поэтому помечаю как сильные рекомендации, а не как Block. _Скоуп: ДЕЛЬТА-ревью. DELTA review. The last multi-aspect review (Request changes) was against head 68515ef9. Since then the branch was rebased and gained ~65 new commits. This review is SCOPED to the new work in the git-sync ENGINE + server integration seam (packages/git-sync/src + apps/server/src/integrations/git-sync), excluding develop-merge noise in unrelated paths. Review ONLY these scoped changes. (~23 файлов, +2372/−324). Аспекты: security, stability, regressions, test-coverage (параллельные ревьюеры + judge)._ ### Статус прошлых блокеров - **HTML-embed терялся на round-trip** — адресовано: добавлен case `htmlEmbed` + base64-хелперы `encode/decodeHtmlEmbedSource`. Но фактическое сохранение ЗНАЧЕНИЯ не покрыто тестом (см. Test coverage). - **footnotes/transclusion терялись** — адресовано на уровне конвертера (новые cases). Покрытие — только выживание типа, не тела/атрибутов (см. Test coverage). - **details `open` state** — частично: top-level case теперь несёт `open`, но raw-HTML путь для вложенного details — нет (новая асимметрия, см. Must fix). - **delete-cap (defense-in-depth от фантомных удалений)** — СОЗНАТЕЛЬНО снят в `driveCycle`; задокументирован как намеренный (удаления soft/Trash, всегда логируются). Engine-конвергенция остаётся единственной защитой (см. Non-blocking). ### Must fix before merge - **[regressions] Пробросить `open` через raw-HTML путь `detailsToHtml`** — `packages/git-sync/src/lib/markdown-converter.ts:858-860` top-level case теперь эмитит `<details${open}>` и схема парсит `open` обратно (`docmost-schema.ts:471-476`), но `detailsToHtml` (используется для details внутри `columns`/`column`/spanned-ячейки) эмитит голый `<details>` — `open: true` у вложенного details молча теряется на каждом round-trip. Это та же потеря данных, что только что починили сверху, теперь рассогласованная между двумя путями. Fix: в `detailsToHtml` эмитить атрибут из `node.attrs?.open` (`<details${node.attrs?.open ? " open" : ""}>…`), как в top-level case. - **[regressions] warning — per-cycle delete-cap зафиксировать отказ ** — `apps/server/src/integrations/git-sync/services/git-sync.orchestrator.ts:253-300` дельта убрала хук `resolveApplyClient`, который прошлый ревью добавил как защиту от bulk фантомных удалений при неконвергентном vault; engine хук всё ещё объявляет (`cycle.ts:48,154-170`), но оркестратор его не передаёт — кэп инертен. Изменение задокументировано и осознанно (удаления soft→Trash, всегда логируются), поэтому не блокер. удалить мёртвую обвязку `resolveApplyClient` из engine, чтобы не подразумевать контроль, который никогда не срабатывает. ### Test coverage - **[test-coverage] Покрыть abort-on-lost-lock (refreshLock abort + cycle throwIfAborted)** — `space-lock.service.ts:105-118,160-171` + `cycle.ts:128,174` новая safety-логика без единого теста: `refreshLock()` вызывает `controller?.abort()` на CAS-miss (`res !== 1`) И на ошибке Redis (catch); `withSpaceLock()` создаёт `AbortController` и прокидывает signal в `fn`; `cycle.ts` зовёт `signal?.throwIfAborted()` перед ОБЕИМИ деструктивными фазами. Heartbeat-тест проверяет только happy-path (что refresh eval отработал). Регрессия, тихо убравшая `abort()` (вернувшая blind-write race), оставила бы тесты зелёными. Fix: тест, где heartbeat-refresh CAS-промахивается (fake redis eval → 0) пока `fn` в полёте → assert signal aborted; sibling-кейс, где eval бросает; кейс в `cycle.test.ts` с pre-aborted signal → assert `runCycle` бросает до `applyPullActions`/`runPush`. - **[test-coverage] Проверить выживание ЗНАЧЕНИЯ htmlEmbed source на полном round-trip + unit-тест encode/decodeHtmlEmbedSource** — `markdown-converter.ts:686-707` `roundtrip-all-nodes.test.ts` проверяет только `types.has('htmlEmbed')`, redteam — только `not.toBe('')`. Что `<b>hi</b>` реально возвращается после convert→import — не утверждается; хелперы без прямого unit-теста. Баг, эмитящий пустой/битый `data-source`, прошёл бы. Fix: round-trip doc с htmlEmbed `source: '<b>hi</b>'` (+ height), импорт обратно, assert `attrs.source === '<b>hi</b>'` и height сохранён; отдельный unit на UTF-8 симметрию и `'' → ''`. - **[test-coverage] Проверить выживание тела footnote-определения + footnotesList, не только reference** — `markdown-converter.ts:708-714` единственный фикстур (`roundtrip-all-nodes.test.ts:70-74`) строит `footnotesList`+`footnoteDefinition`, но утверждает только `primary='footnoteReference'`. Не проверяется, что узлы определения переживают импорт, что текст заметки `'note'` сохранён, что `reference.id` ссылается на `definition.id`. Fix: расширить round-trip — после import убедиться, что есть `footnotesList`/`footnoteDefinition`, текст `'note'` присутствует, и id совпадают. - **[test-coverage] Добавить round-trip для атрибутов transclusionReference (sourcePageId/transclusionId)** — `markdown-converter.ts:731-746` `transclusionReference` (атом, несущий потеряемые id) не имеет round-trip теста вовсе; единственный фикстур использует `transclusionSource` с НЕВЕРНЫМ именем атрибута (`pageId` вместо схемного `id`) и пустым content (а схема требует `+`, т.е. может не пере-распарситься). Fix: round-trip для `transclusionReference` с `{ sourcePageId, transclusionId }` (assert оба переживают); починить фикстур `transclusionSource` на `attrs.id` хотя бы с одним блочным потомком. - **[test-coverage] suggestion — Проверить, что флаг autoMergeConflicts (strict opt-in) доходит до settings** — `git-sync.orchestrator.ts:116-138` `buildSettings` добавил per-space read `settings->'gitSync'->>'autoMergeConflicts' = 'true'` (всё кроме литерала `'true'` → false) в `settings.autoMergeConflicts`. Spec хардкодит stub `{ autoMergeConflicts: false }` и не утверждает поле на `deps.settings`; SQL-тест проверяет только предикат `enabled`. Downstream skip/strip покрыт в redteam-push-cycle, так что пробел умеренный. Fix: тест со stub `executeTakeFirst → { autoMergeConflicts: true }` (assert `deps.settings.autoMergeConflicts === true`) и кейс с missing row (assert default false).
Ghost added 1 commit 2026-06-26 17:53:35 +03:00
Addresses review 1863 (delta) on PR #119.

MUST-FIX:
- detailsToHtml (the raw-HTML path used for a details nested inside
  columns/spanned cells) now emits `<details${open}>`, mirroring the
  top-level case, so `open` no longer silently drops every round trip.
- Remove the dead `resolveApplyClient` delete-cap hook from the engine
  `runCycle`: the orchestrator stopped passing it, so the hook + its
  dry-run pass were inert. Deletes are soft (Trash) + always logged and
  engine convergence is the guard, so no cap is re-added — just the dead
  wiring removed.

TEST COVERAGE:
- space-lock: heartbeat refresh CAS-miss (eval -> 0) and Redis-error
  (eval throws) both abort the in-flight fn's signal.
- cycle: a pre-aborted signal (and an abort during the pull read) throws
  before the push apply / first destructive phase.
- converter: htmlEmbed source VALUE + height survive; encode/decode
  UTF-8 symmetry and '' -> ''; footnote definition body + ref/def id
  match; transclusionReference both ids survive; fix the bad
  transclusionSource fixture (wrong `pageId` attr + empty content ->
  schema `id` + a block child); nested details `open` parity test.
- orchestrator: autoMergeConflicts:true reaches engine settings; default
  false on a missing settings row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added 1 commit 2026-06-26 18:01:58 +03:00
Ghost force-pushed feat/git-sync from c1c87c21c3 to 5141279e42 2026-06-26 20:44:55 +03:00 Compare
Ghost force-pushed feat/git-sync from 5141279e42 to a358d96a80 2026-06-26 21:09:05 +03:00 Compare
Owner

Heads-up: в develop/main сноски стали first-class — конвертеру git-sync их надо поддержать

В develop (и в релизной ветке) сноски — полноценная фича: узлы footnoteReference / footnotesList / footnoteDefinition (схема @docmost/editor-ext, канонизация footnoteSyncPlugin), и канонический конвертер packages/mcp/src/lib/markdown-converter.ts гоняет их round-trip как [^id] / [^id]: text (адресация по id).

Вендорный конвертер git-sync их не знает. В packages/git-sync/build/lib/markdown-converter.js нет case "footnoteReference"/"footnotesList"/"footnoteDefinition", а canonicalize.js их игнорирует. Неизвестный узел уходит в default (оборачивание детей в <div>), поэтому на export→import:

  • footnoteReference (инлайн-атом без детей) → теряется (пустой маркер);
  • footnoteDefinition / footnotesList → текст определений утекает в тело страницы как <div>.

Итог: любая страница со сносками через git-sync round-trip ломается. Это надо поддержать — желательно по той же модели (id-keyed [^id]/[^id]: text) и в идеале поверх общего канонизатора. Модель, канонизатор и анализ совместимости расписаны в #228 — там же предложено вынести canonicalizeFootnotes в общий код, чтобы git-sync переиспользовал его, а не заводил свою копию.

Заодно — просьба про аудит покрытия конвертера vs «апстрим»

Прогони, пожалуйста, сверку покрытия узлов/марок вендорного конвертера против канонического (packages/mcp/src/lib/markdown-converter.ts) и зафиксируй реальные потери на round-trip. Я сделал быстрый diff как стартовую точку:

  • Подтверждённое расхождение (есть в каноне, нет в git-sync): ровно footnoteReference, footnotesList, footnoteDefinition — больше по списку case ничего не отсутствует.
  • Дрейф в обратную сторону: у вендорного есть case "pageBreak" (стр. ~519), которого нет в каноническом конвертере. Стоит сверить и эту сторону — схемы немного разъехались.
  • Про выравнивание картинок (ты приводил как пример): проверил — это не git-sync-специфичный пробел. Оба конвертера сериализуют image как ![alt](src) и одинаково теряют выравнивание/ширину (align/width), потому что markdown-картинка их не несёт. Если важно сохранять выравнивание/ширину через round-trip — это правка для обоих конвертеров (например HTML-fallback <img …> по аналогии с тем, как уже сделан <video> с сохранением data-атрибутов), и её надо оценить отдельно.

Просьба: гонять converter-gate / round-trip на документах со сносками, выровненными картинками и pageBreak, и приложить список фактических потерь (что дропается, что мутирует). Сноски из этого списка — блокер для страниц с ними; остальное — по приоритету.

### Heads-up: в develop/main сноски стали first-class — конвертеру git-sync их надо поддержать В `develop` (и в релизной ветке) сноски — полноценная фича: узлы `footnoteReference` / `footnotesList` / `footnoteDefinition` (схема `@docmost/editor-ext`, канонизация `footnoteSyncPlugin`), и канонический конвертер `packages/mcp/src/lib/markdown-converter.ts` гоняет их round-trip как `[^id]` / `[^id]: text` (адресация по `id`). **Вендорный конвертер git-sync их не знает.** В `packages/git-sync/build/lib/markdown-converter.js` нет `case "footnoteReference"/"footnotesList"/"footnoteDefinition"`, а `canonicalize.js` их игнорирует. Неизвестный узел уходит в `default` (оборачивание детей в `<div>`), поэтому на export→import: - `footnoteReference` (инлайн-атом без детей) → теряется (пустой маркер); - `footnoteDefinition` / `footnotesList` → текст определений утекает в тело страницы как `<div>`. Итог: любая страница со сносками через git-sync round-trip **ломается**. Это надо поддержать — желательно по той же модели (id-keyed `[^id]`/`[^id]: text`) и в идеале поверх общего канонизатора. Модель, канонизатор и анализ совместимости расписаны в #228 — там же предложено вынести `canonicalizeFootnotes` в общий код, чтобы git-sync переиспользовал его, а не заводил свою копию. ### Заодно — просьба про аудит покрытия конвертера vs «апстрим» Прогони, пожалуйста, сверку покрытия узлов/марок вендорного конвертера против канонического (`packages/mcp/src/lib/markdown-converter.ts`) и зафиксируй реальные потери на round-trip. Я сделал быстрый diff как стартовую точку: - **Подтверждённое расхождение (есть в каноне, нет в git-sync):** ровно `footnoteReference`, `footnotesList`, `footnoteDefinition` — больше по списку `case` ничего не отсутствует. - **Дрейф в обратную сторону:** у вендорного есть `case "pageBreak"` (стр. ~519), которого нет в каноническом конвертере. Стоит сверить и эту сторону — схемы немного разъехались. - **Про выравнивание картинок** (ты приводил как пример): проверил — это **не** git-sync-специфичный пробел. Оба конвертера сериализуют image как `![alt](src)` и одинаково теряют выравнивание/ширину (`align`/`width`), потому что markdown-картинка их не несёт. Если важно сохранять выравнивание/ширину через round-trip — это правка для **обоих** конвертеров (например HTML-fallback `<img …>` по аналогии с тем, как уже сделан `<video>` с сохранением data-атрибутов), и её надо оценить отдельно. Просьба: гонять converter-gate / round-trip на документах со сносками, выровненными картинками и pageBreak, и приложить список фактических потерь (что дропается, что мутирует). Сноски из этого списка — блокер для страниц с ними; остальное — по приоритету.
Ghost force-pushed feat/git-sync from a358d96a80 to 7c535f0165 2026-06-27 05:32:02 +03:00 Compare
Ghost added 1 commit 2026-06-27 19:50:52 +03:00
Addresses QA findings on PR #119 (issues #235/#236).

SYNC-WEDGE (HIGH): one same-line conflict on one page froze sync for the
WHOLE space in both directions forever. The pull's docmost->main merge left
the vault mid-merge, so every later cycle's isMergeInProgress() check returned
skipped:"merge-in-progress" and skipped the entire space with no recovery.
- pull.ts now COMMITS a conflicting merge with markers in place (commitMerge):
  cleanly-merged pages land, the conflicted page carries its markers on main and
  is isolated by the existing push-side conflict-marker skip (markers never reach
  Docmost), and the next cycle is no longer wedged. conflictedPaths is surfaced.
- cycle.ts now RECOVERS a vault left mid-merge by a prior/pre-fix cycle: it
  aborts the stale merge (merge --abort, hard-reset fallback) and continues,
  instead of skipping the space forever.
- git.ts: listUnmergedPaths / commitMerge / abortMerge / resetHardToHead.

CALLOUT TYPE FIDELITY: git-sync's CALLOUT_TYPES was missing "note" and "default"
(editor-canonical types), so [!note]/[!default] callouts flattened to [!info] on
every round-trip. Aligned the list with @docmost/editor-ext getValidCalloutType.

LOSS-ON-FAST-CLOSE: editing a page then closing the tab inside the collab
debounce window (~3-18s) lost the edit, because with unloadImmediately:false
Hocuspocus does not flush the debounced onStoreDocument on the last-client
disconnect. PersistenceExtension.onDisconnect now flushes the pending store
(debouncer.executeNow) on the last disconnect only, with no redundant write.

DUPLICATION re-verify (#1): the schema-default merge-key normalization is intact;
faithful toYdoc-based reproduction shows callout + rich content resync with 0 ops
and no growth/strip across cycles -> the re-report was leftover vault data, not a
live regression. Locked with a callout regression spec.

Tests: git-sync 688 pass (incl. real-VaultGit wedge-recovery integration); server
git-sync+collaboration 285 pass; new callout merge/fidelity + onDisconnect-flush
specs. tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added 1 commit 2026-06-27 22:48:19 +03:00
Security (must-fix):
- /git smart-HTTP gate: an authenticated NON-member of a git-sync space now gets
  404 (not 403), so the 403<->404 difference can no longer be used to brute-force
  which spaces exist / have git-sync enabled. 403 is reserved for a MEMBER who
  lacks the required role (existence already known). New gate input
  userIsSpaceMember; decision-table + service specs extended.

Config (must-fix):
- Remove the dead GIT_SYNC_SSH_KEY_PATH knob (getter + validation field + two
  .env.example lines) — it had zero consumers and advertised a nonexistent push
  capability.

Stability/docs (warnings):
- Wire the lost-lock AbortSignal into runReceivePack -> git http-backend so the
  receive-pack child is killed if the per-space lock lapses mid-write.
- Raise the divergent-`docmost` (invariant §5) push refusal from info -> warn and
  surface divergentDocmost in the run status (/status).
- Comment the stale read-after-debounced-collab-write updatedAt in
  importPageMarkdown (deferred §10 loop-guard must not trust it).
- Fix the Dockerfile comment: the loader uses require.resolve + dynamic import(),
  it deliberately does NOT require('@docmost/git-sync').
- Merge the two near-identical space toggle handlers into one parameterized
  handler; add the 2 missing en-US i18n keys for the auto-merge switch (ru-RU not
  maintained for these git-sync strings, mirrored).

Tests:
- isGitSyncHttpEnabled() default-branch (unset -> isGitSyncEnabled fallback).
- agentSourceFields 'git-sync' case (source stamped, chat key omitted).
- editor-ext name-level schema contract (vendored mirror superset of editor-ext
  node/mark types) + the new shared resolver + non-member 404 gate cases.

Architecture:
- Extract resolveRequestWorkspace shared by DomainMiddleware + GitHttpService
  (the two real self-hosted/cloud copies; McpService has no cloud branch).
- Document the in-process setInterval multi-replica limitation + BullMQ/fencing
  future direction (deferred, not implemented).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added 1 commit 2026-06-27 23:50:02 +03:00
Must-fix:
- Throttle the raw /git HTTP-Basic path: it bypasses Nest/ThrottlerGuard, so
  verifyUserCredentials (bcrypt) ran unthrottled. Wrap it in the SAME
  FailedLoginLimiter the /mcp path uses (5/60s; per-IP, per-IP+email, global
  per-email keys; atomic tryReserve BEFORE bcrypt; success resets, non-credential
  errors release). The (threshold+1)-th attempt now gets 429 pre-bcrypt. Sweep
  timer + onModuleDestroy mirror McpService.
- Fix the mcp schema mirror drift: packages/mcp details `open` attr now reads via
  hasAttribute (matches editor-ext canon + git-sync copy); getAttribute dropped a
  bare `<details open>` state. (build/ is gitignored — rebuilt locally.)

Tests added:
- /git brute-force throttle: pre-bcrypt 429 on the 6th failure; success resets;
  non-credential error releases the budget.
- git-http-backend lost-lock AbortSignal: already-aborted -> no spawn + 500;
  live abort mid-request -> SIGTERM + response closed.
- orchestrator divergentDocmost -> WARN + flag surfaced in status (+ clean case).
- pollTick re-entrancy guard skips an overlapping tick.
- datasource NotFound early-throws (getPageJson/move/rename) + updatedAt:undefined
  stale-read branch (importPageMarkdown/createPage).

Suggestions:
- space.repo updateGitSyncSettings: parameterize the jsonb key (`${prefKey}::text`)
  instead of sql.raw (latent-injection footgun); value stays sql.lit. Spec updated.
- pollTick re-entrancy guard (private `polling` flag).
- page-change.listener docstring: honest about the move/rename/delete over-skip
  (loop-guard keys only on lastUpdatedSource) -> ~poll-interval latency, not loss.
- AGENTS.md: document the root /git smart-HTTP route + GitSyncModule.
- Remove redundant redteam-provenance.spec.ts (covered e2e in
  persistence.extension.spec.ts:145).
- Extract the duplicated SIGTERM->SIGKILL+finish block (watchdog + abort) into
  terminateChild; centralize watchdog-timer teardown in done().

Architecture (deferred, documented): mcp schema header now carries the three-copy
keep-in-sync + schema-core note; the editor-ext contract test documents that the
mcp copy and attribute-behaviour drift (details `open`) are not mechanically
covered yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added 1 commit 2026-06-28 01:35:56 +03:00
1. gitRemote is NOT yet consumed (the vendored engine has no remote-push path,
   SPEC §7). Corrected the buildSettings docstring (it wrongly called gitRemote
   "load-bearing") and marked the env -> validation -> getter -> buildSettings
   chain as inert SCAFFOLDING for the deferred remote-push feature at all three
   sites. Kept the wiring (harmless; removing only churns).

2. .env.example: document that GIT_SYNC_REMOTE_TEMPLATE substitutes the literal
   "{spaceId}" per-space (with the example), so an operator doesn't point every
   space at one remote.

3. Extracted the copy-pasted CJS->ESM dynamic-import bridge
   (`new Function('s','return import(s)')`) into one shared
   common/helpers/esm-import.ts; git-sync.loader, docmost-client.loader and
   mcp.service now import it and keep their own typed loadX() wrappers.

Deferred (notes only, not implemented):
- lcs.ts + three-way-merge.ts could move into packages/git-sync, but that engine
  is vendored (manual re-sync) — added a one-line note at three-way-merge.ts to
  revisit once the re-sync story is settled.
- schema-core single source + BullMQ/fencing remain documented from prior rounds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost force-pushed feat/git-sync from 9345396bb5 to 906733b5c8 2026-06-28 15:39:45 +03:00 Compare
Ghost added 1 commit 2026-06-28 20:03:51 +03:00
Bug #1 (push 503 starvation): an external receive-pack that briefly overlapped
a poll cycle immediately 503'd because the per-space single-writer lock was
held. Add a BOUNDED retry-acquire on the PUSH path only (SpaceLockService
.withSpaceLock acquireRetry: capped exponential backoff up to ~5s); a transient
overlap now waits and succeeds, a genuinely stuck cycle still 503s after the
bound. The poll cycle passes no retry (immediate skip). Push result stays
deterministic: the receive-pack only runs once the lock is held, so a 503 never
leaves a half-applied ref.

Bug #2 (concurrent-edit marker leak + silent same-block loss):
- Marker leak (a): the push UPDATE path stripped markers for the body sent to
  Docmost but left raw <<<<<<</>>>>>>> committed on the published `main` vault
  forever (autoMergeConflicts ON). Now the cleaned body is written back to the
  vault file + recorded in writtenBack so runPush commits it on `main` and the
  vault converges to clean bytes.
- Marker leak (b): pin merge.conflictStyle=merge in ensureRepo and teach
  stripConflictMarkers/hasConflictMarkers about the diff3 `|||||||` base section
  (drop the marker AND the stale base region) so diff3/zdiff3 conflicts can
  never leak `|||||||` + base content into a page. Also scrub the 3-way merge
  BASE markdown.
- Silent same-block loss: the block 3-way merge still resolves same-block
  conflicts deterministically to git, but it is no longer silent: diff3Plan now
  reports a conflict count (mergeXmlFragments3WayWithStats), gitSyncWriteBody
  logs it, and the persistence boundary-snapshot now fires for git-sync writes
  over a non-git-sync baseline so the human's pre-merge content is preserved in
  page history (recoverable). Full both-preserved persisted-conflict UI remains
  the deferred redesign.

Tests: space-lock bounded-retry (success/stuck/poll-immediate); push vault-clean
+ diff3 |||||||  strip; ensureRepo conflictStyle pin; diff3Plan/3-way conflict
counts; persistence git-sync boundary snapshot. Server tsc clean; git-sync
vitest + server collaboration/git-sync jest all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added 1 commit 2026-06-28 22:06:05 +03:00
Three more git-sync QA defects from the 2nd live pass on PR #119, plus a
callout-fidelity nit:

1. SPURIOUS conflict leaked raw markers into canonical main (root cause). On an
   ordinary round-trip the only difference between the docmost mirror (normalize-
   on-write) and a user's raw push is trailing/empty-line normalization, which made
   git's line-based docmost->main merge CONFLICT, and the wedge fix then committed
   the file WITH literal <<<<<<< / ======= / >>>>>>> markers onto main (git and the
   DB silently diverged for cycles). Fix: on a conflict, normalize trailing/empty
   lines on BOTH sides (showStage :2:/:3:) before comparing — a trailing-only diff
   is recognized as spurious and resolved to the clean normalized form. A GENUINE
   same-block conflict is auto-resolved to OURS (git wins, mirroring the live-doc
   3-way rule); the docmost side stays on the `docmost` branch + page history. Raw
   markers NEVER reach main again.

2. Concurrent UI<->git edit silently lost the UI side. The git->Docmost 3-way merge
   ran against a live Y.Doc that hadn't yet received the user's debounced in-flight
   edit, so git clean-applied (no conflict detected) and the edit vanished even on a
   different block. Fix: flush the pending debounced store before the merge so the
   in-flight edit is drained into the live doc first — a different-block edit is
   merged, a same-block one is detected and pinned to history (recoverable).

3. Smart-HTTP HEAD flapped to the read-only `docmost` mirror (~1/4 of clones). The
   engine transiently checks out `docmost` mid-pull and the host advertises whatever
   HEAD resolves to. Fix: VaultGit.pinHeadToMain(); the cycle restores HEAD->main in
   a finally; and the upload-pack ref advertisement is served HEAD-pinned under the
   per-space lock so it can never observe a mid-cycle HEAD.

4. (callout) clampCalloutType now mirrors the editor's GITHUB_ALERT_TYPE_MAP for
   non-schema aliases (tip->success, caution->danger, important->info) instead of
   flatly collapsing to info. The editor schema genuinely supports only the six
   banner types, so unknown types still fall back to info (by design).

Tests: deterministic real-git trailing-blank round-trip (no conflict, no markers,
in sync over 2 cycles) + genuine-conflict no-marker-leak; HEAD advertisement
stability; pre/post-flush concurrent-edit survival; serveReadAdvertisement lock
pin; widened callout-alias coverage. Engine vitest + server tsc + collaboration /
git-http / orchestrator specs all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added the review/changes-requested label 2026-06-28 22:51:50 +03:00
Ghost added the status/in-progress label 2026-06-29 00:17:05 +03:00
Ghost added 1 commit 2026-06-29 00:24:21 +03:00
The genuine-conflict branch in applyPullActions resolves to `ours ?? theirs`,
but the two stages where a side is ABSENT had NO test — the existing conflict
tests only fed stages where both ours and theirs are non-null. This is the
data-preservation core on the published `main`: a regression (dropping the
`?? theirs`, or wrongly writing on both-null) would silently lose a surviving
Docmost edit or resurrect a both-deleted page.

Adds four tests:
- apply-pull-actions.test.ts (fake-git, controlled stages): modify/delete
  (ours=null, theirs!=null -> keep THEIRS) and delete/delete (both null ->
  write nothing, deletion staged by commitMerge's `git add -A`).
- pull-conflict-normalize.test.ts (real-git 3-way): modify/delete built by
  deleting on main + modifying on docmost (stage 2 absent -> theirs kept,
  committed clean, no markers); delete/delete built via a rename/rename(1to2)
  on the shared base file, which records the original path as both-deleted
  (stages 2 AND 3 absent -> nothing written, deletion committed off main).

Production logic at pull.ts:487-497 held — pure test-coverage fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added review/needs and removed review/changes-requested labels 2026-06-29 00:28:50 +03:00
Ghost added review/changes-requested and removed review/needs labels 2026-06-29 01:33:36 +03:00
Ghost added 1 commit 2026-06-29 02:05:10 +03:00
F2: the real-git modify/delete null-edge test docstring overclaimed it
caught loss of the `?? theirs` fallback end-to-end. git itself leaves
theirs in the working tree (stage 3) so commitMerge's `git add -A` would
stage it even with the bug — the assertions pass on broken logic. Reword
to state it verifies the clean-merge happy path; the real F1 regression
guard lives in the fake-fs apply-pull-actions.test.ts.

F3: fill the `round-?` placeholder with `round-2` in both new blocks to
match the file convention (header: 'QA #119 round-2').

Comment-only; no production or test-logic changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added review/needs and removed review/changes-requested labels 2026-06-29 02:07:41 +03:00
Ghost added review/approved and removed review/needs labels 2026-06-29 02:29:26 +03:00
Collaborator

F4 [blocking] (critical) packages/git-sync/src/engine/push.ts:403-424 (+ ghost-move ветки A:312-320, M:345-353) — при переименовании/перемещении файла, ОДНОВРЕМЕННО отредактированного в том же диффе, правка ТЕЛА теряется безвозвратно.

Подтверждено чтением кода (судья):

  • computePushActions для case "R"/"C" кладёт страницу ТОЛЬКО в actions.renamesMoves, никогда в actions.updates. То же в ghost-move ветках A (312) и M (349): любой переименованный/перемещённый pageId уходит в renamesMoves.
  • applyPushActions для renamesMoves (900-976) вызывает лишь client.movePage / client.renamePage. client.importPageMarkdown (тело) вызывается ТОЛЬКО для actions.updates (682-716) и creates (841) — для renamesMoves НЕ вызывается никогда.
  • git detection -M репортит rename+правку одним R-row (или, при крупной правке, D+A, который ghost-move-коалесинг сводит в renamesMoves). В обоих случаях тело в Docmost не уходит.

Хуже: потеря НЕ откладывается, а становится постоянной. После move push продвигает refs (LAST_PUSHED_REF → main, docmost-mirror ff → main), т.е. зеркало docmost заявляет «тело = новое», хотя в БД Docmost тело старое. На следующем pull читается старое тело из Docmost, пишется в ветку docmost поверх нового, merge docmost→main фаст-форвардит main к старому — правка затирается и в git.

Реалистично: в Obsidian/git переименовали заметку (или перенесли в папку) и в том же коммите поправили текст → правка тела молча и необратимо потеряна. Все прошлые ревью #119 это пропустили.

Fix: в computePushActions для R/C (403-424) и ghost-move A/M (312-320, 345-353) дополнительно эмитить actions.updates.push({ pageId, path: <newPath> }) рядом с записью в renamesMoves, чтобы тело прогонялось через importPageMarkdown (idempotent 3-way merge — для чистого переименования без правки это near-no-op). Обновить compute-push-actions.test.ts (ожидание updates:[] для R/C заменить) и добавить кейс «rename+edit → и move, и update тела».

F4 [blocking] (critical) `packages/git-sync/src/engine/push.ts:403-424` (+ ghost-move ветки A:312-320, M:345-353) — при переименовании/перемещении файла, ОДНОВРЕМЕННО отредактированного в том же диффе, правка ТЕЛА теряется безвозвратно. Подтверждено чтением кода (судья): - `computePushActions` для `case "R"/"C"` кладёт страницу ТОЛЬКО в `actions.renamesMoves`, никогда в `actions.updates`. То же в ghost-move ветках A (312) и M (349): любой переименованный/перемещённый pageId уходит в renamesMoves. - `applyPushActions` для renamesMoves (900-976) вызывает лишь `client.movePage` / `client.renamePage`. `client.importPageMarkdown` (тело) вызывается ТОЛЬКО для `actions.updates` (682-716) и creates (841) — для renamesMoves НЕ вызывается никогда. - git detection `-M` репортит rename+правку одним `R`-row (или, при крупной правке, D+A, который ghost-move-коалесинг сводит в renamesMoves). В обоих случаях тело в Docmost не уходит. Хуже: потеря НЕ откладывается, а становится постоянной. После move push продвигает refs (LAST_PUSHED_REF → main, docmost-mirror ff → main), т.е. зеркало docmost заявляет «тело = новое», хотя в БД Docmost тело старое. На следующем pull читается старое тело из Docmost, пишется в ветку docmost поверх нового, merge docmost→main фаст-форвардит main к старому — правка затирается и в git. Реалистично: в Obsidian/git переименовали заметку (или перенесли в папку) и в том же коммите поправили текст → правка тела молча и необратимо потеряна. Все прошлые ревью #119 это пропустили. Fix: в computePushActions для R/C (403-424) и ghost-move A/M (312-320, 345-353) дополнительно эмитить `actions.updates.push({ pageId, path: <newPath> })` рядом с записью в renamesMoves, чтобы тело прогонялось через importPageMarkdown (idempotent 3-way merge — для чистого переименования без правки это near-no-op). Обновить compute-push-actions.test.ts (ожидание updates:[] для R/C заменить) и добавить кейс «rename+edit → и move, и update тела».
Collaborator

F5 [warning] packages/git-sync/src/engine/push.ts:803-811 — гард «конфликт-маркеры никогда не попадают в Docmost» на CREATE-пути (autoMergeConflicts ON) не покрыт тестом. При ON ветка вызывает stripConflictMarkers(rawBody) и затем createPage(title, body) с очищенным телом. Покрыты только: CREATE при OFF (redteam-push-cycle.test.ts:221 — createPage НЕ вызван) и UPDATE при ON (другой code-path через importPageMarkdown). Регрессия, передающая rawBody вместо body в createPage, опубликует сырые <<<<<<< / >>>>>>> в новую страницу — ни один тест не поймает. Сами авторы отмечают важность симметрии в комментарии redteam-push-cycle.test.ts:222-225, но закрыли только OFF-половину.

Fix: добавить в redteam-push-cycle.test.ts тест CREATE при autoMergeConflicts:true — новый .md (status 'A', без gitmost_id) с конфликт-маркерами; проверить, что createPage вызван и его body не содержит <<<<<<</=======/>>>>>>>, но сохраняет контент обеих сторон, а файл переписан чистым телом.

F5 [warning] `packages/git-sync/src/engine/push.ts:803-811` — гард «конфликт-маркеры никогда не попадают в Docmost» на CREATE-пути (autoMergeConflicts ON) не покрыт тестом. При ON ветка вызывает stripConflictMarkers(rawBody) и затем createPage(title, body) с очищенным телом. Покрыты только: CREATE при OFF (redteam-push-cycle.test.ts:221 — createPage НЕ вызван) и UPDATE при ON (другой code-path через importPageMarkdown). Регрессия, передающая rawBody вместо body в createPage, опубликует сырые <<<<<<< / >>>>>>> в новую страницу — ни один тест не поймает. Сами авторы отмечают важность симметрии в комментарии redteam-push-cycle.test.ts:222-225, но закрыли только OFF-половину. Fix: добавить в redteam-push-cycle.test.ts тест CREATE при autoMergeConflicts:true — новый .md (status 'A', без gitmost_id) с конфликт-маркерами; проверить, что createPage вызван и его body не содержит <<<<<<</=======/>>>>>>>, но сохраняет контент обеих сторон, а файл переписан чистым телом.
Collaborator

F6 [warning] .env.example:219-224 — блок GIT_SYNC_REMOTE_TEMPLATE описан как рабочая фича («Optional remote URL template to mirror each space's vault to… each space mirrors to its OWN remote… Leave unset to keep vaults local-only»), но это инертный scaffolding: значение ничем не потребляется. Все внутренние места говорят обратное: environment.validation.ts:193-195 («SCAFFOLDING for the deferred remote-push feature… currently inert»), environment.service.ts:370-379 («currently inert»), git-sync.orchestrator.ts:129-142 («gitRemote is NOT yet consumed… inert SCAFFOLDING»), pull.ts:534 логирует «git push to remote is DEFERRED». Оператор, выставивший переменную в расчёте на зеркалирование на git-хост, получает молчаливый no-op, поданный как рабочая ручка.

Fix: переписать комментарий GIT_SYNC_REMOTE_TEMPLATE в .env.example — указать, что remote push ещё не реализован (deferred, SPEC §7) и переменная сейчас инертный scaffolding без эффекта, по формулировке из environment.validation.ts:193-195.

F6 [warning] `.env.example:219-224` — блок GIT_SYNC_REMOTE_TEMPLATE описан как рабочая фича («Optional remote URL template to mirror each space's vault to… each space mirrors to its OWN remote… Leave unset to keep vaults local-only»), но это инертный scaffolding: значение ничем не потребляется. Все внутренние места говорят обратное: environment.validation.ts:193-195 («SCAFFOLDING for the deferred remote-push feature… currently inert»), environment.service.ts:370-379 («currently inert»), git-sync.orchestrator.ts:129-142 («gitRemote is NOT yet consumed… inert SCAFFOLDING»), pull.ts:534 логирует «git push to remote is DEFERRED». Оператор, выставивший переменную в расчёте на зеркалирование на git-хост, получает молчаливый no-op, поданный как рабочая ручка. Fix: переписать комментарий GIT_SYNC_REMOTE_TEMPLATE в .env.example — указать, что remote push ещё не реализован (deferred, SPEC §7) и переменная сейчас инертный scaffolding без эффекта, по формулировке из environment.validation.ts:193-195.
Collaborator

Ревью fbaaa8441 — раунд 3 (ПОЛНЫЙ РАЗДЕЛЬНЫЙ веер, 8 отдельных субагентов: security/stability/regressions/test-coverage/conventions/documentation/simplification/architecture). Вердикт: CHANGES.
ВАЖНО: полный раздельный веер поймал CRITICAL, который пропустили round-1 (stability=LGTM) и объединённый round-2.
Открыто: F4 (blocking/critical, stability — rename/move + правка тела в одном диффе теряет прав484 тела безвозвратно; подтверждено судьёй чтением кода), F5 (warning, test-coverage — CREATE-путь strip конфликт-маркеров не покрыт), F6 (warning, docs — .env.example врёт про GIT_SYNC_REMOTE_TEMPLATE как рабочую фичу).
Прошлые F1/F2/F3 подтверждены закрытыми. security / regressions / conventions / simplification / architecture — LGTM (git через execFile, path-guard, фича opt-in, чистый seam пакета, Yjs=авторитет).
F4 — НЕ эскалация: фикс конкретный (эмитить updates рядом с renamesMoves), кодер применит.

Ревью fbaaa8441 — раунд 3 (ПОЛНЫЙ РАЗДЕЛЬНЫЙ веер, 8 отдельных субагентов: security/stability/regressions/test-coverage/conventions/documentation/simplification/architecture). Вердикт: CHANGES. ВАЖНО: полный раздельный веер поймал CRITICAL, который пропустили round-1 (stability=LGTM) и объединённый round-2. Открыто: F4 (blocking/critical, stability — rename/move + правка тела в одном диффе теряет прав484 тела безвозвратно; подтверждено судьёй чтением кода), F5 (warning, test-coverage — CREATE-путь strip конфликт-маркеров не покрыт), F6 (warning, docs — .env.example врёт про GIT_SYNC_REMOTE_TEMPLATE как рабочую фичу). Прошлые F1/F2/F3 подтверждены закрытыми. security / regressions / conventions / simplification / architecture — LGTM (git через execFile, path-guard, фича opt-in, чистый seam пакета, Yjs=авторитет). F4 — НЕ эскалация: фикс конкретный (эмитить updates рядом с renamesMoves), кодер применит.
agent_reviewer added review/changes-requested and removed review/approved labels 2026-06-29 03:56:08 +03:00
This pull request has changes conflicting with the target branch.
  • apps/server/src/app.module.ts
  • apps/server/src/integrations/environment/environment.service.spec.ts
  • apps/server/src/integrations/environment/environment.service.ts
  • apps/server/src/integrations/environment/environment.validation.ts
  • packages/mcp/build/client.js
  • packages/mcp/build/index.js
  • packages/mcp/build/tool-specs.js
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/git-sync:feat/git-sync
git checkout feat/git-sync
Sign in to join this conversation.
No Reviewers
3 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#119