В инфраструктурной жизни регулярно встречается задача синхронизации исходного кода между разными Git-серверами. Причины могут быть самыми разными: резервное копирование репозиториев, публикация внутреннего проекта во внешний контур, перенос разработки между площадками, миграция между Git-сервисами или поддержка нескольких независимых зеркал одного и того же проекта.

Обычно подобные задачи решают через Jenkins, GitLab CI/CD, вебхуки, зеркалирование репозиториев или специализированные системы доставки кода. Однако далеко не всегда хочется поднимать отдельную инфраструктуру ради простой операции копирования содержимого из одного репозитория в другой. Особенно если синхронизация требуется для нескольких проектов или выполняется по расписанию один-два раза в сутки.

В таких случаях вполне достаточно обычного Bash-скрипта, который использует только Git и rsync. Такой подход обладает несколькими преимуществами. Во-первых, отсутствуют дополнительные зависимости. Во-вторых, решение работает практически на любом Linux-сервере. В-третьих, вся логика остаётся прозрачной и легко читаемой даже спустя несколько лет эксплуатации.

Общая логика работы

Логика скрипта предельно проста.

Сначала выполняется клонирование исходного репозитория во временный каталог. Затем клонируется целевой репозиторий. После этого содержимое исходного проекта полностью синхронизируется в целевой каталог через rsync. Если обнаруживаются изменения, создаётся коммит и выполняется отправка изменений в удалённый репозиторий.

Получается своеобразная односторонняя репликация содержимого проекта.

Схематично процесс выглядит следующим образом:

Исходный Git
      │
      ▼
Временный каталог
      │
      ▼
     rsync
      │
      ▼
Целевой Git

Подобный подход удобен тем, что история коммитов в целевом репозитории остаётся независимой. Мы переносим именно содержимое проекта, а не весь набор веток и объектов Git.

Конфигурация репозиториев

В верхней части скрипта располагается блок настроек.

SOURCE_REPO="https://git.example.com/team/project.git"
SOURCE_BRANCH="develop"

TARGET_REPO="https://git.backup.local/team/project.git"
TARGET_BRANCH="main"

Здесь задаются адреса репозиториев и ветки, участвующие в синхронизации.

Отдельно указываются учётные данные для доступа по HTTPS. Такой вариант не требует настройки SSH-ключей и часто оказывается удобным для сервисных учётных записей.

SOURCE_USER="user"
SOURCE_PASSWORD="password"

TARGET_USER="user"
TARGET_PASSWORD="password"

Перед запуском выполняется проверка конфигурации. Если какие-либо обязательные параметры не заполнены, скрипт завершается с ошибкой. Это позволяет избежать неприятных ситуаций, когда в рабочую систему случайно попадают тестовые настройки.

Безопасная работа во временном каталоге

Для выполнения синхронизации создаётся отдельный временный каталог.

WORKDIR="${SYNC_WORKDIR:-${TMPDIR:-/tmp}/repo-sync-$$}"

Использование идентификатора процесса делает путь уникальным для каждого запуска. Благодаря этому несколько экземпляров скрипта не будут конфликтовать друг с другом.

После завершения работы временные файлы автоматически удаляются через обработчик EXIT.

trap cleanup EXIT

Даже если выполнение завершится аварийно, рабочий каталог не останется лежать в файловой системе.

Клонирование исходного репозитория

Для получения данных используется неглубокое клонирование.

git fetch --depth 1 origin "${SOURCE_BRANCH}"
git checkout FETCH_HEAD

Параметр depth=1 позволяет получить только актуальное состояние ветки без загрузки полной истории проекта.

На крупных репозиториях это может сократить объём передаваемых данных на сотни мегабайт или даже гигабайты.

Если проект использует подмодули, они также инициализируются.

git submodule update --init --recursive

Это позволяет синхронизировать проект полностью, включая вложенные зависимости.

Клонирование целевого репозитория

Следующим этапом происходит клонирование целевого проекта.

Скрипт пытается сразу переключиться на необходимую ветку. Если ветка отсутствует локально, выполняется поиск среди удалённых ссылок. Если и там ничего не найдено, создаётся новая ветка.

Подобная логика позволяет использовать один и тот же скрипт как для уже существующих репозиториев, так и для новых площадок, которые только начинают получать данные.

Синхронизация через rsync

Основная работа выполняется командой rsync.

rsync -a --delete \
  --exclude '.git/' \
  --exclude '.git' \
  source/ target/

Ключ archive сохраняет права доступа, временные метки и структуру каталогов.

Параметр delete обеспечивает удаление файлов, которые были удалены в источнике.

Исключение каталога .git позволяет сохранить независимую историю целевого репозитория.

