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>
181 lines
13 KiB
Markdown
181 lines
13 KiB
Markdown
# git-sync: thin meta + third-party-editor support
|
|
|
|
Статус: **дизайн (согласован с владельцем 2026-06-24), к реализации.**
|
|
|
|
## Зачем
|
|
|
|
Сейчас каждый `.md` в волте несёт служебный блок
|
|
`<!-- docmost:meta {version,pageId,slugId,title,spaceId,parentPageId} -->` и
|
|
`<!-- docmost:comments [...] -->`. Сторонние редакторы (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, это отдельная фаза (см. ниже).
|
|
|
|
## Формат волта
|
|
|
|
```
|
|
<Space-vault>/
|
|
.gitmost/
|
|
index.json # вся служебная мета (см. ниже)
|
|
Заметка.md # лист без детей: <title>.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 на одноразовом спейсе. Никаких «миграций на месте» без бэкапа волта.
|