feat(git-sync): двусторонний Docmost↔git синк на унифицированном конвертере (#359)

Схлопнутая дельта feat/git-sync-2 поверх актуального develop (3085ec1b).

Почему squash, а не буквальный rebase: в ветке 113 коммитов, из которых
develop уже впитал ~80 в курированном виде при унификации конвертера
(#326/#293) — но не patch-identical, поэтому они не отваливаются сами; а
коммит, УДАЛЯЮЩИЙ вендоренную копию конвертера, апстримный и в replay-набор
не входит, так что наивный replay заново добавил бы вендоренные копии.
Поэтому корректный итог — «develop + чистая net-дельта ветки», сведённая
3-way мержом (merge-base 5336f06d).

Net-дельта: серверный git-sync модуль (GitSyncModule/orchestrator/HTTP),
движок git-sync (layout/reconcile/pull/push/stabilize + QA), два фикса
round-trip/data-loss конвертера поверх УНИФИЦИРОВАННОГО
@docmost/prosemirror-markdown (вендоренной копии больше нет — git-sync
целиком на унифицированном конвертере, поведение унифицированного принято
за эталон), e2e-скрипты, доки.

Конфликты (4) слиты объединением: main.ts (метрики + GitHttpService),
apps/server/package.json (pretest-суперсет; moduleNameMapper: git-sync→src
и .js-strip оставлены, а bare-specifier @docmost/prosemirror-markdown→src
УБРАН — серверный jest выровнен на develop-подход #345 со сборённым
пакетом), .env.example (метрики + GIT_SYNC блоки), AGENTS.md (строки про
пакеты; устаревшая заметка про «три hand-synced копии схемы» в git-sync
поправлена — схема теперь только в @docmost/prosemirror-markdown).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-06 05:35:03 +03:00
parent da7bb95d4f
commit 044daab357
127 changed files with 14238 additions and 193 deletions
+37
View File
@@ -59,6 +59,43 @@ describe('planReconciliation', () => {
expect(plan.moved).toEqual([]);
});
// D-P3-1 ghost guard: when `deletableIds` is supplied, an absence-delete fires
// ONLY for an id that is a REAL page row. A tracked file whose id was NEVER a
// page (a hand-authored git file with an unknown id) is absent from `live` AND
// absent from `deletableIds` -> it MUST be preserved, not silently deleted.
it('GHOST GUARD: an absent id NOT in deletableIds is PRESERVED (not deleted)', () => {
const live: LiveEntry[] = [{ pageId: 'p1', relPath: 'Space/Keep.md' }];
const existing: ExistingEntry[] = [
{ pageId: 'p1', relPath: 'Space/Keep.md' },
// A ghost: its id is not live and not a real page row.
{ pageId: 'ghost', relPath: 'Space/Ghost.md' },
// A genuinely deleted page: absent from live but IS a real row.
{ pageId: 'gone', relPath: 'Space/Gone.md' },
];
// Only the real page row ('gone') is deletable; 'ghost' has no row.
const deletableIds = new Set(['gone']);
const plan = planReconciliation(live, existing, deletableIds);
expect(plan.toWrite).toEqual([{ pageId: 'p1', relPath: 'Space/Keep.md' }]);
// The genuine delete is applied; the ghost file is preserved.
expect(plan.toDelete).toEqual(['Space/Gone.md']);
expect(plan.toDelete).not.toContain('Space/Ghost.md');
expect(plan.moved).toEqual([]);
});
// The empty gate is the maximally-safe case: no id is a real row, so NOTHING
// is absence-deleted (every absent tracked file is treated as a ghost). This
// is what a cycle sees when `pageIdsExist` returns nothing for the candidates.
it('GHOST GUARD: an EMPTY deletableIds set suppresses every absence delete', () => {
const live: LiveEntry[] = [{ pageId: 'p1', relPath: 'Space/Keep.md' }];
const existing: ExistingEntry[] = [
{ pageId: 'p1', relPath: 'Space/Keep.md' },
{ pageId: 'gone', relPath: 'Space/Gone.md' },
];
const plan = planReconciliation(live, existing, new Set<string>());
expect(plan.toDelete).toEqual([]);
expect(plan.moved).toEqual([]);
});
it('NO-OP: live and existing identical -> writes (re-emit) but no deletes/moves', () => {
const live: LiveEntry[] = [
{ pageId: 'p1', relPath: 'A.md' },