По сути после выполнения rsync содержимое обоих каталогов становится идентичным.

Это значительно проще и надёжнее, чем пытаться выполнять сложные операции с ветками, cherry-pick или автоматическим merge.

Проверка изменений

Перед созданием коммита скрипт анализирует текущее состояние репозитория.

git status --porcelain

Если изменений нет, выполнение завершается.

Такой подход избавляет от появления большого количества пустых коммитов и лишних операций push.

Кроме того, это уменьшает нагрузку на Git-сервер и делает историю изменений более чистой.

Режим предварительной проверки

Для безопасного тестирования предусмотрен режим Dry Run.

Запуск выглядит следующим образом:

DRY_RUN=1 ./sync.sh

В этом режиме отображается список файлов, которые будут изменены.

git status --short

При этом коммит и отправка изменений не выполняются.

Это особенно полезно после изменения логики синхронизации или перед первым запуском на продуктивной системе.

Автоматическое формирование сообщений коммита

Если сообщение коммита не задано вручную, скрипт формирует его автоматически.

В сообщение попадает короткий идентификатор исходного коммита и текущее время синхронизации.

Пример:

Sync from source repository @ 4f7d2c1 (2026-07-04T10:15:32Z)

Подобный формат позволяет быстро определить, из какого состояния исходного проекта были получены изменения.

Запуск по расписанию

Наиболее логичный способ эксплуатации - запуск по расписанию.

Для классического cron задача может выглядеть так:

*/30 * * * * /opt/scripts/sync.sh >> /var/log/repo-sync.log 2>&1

Аналогичная настройка для systemd timer:

[Timer]
OnCalendar=*:0/30
Persistent=true

Для современных Linux-серверов вариант с systemd timer обычно оказывается удобнее, поскольку предоставляет встроенный журнал выполнения и средства контроля состояния задач.

Почему не Git Mirror, GitLab CI/CD или Jenkins

Когда показываешь подобный скрипт, почти всегда находится человек, который задаёт вполне логичный вопрос: зачем писать Bash-скрипт, если существуют зеркала репозиториев, GitLab CI/CD, Jenkins, Gitea Actions и множество других готовых инструментов?

Проблема в том, что инфраструктура далеко не всегда выглядит как учебный стенд из документации. В крупных компаниях, государственных организациях и системах с повышенными требованиями безопасности часто существуют полностью изолированные контуры. Один Git-сервер может находиться в закрытой сети без доступа в интернет, другой - в смежном сегменте, третий - вообще в отдельном контуре с собственными правилами маршрутизации и контроля доступа.

В таких условиях встроенное зеркалирование репозиториев зачастую оказывается бесполезным. Git-сервер просто не может самостоятельно достучаться до другой стороны. Иногда исходящий трафик запрещён полностью. Иногда разрешён только HTTPS через определённый шлюз. Иногда служба безопасности запрещает использование SSH-ключей между контурами. Бывают и более экзотические случаи, когда два Git-сервера работают на разных платформах и поддерживают разные механизмы аутентификации.

Отдельная история связана с CI/CD-системами. Чтобы использовать Jenkins или GitLab CI/CD для синхронизации, необходимо где-то разместить агент, обеспечить ему доступ одновременно к обоим контурам, настроить хранение секретов, журналирование, управление жизненным циклом и резервирование. В результате задача копирования нескольких каталогов неожиданно превращается в отдельный инфраструктурный проект.

У меня несколько раз встречалась ситуация, когда между двумя Git-серверами существовал всего один разрешённый маршрут через промежуточный сервер. На этом сервере не было ни Jenkins, ни GitLab Runner, ни Kubernetes. Зато был обычный Linux, установленный Git и возможность запускать cron. В такой конфигурации Bash-скрипт из нескольких десятков строк оказывается не временным костылём, а самым простым и надёжным решением.

Кроме того, данный подход позволяет выполнять не только зеркалирование Git-объектов, но и модификацию содержимого перед публикацией. Например, можно автоматически удалять внутреннюю документацию, исключать служебные каталоги, подменять конфигурационные файлы, очищать секреты или публиковать только определённую часть репозитория. Встроенные механизмы зеркалирования Git-серверов обычно таких возможностей не предоставляют.

Поэтому данный скрипт не является заменой полноценным CI/CD-конвейерам или встроенному зеркалированию репозиториев. Скорее это инструмент для тех случаев, когда инфраструктура диктует свои правила и между красивой архитектурой на диаграмме и реальной жизнью внезапно оказывается несколько сетевых экранов, два закрытых контура и письмо от службы безопасности на пять страниц. В таких условиях старый добрый Bash иногда оказывается самым прагматичным решением.

Возможные улучшения

Несмотря на простоту, скрипт легко развивается под производственные требования.

