Мне давно нравится идея хранить заметки обычными Markdown-файлами в своём репозитории на GitHub. Файлы остаются твоими, их можно открыть на github.com или в любом редакторе, а история изменений достаётся даром: это просто коммиты.Был проект BatNoter, который делал ровно это, но ему нужен был собственный сервер для входа через GitHub. В конце 2022 года сервер перестал отвечать, и приложение умерло.Я переписал его так, чтобы сервер был не нужен вовсе. Получился Notewing: браузер сам ходит в GitHub API, изменения копятся офлайн и отправляются одним коммитом, а конфликтующие правки не теряются. В статье расскажу, как устроена синхронизация через Git Data API, где были подводные камни и как всё это тестировать, не трогая настоящий GitHub. Как устроена синхронизация
Уровень сложностиСредний
Время на прочтение8 мин
Охват и читатели6.6K
Кейс
Мне давно нравится идея хранить заметки обычными Markdown‑файлами в своём репозитории на GitHub. Файлы остаются твоими, их можно открыть на github.com или в любом редакторе, а история изменений достаётся даром, это просто коммиты.
Был проект, который делал ровно это: BatNoter, 2,4 тысячи звёзд на GitHub. Веб‑приложение на React, заметки в вашем репозитории. Но у него был свой сервер на Go: через него шёл вход по OAuth и все запросы к GitHub. В конце 2022 года сервер batnoter.com перестал отвечать. В issue “Log in with github error” люди до сих пор спрашивают, есть ли решение.
Я переписал приложение так, чтобы сервер был не нужен вовсе. Получился Notewing: https://perruer.github.io/notewing/ (MIT, код на GitHub). В статье расскажу, как устроена синхронизация без бэкенда, где были подводные камни и как всё это тестировать, не трогая настоящий GitHub.

