# git-sync: thin meta + third-party-editor support Статус: **дизайн (согласован с владельцем 2026-06-24), к реализации.** ## Зачем Сейчас каждый `.md` в волте несёт служебный блок `` и ``. Сторонние редакторы (Obsidian + obsidian-git — основной кейс) про это не знают: они кладут «голые» markdown-файлы и папки, а движок их МОЛЧА игнорирует (нет pageId → не страница), плюс мета сорит в каждом файле. Цель: `.md` остаются **чистым markdown** (любой редактор доволен), вся служебка живёт в одном сайдкаре `.gitmost/index.json`, а дерево страниц **зеркалится структурой папок**. Голые файлы/папки от стороннего редактора **адоптируются** в страницы Docmost. ## Решённые принципы 1. **Идентичность — стабильный id, не имя файла и не путь.** (Подпёрто фиксом 5133bb34: страница с известным id, чей файл переехал, никогда не удаляется — только move.) `pageId` нужен как durable мост файл↔Docmost-страница: title, путь, parent — всё мутабельно, id — нет; без него re-clone/rename плодят дубли. 2. **id ЕДЕТ ВМЕСТЕ С ФАЙЛОМ.** Критично: path-keyed сайдкар (`index.json` по пути) НЕ годится источником истины — если git не распознал rename (контент менялся вместе с именем, наш кейс), путь-ключ протухает и страница теряется. Поэтому id хранится так, что переживает любой move: - **Вариант B** — slugId суффиксом в ИМЕНИ файла (`Заметка ~Cj7YX7.md`). Ноль меты в контенте; имена «грязные»; адопция переименовывает файл юзера. - **Вариант C** — минимальный YAML-frontmatter `id:` в начале файла. Имена чистые; frontmatter скрыт в Obsidian; адопция имя не трогает (только дописывает). - **ОТКРЫТО:** B или C (см. ниже). Обе устойчивы. Сайдкар может остаться как опциональный КЭШ path→id, но не источник истины. Всё остальное (title/parent/spaceId/version) из файла убираем — выводимо. 3. **Папка = страница-родитель**, дерево зеркальное 1:1. 4. **Контент родителя — внутри его папки**, файлом-индексом (folder-note). ## Открытое решение: B vs C (носитель id) Различие свелось к UX, устойчивость равная (id в обоих едет с файлом): - **B (id в имени):** проще в реализации (парс суффикса), ноль меты в контенте, но Obsidian показывает ` ~slug` как заголовок заметки, и адопция переименовывает голый файл (→ может поехать `[[ссылка]]`). - **C (frontmatter `id:`):** чистые имена, скрытый нативный YAML, адопция имя не трогает (дописывает строку). Цена — одна строка «меты» в файле. - Рекомендация автора — **C** (чистые имена важнее, frontmatter идиоматичен), но это вкусовщина, решает владелец. - NB: ссылки между заметками ломаются при rename в ЛЮБОМ варианте (title=имя файла) — это не различает B/C, это отдельная фаза (см. ниже). ## Формат волта ``` / .gitmost/ index.json # вся служебная мета (см. ниже) Заметка.md # лист без детей: .md, чистый markdown Проект/ # страница-родитель = папка index.md # ТЕЛО самой страницы «Проект» (folder-note) Задача.md # ребёнок Подпроект/ index.md # тело «Подпроект» ... ``` - **Лист** (нет детей) → `<title>.md`. - **Родитель** (есть дети) → папка `<title>/`, тело в `<title>/index.md`. Когда у листа появляется первый ребёнок — `<title>.md` превращается в `<title>/index.md` (это безопасный move по pageId). - **Имя файла** = санитизированный title + `.md`. Родитель чьё имя = имя папки. - **Коллизии** (2+ сиблинга с одним title): всем коллидирующим — суффикс ` ~<slugId>` (`Заметка ~Cj7YX7.md`). slugId короткий и стабильный (из Docmost), суффикс детерминирован по самой странице, не по порядку. Смена суффикса — безопасный rename (идентичность в индексе по pageId). - `index.md` внутри папки «занят» телом родителя; ребёнок с title «index» получает `index ~slug.md`. ### `.gitmost/index.json` ```jsonc { "version": 1, "spaceId": "019…", // спейс = эта репа (одна на спейс) "pages": { // ключ — vault-relative путь файла (forward-slash) "Проект/index.md": { "pageId": "019…", "slugId": "Ab12Cd" }, "Проект/Задача.md": { "pageId": "019…", "slugId": "Ef34Gh" }, "Заметка ~Cj7YX7.md": { "pageId": "019…", "slugId": "Cj7YX7" } } } ``` - `title` — выводим из имени файла (stem), для папки — из имени папки. - `parentPageId` — выводим из ПУТИ (родитель = `index.md` ближайшей родительской папки; корневой файл → parent=null). Это уже path-as-truth на пуше сегодня. - `version`/`updatedAt` — не храним: факт правки берётся из git. - В индексе — только то, что НЕ выводимо: `pageId` (идентичность) и `slugId` (дизамбигуация коллизий, write-back при create). ## PULL (Docmost → vault) 1. Прочитать дерево спейса (как сейчас) + текущий `.gitmost/index.json`. 2. Построить layout: лист→`<t>.md`, родитель→`<t>/index.md`, коллизии→` ~slug`. 3. Записать ЧИСТЫЕ `.md` (только тело) + обновить `index.json` (path→{pageId,slugId}). 4. Старые `index.md`/файлы переехавших страниц — move (по pageId), не delete. 5. Коммит на `docmost`, merge в `main` (как сейчас). ## PUSH (vault → Docmost) 1. Дифф `last-pushed..main` (как сейчас). 2. Идентичность файла берём из `index.json` (path→pageId), НЕ из меты в файле. 3. Родитель — из пути (enclosing `<folder>/index.md` → его pageId из индекса). 4. Классификация: - файл/папка ЕСТЬ в индексе → update/move/rename по pageId (как сейчас, но идентичность из индекса). - файла НЕТ в индексе (новый голый файл) → **adopt**: create page (title=stem, parent=из пути), записать pageId/slugId в индекс + (если коллизия) переименовать файл с суффиксом. - голая папка с детьми, но без своей записи → создать страницу-родитель (пустое тело), завести `<folder>/index.md`, добавить в индекс. - файл пропал, а pageId ещё в дереве/индексе под другим путём → move (фикс уже есть). Реально пропал отовсюду → delete (как сейчас, под delete-cap). ## Миграция со старого формата Существующие волты несут `docmost:meta` в файлах и НЕ имеют `.gitmost/index.json`. - На первом цикле нового движка: если `.gitmost/index.json` нет — построить его из `docmost:meta`, прочитанных по файлам, затем **переписать** файлы без меты (один разовый «normalize» коммит) и разложить в folder-note layout. - **Фолбэк навсегда**: если у файла нашлась `docmost:meta`, а в индексе записи нет — уважаем мету (на случай файла, принесённого со старой системой). ## Краевые случаи - Git не хранит пустые папки → «омит файла родителя» работает только пока есть дети (папка жива за счёт детей). Childless пустая страница → свой `<t>.md`. - `.gitmost/` и `.obsidian/` (и любые dot-папки) — игнор движком; добавить дефолтный `.gitignore` для `.obsidian/` при инициализации волта. - Конфликт `index.md` (folder-note) с ребёнком title «index» → ребёнку суффикс. - Переименование папки (= rename родителя) → move всего поддерева, идентичность по pageId из индекса, без delete+create. ## План фаз (каждая — тесты на одноразовом стенде + юниты движка) 1. **Индекс-модуль**: чтение/запись `.gitmost/index.json` (pure + IO-инъекция), юниты. Без смены поведения. 2. **PULL пишет чистые файлы + индекс** (folder-note layout), миграция со старого формата. Юниты layout + e2e: Docmost→git даёт чистый markdown, folder-note. 3. **PUSH читает индекс** для идентичности/родителя (вместо меты в файле). e2e: push-правка/move/rename без потерь. 4. **Адопция**: голый `.md`/папка → страница. e2e: положить файл/папку через Obsidian-подобный клиент → появляются страницы. 5. **Чистка**: удалить `docmost:meta` из формата (оставить только фолбэк-парсер), `.gitignore` для `.obsidian/`. 6. **Синк ссылок между заметками** (см. ниже) — отдельный кусок, после формата. ## Ссылки между заметками (фаза 6) Поднял владелец: `title = имя файла`, значит при переименовании страницы ссылки на неё в волте протухают. - **В Docmost** ссылки — по pageId (mention/reference node редактора), переименование ПЕРЕЖИВАЮТ, не ломаются. Эта сторона безопасна. - **В волте** markdown-ссылки — по пути/имени (`[t](rel/path.md)` или `[[Имя]]`), при rename/move файла протухают. - Решение: при rename/move файла движок ПЕРЕПИСЫВАЕТ цель ссылок во всех файлах, что на него ссылались (Obsidian «update links on rename»). Альтернатива — хранить ссылки в волте по стабильному id (но это уже не человекочитаемый md; отвергаем для дружбы со сторонними редакторами). - Конвертер Docmost-mention ↔ markdown-link (обе стороны) — часть этой фазы. - Не влияет на выбор B/C и на фазы формата 1–5; выносим отдельно. ## Риски Это смена ФОРМАТА волта на data-loss-чувствительном движке (сегодня уже ловили тяжёлый баг с трашем живых страниц). Поэтому: каждая фаза — за фиче-флагом/инкрем., с юнит-тестами движка И браузерным e2e (`git-sync-browser-e2e.cjs`) + изолированные shell-e2e на одноразовом спейсе. Никаких «миграций на месте» без бэкапа волта.