Можно вынести учётные данные в переменные окружения, подключить Vault для получения секретов, добавить уведомления в Telegram или Mattermost, настроить экспорт метрик в Prometheus, реализовать блокировки от параллельного запуска и добавить журналирование в формате JSON.

Однако даже в текущем виде решение остаётся полезным инструментом для небольших команд и инфраструктурных задач. Иногда несколько десятков строк Bash-кода оказываются гораздо эффективнее, чем отдельный сервер автоматизации, набор конвейеров и сложная система интеграций.

Полный текст скрипта

#!/usr/bin/env bash
# Локальный скрипт синхронизации репозиториев
#
# Использование:
#   ./sync.sh
#   DRY_RUN=1 ./sync.sh

set -euo pipefail

# ---------------------------------------------------------------------------
# Конфигурация
# ---------------------------------------------------------------------------

SOURCE_REPO="https://git.example.com/team/project.git"
SOURCE_BRANCH="develop"
SOURCE_USER="your_source_username"
SOURCE_PASSWORD="your_source_password"

TARGET_REPO="https://git.example.com/backup/project.git"
TARGET_BRANCH="main"
TARGET_USER="your_target_username"
TARGET_PASSWORD="your_target_password"

COMMIT_MESSAGE=""

# ---------------------------------------------------------------------------

DRY_RUN="${DRY_RUN:-0}"
WORKDIR="${SYNC_WORKDIR:-${TMPDIR:-/tmp}/repo-sync-$$}"

require_config() {
  local name="$1"
  local value="$2"
  if [[ -z "${value}" || "${value}" == your_* ]]; then
    echo "sync: задайте ${name} в конфиге скрипта" >&2
    exit 1
  fi
}

auth_url() {
  local user="$1"
  local password="$2"
  local url="$3"

  if [[ ! "${url}" =~ ^https:// ]]; then
    echo "sync: поддерживается только HTTPS: ${url}" >&2
    exit 1
  fi

  local host_path="${url#https://}"
  host_path="${host_path#/}"
  printf 'https://%s:%s@%s\n' "${user}" "${password}" "${host_path}"
}

run_git() {
  local dir="$1"
  shift
  git -C "${dir}" "$@"
}

cleanup() {
  if [[ -n "${WORKDIR:-}" && -d "${WORKDIR}" ]]; then
    rm -rf "${WORKDIR}"
  fi
}

trap cleanup EXIT

require_config "SOURCE_USER" "${SOURCE_USER}"
require_config "SOURCE_PASSWORD" "${SOURCE_PASSWORD}"
require_config "TARGET_USER" "${TARGET_USER}"
require_config "TARGET_PASSWORD" "${TARGET_PASSWORD}"
require_config "TARGET_REPO" "${TARGET_REPO}"
require_config "TARGET_BRANCH" "${TARGET_BRANCH}"

SOURCE_AUTH="$(auth_url "${SOURCE_USER}" "${SOURCE_PASSWORD}" "${SOURCE_REPO}")"
TARGET_AUTH="$(auth_url "${TARGET_USER}" "${TARGET_PASSWORD}" "${TARGET_REPO}")"

mkdir -p "${WORKDIR}"

echo "sync: клонирование источника"
run_git "${WORKDIR}" init source
run_git "${WORKDIR}/source" remote add origin "${SOURCE_AUTH}"
run_git "${WORKDIR}/source" fetch --depth 1 origin "${SOURCE_BRANCH}"
run_git "${WORKDIR}/source" checkout FETCH_HEAD

echo "sync: клонирование назначения"

if run_git "${WORKDIR}" clone --branch "${TARGET_BRANCH}" "${TARGET_AUTH}" target 2>/dev/null; then
  :
else
  run_git "${WORKDIR}" clone "${TARGET_AUTH}" target
fi

rsync -a --delete \
  --exclude '.git/' \
  --exclude '.git' \
  "${WORKDIR}/source/" "${WORKDIR}/target/"

cd "${WORKDIR}/target"

if git diff --quiet && git diff --cached --quiet && [[ -z "$(git status --porcelain)" ]]; then
  echo "sync: изменений нет"
  exit 0
fi

git status --short

if [[ "${DRY_RUN}" == "1" ]]; then
  echo "sync: DRY_RUN=1"
  exit 0
fi

git add -A

if [[ -z "${COMMIT_MESSAGE}" ]]; then
  source_sha="$(git -C "${WORKDIR}/source" rev-parse --short HEAD)"
  COMMIT_MESSAGE="Sync from source repository @ ${source_sha}"
fi

git commit -m "${COMMIT_MESSAGE}"
git push origin "${TARGET_BRANCH}"

echo "sync: готово"