Работая с клиентами на таких платформах, как 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. Заключение

И вот, репозитории наконец-то синхронизированы! 

Это похоже на волшебство, но именно так оно и ощущается, когда технология используется изящно и с умом. 

Спасибо за чтение!

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи: Navoneel Bera, “Syncing Git Repos in Real-Time: Part 1”

Предыдущая статья25 наборов аудиоданных для исследований
Следующая статьяКак восстановить положение прокрутки виджета RecyclerView