Notewing
Почему сервер не нуженСервер BatNoter решал две задачи:
OAuth. Для обмена кода авторизации на токен нужен client_secret, а секрет в браузере не спрячешь.
Прокси к API. Он брал токен из своей базы и ходил в GitHub от имени пользователя.
Вторая задача в браузере решается сама: api.github.com отдаёт CORS‑заголовки, и fetch из любого сайта с заголовком Authorization работает.
С первой сложнее. У GitHub есть device flow, где секрет не нужен, но эндпоинт github.com/login/device/code CORS не поддерживает. Из обычной веб‑страницы его не вызвать.
Я выбрал fine‑grained personal access token. Пользователь создаёт токен сам и даёт ему доступ ровно к одному репозиторию с правом Contents: Read and write. По сравнению с OAuth‑прокси это даже безопаснее:
токен никогда не покидает браузер и уходит только на api.github.com;
он не видит остальные репозитории;
его можно отозвать в один клик на GitHub.
Цена — лишний шаг при первом входе. Чтобы шаг был короче, ссылка на создание токена сразу заполняет название и описание.
Модель данных: снимок и очередьГлавная идея: приложение ничего не пишет в GitHub сразу. Любая правка сначала попадает в локальную очередь в IndexedDB. Поэтому:
интерфейс не ждёт сеть;
без интернета можно спокойно работать;
перезагрузка страницы ничего не теряет.
Локально хранятся две структуры:
interface Snapshot {
head: string | null; // коммит, на котором мы стоим
tree: string | null; // его дерево
files: Record<string, string>; // путь -> sha блоба
}
interface Change {
text?: string; // новый текст заметки
data?: string; // двоичный файл (картинка), base64
blob?: string; // уже существующий блоб (для переноса)
del?: true; // удаление
base: string | null; // sha файла, когда правка началась
rev: number; // номер правки
}
Рабочая копия — это snapshot.files, поверх которого наложена очередь pending: Record<path, Change>.
Поле base — ключ ко всему остальному: оно запоминает, от какой версии файла мы отталкивались.
Небольшая, но приятная деталь: sha блоба git можно посчитать прямо в браузере. Это SHA-1 от строки blob <длина>\0<содержимое>:
export async function blobSha(data: string | Uint8Array): Promise<string> {
const body = typeof data === "string" ? utf8(data) : data;
const head = utf8(`blob ${body.length}\0`);
const all = new Uint8Array(head.length + body.length);
all.set(head);
all.set(body, head.length);
const digest = await crypto.subtle.digest("SHA-1", all);
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
Благодаря этому, если пользователь напечатал текст, а потом вернул его к исходному, правка просто исчезает из очереди: sha совпал с base. Лишнего коммита «ничего не изменил» не будет.
Самый очевидный способ писать в репозиторий — Contents API (PUT /repos/{owner}/{repo}/contents/{path}). Но каждый вызов этого метода делает отдельный коммит. Переименование заметки превращается в два коммита, «создать» и «удалить», а перенос папки с двадцатью заметками — в сорок.
Поэтому Notewing работает на уровень ниже, через Git Data API, как это делает сам git:
POST /git/trees с base_tree — новое дерево. Текстовые файлы передаются прямо полем content, удаления — как sha: null, картинки сначала загружаются через POST /git/blobs.
POST /git/commits — коммит с этим деревом и родителем head.
PATCH /git/refs/heads/main с force: false — передвинуть ветку.
Сколько бы изменений ни накопилось, всё уходит одним коммитом. При переносе файла не нужно даже перекачивать содержимое: в дерево кладётся sha существующего блоба под новым путём.
for (const [path, c] of batch) {
if (c.del) entries.push({ path, mode: "100644", type: "blob", sha: null });
else if (c.text !== undefined) entries.push({ path, mode: "100644", type: "blob", content: c.text });
else if (c.data !== undefined) entries.push({ path, mode: "100644", type: "blob", sha: await gh.createBlob(ref, c.data) });
else entries.push({ path, mode: "100644", type: "blob", sha: c.blob! }); // перенос
}
const tree = await gh.createTree(ref, snapshot.tree, entries);
const commit = await gh.createCommit(ref, message(batch), tree, [snapshot.head]);
await gh.updateRef(ref, commit); // force: false
force: false — это наша защита от гонок. Если с другого устройства в ветку успели что‑то закоммитить, GitHub ответит 422 Update is not a fast forward. Тогда мы подтягиваем свежий снимок, перебазируем очередь и пробуем ещё раз.
При подтягивании новой версии для каждой правки в очереди сравниваются три sha:
base — версия, от которой мы начали;
now — что сейчас на GitHub;
mine — что получится после нашей правки.
for (const [path, c] of Object.entries(this.pending)) {
const now = remote[path] ?? null;
if (now === c.base) continue; // там файл не трогали
if (now === await this.targetSha(c)) { drop(path); continue; } // обе стороны пришли к одному
if (c.del) { drop(path); continue; } // мы удалили, там изменили: оставляем их версию
if (now === null) { c.base = null; continue; } // там удалили, мы изменили: создаём заново
// изменили с обеих сторон
saveAsConflictCopy(path, c); // "Plan (conflict 2026-09-22 1530).md"
}
Я сознательно не стал делать трёхстороннее слияние текста. Для заметок самый понятный вариант такой: путь остаётся за удалённой версией, а ваш текст сохраняется рядом копией. Пользователь получает уведомление с кнопкой «Открыть». Ничего не теряется и ничего не склеивается молча.
Отдельно пришлось обработать правки, которые пользователь сделал во время отправки. Для этого и нужен rev: после успешного коммита из очереди убираются только те записи, чей rev не изменился. У остальных просто обновляется base.
Пока всё было проверено на репозитории с README, всё работало. На пустом репозитории всё сломалось: Git Data API отвечает 409 Git Repository is empty, причём и на создание блоба, и на создание дерева.
Решение — первый файл отправляется старым Contents API, который умеет создавать первый коммит. Всё остальное уходит следующим циклом через Git Data API.
Подводный камень: управляемый редакторРедактор — CodeMirror 6. Сначала я подключил его как обычный управляемый компонент: есть value, есть onChange, а если value снаружи отличается от документа, документ заменяется. Это дало две ошибки, одна другой хуже.
Первая. При быстром наборе редактор зависал намертво. Пользователь набирает «a», потом «b». Документ уже …ab, а компонент ещё не перерисовался и передаёт старое value = …a. Редактор «исправляет» документ обратно на …a, это вызывает onChange, и два состояния начинают бесконечно гонять друг друга.
Вторая, коварнее. Любая программная замена документа тоже вызывала onChange и считалась правкой пользователя. Если в момент открытия заметки приходила свежая версия с GitHub, старый текст мог уйти в очередь как «изменение» и затереть её.
Обе ошибки поймали E2E‑тесты, и лечение у них одно: внешние изменения передаются в редактор не значением, а явной ревизией, и помечаются аннотацией.
const external = Annotation.define<boolean>();
// в редакторе: вызываем onChange только для правок пользователя
EditorView.updateListener.of((u) => {
if (u.docChanged && u.transactions.some((tr) => !tr.annotation(external))) onChange(u.state.doc.toString());
});
// снаружи пришёл новый текст (обновление с GitHub, галочка в просмотре, восстановленная версия)
useEffect(() => {
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value }, annotations: external.of(true) });
}, [revision]);
Картинки из приватного репозиторияСкриншот можно вставить в заметку из буфера обмена. Он сохраняется в assets/ в репозитории, а в текст вставляется относительная ссылка . Благодаря этому картинка видна и на github.com.
А вот показать её в предпросмотре не так просто: для приватного репозитория raw.githubusercontent.com без авторизации отдаст 404. Поэтому предпросмотр находит относительные src, загружает файл через API с токеном (или из локальной очереди, если картинку ещё не отправили) и подставляет blob: URL.
Раз токен живёт в браузере, любой XSS превращается в утечку токена. А заметки — это произвольный HTML из репозитория. Поэтому:
весь отрендеренный Markdown проходит через DOMPurify. Разрешены чекбоксы задач, внешние ссылки получают rel="noopener noreferrer";
в собранной версии стоит строгая CSP: script-src 'self', connect-src https://api.github.com и никаких сторонних доменов.
Даже если санитайзер что‑то пропустит, отправить токен на чужой сервер скрипт не сможет. В E2E есть отдельный тест, который кладёт в заметку <img onerror>, <script> и javascript:‑ссылку и проверяет, что ничего не выполнилось.
Три слоя:
IndexedDB — снимок репозитория, тексты заметок (по sha блоба, поэтому неизменные файлы не перекачиваются) и очередь правок;
service worker — кеширует оболочку приложения, в том числе при размещении в подпапке. Поэтому установленное PWA стартует без сети;
фоновая загрузка — после синхронизации в фоне докачиваются тексты всех заметок, чтобы работал поиск.
Интерфейс в это время перерисовывается пачками, а не на каждую заметку. На репозитории из тысячи файлов иначе всё тормозит.
Как это тестировать без GitHubБольше всего времени сэкономило одно решение: я написал FakeGitHub — эмуляцию нужной части GitHub REST API в памяти. Она умеет:
блобы, деревья, коммиты и ветки;
ответ 422 на не fast‑forward;
409 на пустом репозитории;
историю файла.
Эта эмуляция используется в трёх местах:
Юнит‑тесты движка синхронизации: конфликты, гонка при отправке, правки во время отправки, офлайн с перезагрузкой, пустой репозиторий.
E2E‑тесты в Chrome и Firefox. Puppeteer перехватывает запросы к api.github.com и отвечает из той же эмуляции. В Firefox (WebDriver BiDi) пришлось брать тело запроса через fetchPostData(): синхронный postData() там не поддерживается.
Демо‑режим на сайте. Кнопка «Попробовать демо» запускает приложение на той же эмуляции прямо в странице, без токена и без сети.
Плюс отдельный живой тест, который прогоняет те же сценарии на настоящем GitHub в одноразовом репозитории.
Что получилосьЗаметки — обычные .md файлы, репозиторий из BatNoter подходит как есть.
Работает офлайн, ставится как приложение.
Поиск (Ctrl+K), картинки, история версий с восстановлением, перетаскивание, тёмная тема, интерфейс на русском, английском и китайском.
Нет сервера, нет аналитики, нет аккаунта. Для размещения у себя достаточно распаковать архив на любой статический хостинг.
Демо без регистрации: https://perruer.github.io/notewing/ Код: https://github.com/Perruer/notewing
Чего пока нет: GitLab и Gitea (их просят в issues BatNoter), шифрование заметок. Если бы вам это пригодилось, напишите в комментариях, какой вариант нужнее.
Проект открытый и бесплатный.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Книга из переписки без сервера: canvas, jsPDF и брошюра для печати прямо в браузере | 1 | 8.1 | 28-09-2026 |
| 2 | Трон для самого звёздного: я сделал игру, которая живёт внутри GitHub‑репозитория | 0 | 8.1 | 01-10-2026 |
| 3 | ИИ-ревью, или как я себя чуть не уволил | 0 | 9.94 | 02-10-2026 |
| 4 | «Интернета нет — а сайт работает»: как мы учили PWA замечать авиарежим на iPhone | 0 | 8.82 | 29-09-2026 |
| 5 | О том, как я написал свой стейт‑менеджер | 0 | 9.14 | 27-09-2026 |
| 6 | [Перевод] Ветвление оказалось дороже лишней работы: как GitHub разогнал обработку исходного кода до 45 ГиБ/с | 0 | 10.36 | 21-08-2026 |
| 7 | Flutter на клиенте, Fletch на сервере: бэкенд на знакомом Dart | 0 | 8.42 | 02-10-2026 |
| 8 | Как я писал сервер и нечаянно пробил 1М RPS | 0 | 10 | 31-08-2026 |
| 9 | YApi заброшен с 2022 года. Я продолжил его и нашёл токены, которые может подделать любой участник проекта | 0 | 9.96 | 25-09-2026 |