Работая с клиентами на таких платформах, как Github или Gitlab, мы в Gitstart часто сталкиваемся с необходимостью синхронизировать кодовые базы между удаленными репозиториями. По каждой поставленной клиентом задаче наши разработчики работают внутри ветви частного репозитория (клона или форка клиентского репозитория), и необходима уверенность, что состояние клиентского репозитория соответствует нашему, как и наоборот. Со временем синхронизация репозиториев становится повторяющимся процессом, поэтому имеет смысл его как-то автоматизировать.
Встречайте Gitstart Fork: это наш внутренний инструмент, который использует мощь веб-хуков, чтобы синхронизировать код между парой репозиториев практически в реальном времени.
Но, спросите вы, как это работает? Чтобы упростить кодовую базу, мы решили разделить функциональность на две части:
Pull
: перемещение изменений из клиентского репозитория в наш репозиторий.Push
: перемещение изменений из нашего репозитория в клиентский репозиторий.
В этой статье мы сосредоточимся на обсуждении Pull
.
Стек технологий: большую часть кода мы пишем на TypeScript и Nodejs. В качестве базы данных мы выбрали PostgreSQL с Hasura в качестве движка GraphQL (там есть изящная функция под названием подписка, которая упрощает обработку).
Для простоты мы будем говорить о репозиториях Github, однако написанное можно распространить на любой удаленный сервис на основе git, к примеру Gitlab или Bitbucket.
1. Функции
Fork Pull предоставляет следующий функционал для синхронизации:
- Управление ветвями: укажите, какие именно ветви нужно синхронизировать.
- Детализированное управление синхронизацией файлов: укажите, какие именно папки/файлы будут синхронизированы, а какие — нет.
- Поддержка
.gitignore
: синхронизация не затронет файлы, перечисленные в.gitignore
.
2. База данных
Всё начинается с таблицы, где отслеживаются оба синхронизируемых репозитория: какие ветви синхронизировать в этих репозиториях, а также любые файлы или папки, которые синхронизировать не нужно. Высокоуровневая схема будет выглядеть следующим образом:
tableName: git_repo_slices
- id: integer
- fromRepo: string
- toRepo: string
- fromBranch: string
- toBranch: string
- ignored: string[]
- folders: string[]
Мы также ведем учет всех так называемых “извлечений” (pull
), которые мы сделали:
tableName: git_slice_pulls
- id: integer
- startedAt: timestamp
- finishedAt: timestamp
- error: string
- commitSlice: relation to git_commit_slices
- repoSlice: relation to git_repo_slices
Эта таблица служит промежуточным звеном между началом и окончанием pull
’а. Мы в основном используем его для отслеживания хода извлечения. Так можно оценить успех или неудачу pull
, узнать о произошедших ошибках, рассчитать необходимое для операции время и многое другое.
Успешный pull
регистрируется в третьей таблице, которая отслеживает пару головных коммитов синхронизированных ветвей (важность этой таблицы станет ясна в следующем разделе):
tableName: git_commit_slice
- id: integer
- targetCommit: string
- originCommit: string
Примечание: все схемы здесь даны исключительно для того, чтобы создать общее представление. Поля можно как включать, так и исключать, в зависимости от желаемой функциональности.
3. Отслеживание изменений
База данных — это отлично, но как мы определяем, когда нужно делать pull
? В конце концов, это самая важная часть инструмента, который претендует на “работу в реальном времени”. Вот где проявляется магия веб-хуков и подписок Hasura! У нас в Gistart уже создана довольно надежная и масштабируемая инфраструктура веб-хуков. Вкратце ее можно резюмировать так:
- Все веб-хуки со сторонних сервисов, таких как Github или Jira, хранятся в нашей базе данных.
- Впоследствии эти веб-хуки обрабатываются и соответствующие таблицы обновляются соответствующим образом.
- Если обработка любой полезной нагрузки веб-хука не удается, мы ждем 5 минут, прежде чем повторить попытку.
Для наших целей нужно сосредоточиться только на одном типе событий Github: push
. Это событие происходит всякий раз, когда один или несколько коммитов помещаются в ветку или тэг репозитория.
Это вебхук-событие мы объединяем с щепоткой магии подписок и получаем такую последовательность событий для обнаружения изменений:
- Коммиты передаются в наше хранилище исходного кода
fromRepo
. - Веб-хук
push
регистрируется в базе данных и обрабатывается. - Это изменяет головной коммит
SHA-1
исходной ветви в нашей базе данных. - Как только база данных обновляется, подписка (см. GraphQL-код ниже) замечает, что ветвь основного репозитория содержит новые коммиты, которые еще не были извлечены, и запускает в соответствии с этим обработчик:
subscription subGitSlicePull {
git_repo_slices(
where: {
_not: {
git_repo_sliced_from: {
git_branch: {
git_commit: {
git_commit_slice_by_origin: { id: { _is_null: false } }
}
}
}
}
}
) {
id
}
}
- Затем код выполняет
pull
и репозитории синхронизируются (подробнее об этом в следующем разделе).
Основным триггером события pull
служит обновление базы данных. В нашем случае это достигается с помощью веб-хуков благодаря их способности реагировать в реальном времени. Однако того же самого результата можно добиться и через ряд других вариантов.
Внимание: следующие шаги предполагают, что база данных актуализирована.
4. Архитектура Git
И здесь начинается настоящее веселье. На самом деле, выполнить pull
не очень сложно. В качестве клиента git мы используем isomorphic-git. Операции git можно разделить на 5 этапов:
4.1 Клонирование (clone
)
Репозитории мы клонируем во временные папки. Если такие папки уже существуют, мы просто извлекаем последние коммиты и переключаемся на интересующие нас ветви. Ни удаления папок, ни повторного клонирования каждый раз не происходит, потому что pull производится быстрее, чем клонирование.
4.2 Удаление (delete)
В целях синхронизации мы убираем и удаляем из индекса git все файлы/папки, присутствующие в toRepo
, но не в fromRepo
, следя за тем, чтобы исключить игнорируемые файлы/папки. Для этого мы применяем glob matching
(глобальное сопоставление): определяем разницу между наборами путей к файлам и удаляем их:
const toFiles = (
await globby(["**/*.*", "**/.*"], {
cwd: path.relative("", toDir),
gitignore: true,
})
).filter((fp) => !ignored.some((f) => fp.startsWith(f)));
const fromFiles = (
await globby(["**/*.*", "**/.*"], {
cwd: path.relative("", fromDir),
gitignore: true,
})
).filter(
(fp) =>
folders.some((f) => fp.startsWith(f)) &&
!ignored.some((f) => fp.startsWith(f))
);
const filesToDelete = _.difference(toFiles, fromFiles);
await Promise.all(
filesToDelete.map((fp) => fs.promises.unlink(path.join(toDir, fp)))
);
await Promise.all(
filesToDelete.map((fp) => git.remove({ fs, dir: toDir, filepath: fp }))
);
4.3 Копирование (copy)
Теперь мы копируем все файлы из fromRepo
в toRepo
с помощью легкой библиотекиcopy-dir
. Эта библиотека предоставляет фильтр для фильтрации путей к файлам, что в нашем случае полезно:
copyDir(
fromDir,
toDir,
{
utimes: true,
mode: true,
cover: true,
filter: (stat: string, filepath: string) => {
if (stat === "directory") return true;
return (
folderPaths.some((fp) => filepath.startsWith(fp)) &&
!ignoredPaths.some((fp) => filepath.startsWith(fp))
);
},
},
(error: any) => {
if (error) reject(error);
resolve();
}
);
4.4 Добавление и коммит (Add & commit)
Простой шаг, на котором мы добавляем все пути к файлам в будущий коммит (стейджинг) и делаем коммит, получая SHA-1
головного коммита для нужд базы данных.
4.5 Отправление (push)
Наконец мы отправляем коммиты в наш toBranch
. Этот push
вызовет веб-хук, который мы впоследствии обработаем ради сохранения целостности нашей базы данных.
5. Заключение
И вот, репозитории наконец-то синхронизированы!
Это похоже на волшебство, но именно так оно и ощущается, когда технология используется изящно и с умом.
Спасибо за чтение!
Читайте также:
- Как сжимать коммиты в Git с помощью git squash
- Простой способ взлома сайта для получения его Git-данных
- Прекращайте пользоваться Git CLI
Читайте нас в Telegram, VK и Яндекс.Дзен
Перевод статьи: Navoneel Bera, “Syncing Git Repos in Real-Time: Part 1”