В инфраструктурной жизни регулярно встречается задача синхронизации исходного кода между разными 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 обычно оказывается удобнее, поскольку предоставляет встроенный журнал выполнения и средства контроля состояния задач.
Возможные улучшения
Несмотря на простоту, скрипт легко развивается под производственные требования.
Можно вынести учётные данные в переменные окружения, подключить 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: готово"