Skip to content

Instantly share code, notes, and snippets.

@Kenya-West
Last active May 2, 2026 14:54
Show Gist options
  • Select an option

  • Save Kenya-West/16d1f0f4134b005a63066e7794444929 to your computer and use it in GitHub Desktop.

Select an option

Save Kenya-West/16d1f0f4134b005a63066e7794444929 to your computer and use it in GitHub Desktop.
Scripts that implement Git mirroring from one source repo to multiple destinations

Below is a complete implementation using a sourced Bash config file. This is the most Bash-native format and avoids YAML/JSON parsing dependencies.


Usage

chmod +x git-mirror.sh
chmod 600 mirror.conf

./git-mirror.sh --config ./mirror.conf --verbose

Example cron entry:

*/15 * * * * /opt/git-mirror/git-mirror.sh --config /opt/git-mirror/mirror.conf >> /var/log/git-mirror.log 2>&1

Important behavior

The script treats the source repo as authoritative. It fetches from source, resets the local working copy to the source branch, then pushes to every destination.

Destination failures are isolated. If one destination fails, the script continues pushing to the remaining destinations. The final exit code is:

0 = all destinations succeeded
2 = one or more destinations failed
1 = fatal setup/config/source error

Secrets are not printed intentionally, and repository URLs are masked when logged. PATs still live in the config file, so the config should be protected with chmod 600.

Credits

GPT-5.5 lol

# Bash-native config file.
# This file is sourced by git-mirror.sh.
# Keep permissions strict if it contains secrets:
# chmod 600 mirror.conf
# -------------------------------------------------------------------
# Source repository
# -------------------------------------------------------------------
SOURCE_URL="https://github.com/example/source-repo.git"
# Optional. If empty, the repository's default branch is used.
SOURCE_BRANCH="main"
# Used only when SOURCE_AUTH_METHOD="ssh".
SOURCE_SSH_KEY="$HOME/.ssh/id_rsa"
# -------------------------------------------------------------------
# Global authentication defaults
# -------------------------------------------------------------------
# Global SSH key used unless overridden.
GLOBAL_SSH_KEY="$HOME/.ssh/id_rsa"
# -------------------------------------------------------------------
# Work directory behavior
# -------------------------------------------------------------------
# Supported values:
# temp - clone into a temporary directory and delete after run
# persistent - reuse local clone between runs
WORK_MODE="persistent"
# Required when WORK_MODE="persistent".
WORK_DIR="/var/lib/git-mirror/source-repo"
# -------------------------------------------------------------------
# Push behavior
# -------------------------------------------------------------------
# Push tags after branch push.
MIRROR_TAGS="true"
# Use with care.
# false = normal push
# true = git push --force
PUSH_FORCE="false"
# -------------------------------------------------------------------
# Destinations
# -------------------------------------------------------------------
# Destination names must be Bash-safe identifiers:
# letters, numbers, underscore. No dash.
DESTINATIONS=(
"github_backup"
"gitlab_backup"
"ssh_backup"
)
# Destination 1: uses global PAT.
DEST_github_backup_URL="https://github.com/example/source-repo-backup.git"
DEST_github_backup_BRANCH="main"
# Destination 2: overrides PAT.
DEST_gitlab_backup_URL="https://gitlab.com/example/source-repo-backup.git"
DEST_gitlab_backup_BRANCH="main"
# Destination 3: SSH auth.
DEST_ssh_backup_URL="git@github.com:example/source-repo-ssh-backup.git"
DEST_ssh_backup_BRANCH="main"
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_NAME="$(basename "$0")"
CONFIG_FILE=""
VERBOSE=0
log() {
local level="$1"; shift
local ts
ts="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
[[ "$level" == "DEBUG" && "$VERBOSE" -lt 1 ]] && return 0
printf '%s [%s] %s\n' "$ts" "$level" "$*" >&2
}
die() {
log "ERROR" "$*"
exit 1
}
usage() {
cat <<EOF
Usage:
$SCRIPT_NAME --config <path> [--verbose]
Options:
--config <path> Path to Bash-native config file
--verbose Enable verbose logging
--help Show help
EOF
}
expand_path() {
local path="$1"
case "$path" in
"~") printf '%s\n' "$HOME" ;;
"~/"*) printf '%s/%s\n' "$HOME" "${path#"~/"}" ;;
*) printf '%s\n' "$path" ;;
esac
}
git_ssh() {
local ssh_key="$1"
shift
ssh_key="$(expand_path "$ssh_key")"
[[ -f "$ssh_key" ]] || die "SSH key does not exist: $ssh_key"
GIT_SSH_COMMAND="ssh -i $ssh_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" \
git "$@"
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--config)
[[ $# -ge 2 ]] || die "--config requires a path"
CONFIG_FILE="$2"
shift 2
;;
--verbose)
VERBOSE=$((VERBOSE + 1))
shift
;;
--help|-h)
usage
exit 0
;;
*)
die "Unknown argument: $1"
;;
esac
done
}
load_config() {
[[ -n "$CONFIG_FILE" ]] || die "Missing --config <path>"
[[ -f "$CONFIG_FILE" ]] || die "Config file does not exist: $CONFIG_FILE"
# shellcheck source=/dev/null
source "$CONFIG_FILE"
: "${SOURCE_URL:?SOURCE_URL is required}"
: "${DESTINATIONS:?DESTINATIONS array is required}"
SOURCE_BRANCH="${SOURCE_BRANCH:-}"
SOURCE_SSH_KEY="${SOURCE_SSH_KEY:-${GLOBAL_SSH_KEY:-$HOME/.ssh/id_rsa}}"
GLOBAL_SSH_KEY="${GLOBAL_SSH_KEY:-$HOME/.ssh/id_rsa}"
WORK_MODE="${WORK_MODE:-temp}"
WORK_DIR="${WORK_DIR:-}"
MIRROR_TAGS="${MIRROR_TAGS:-true}"
PUSH_FORCE="${PUSH_FORCE:-false}"
if [[ "$WORK_MODE" != "temp" && "$WORK_MODE" != "persistent" ]]; then
die "WORK_MODE must be either 'temp' or 'persistent'"
fi
if [[ "$WORK_MODE" == "persistent" && -z "$WORK_DIR" ]]; then
die "WORK_DIR is required when WORK_MODE=persistent"
fi
}
prepare_workdir() {
if [[ "$WORK_MODE" == "temp" ]]; then
WORK_DIR="$(mktemp -d)"
CLEANUP_WORK_DIR=true
else
WORK_DIR="$(expand_path "$WORK_DIR")"
mkdir -p "$WORK_DIR"
CLEANUP_WORK_DIR=false
fi
REPO_DIR="$WORK_DIR/repo"
log "INFO" "Work mode: $WORK_MODE"
log "INFO" "Work directory: $WORK_DIR"
}
cleanup() {
if [[ "${CLEANUP_WORK_DIR:-false}" == "true" && -n "${WORK_DIR:-}" && -d "$WORK_DIR" ]]; then
log "DEBUG" "Cleaning temporary work directory: $WORK_DIR"
rm -rf "$WORK_DIR"
fi
}
set_safe_directory() {
# Git 2.35+ requires explicitly marking directories as safe when running as root
# or when the directory is owned by a different user. We mark the repo directory
# as safe to avoid issues in those cases.
if git -C "$REPO_DIR" config --global --get safe.directory >/dev/null 2>&1; then
log "DEBUG" "Git safe.directory already configured globally"
else
log "DEBUG" "Configuring Git safe.directory for work directory: $REPO_DIR"
git config --global --add safe.directory "$REPO_DIR"
fi
}
clone_or_update_source() {
if [[ -d "$REPO_DIR/.git" ]]; then
log "INFO" "Updating existing local repository"
git -C "$REPO_DIR" remote set-url origin "$SOURCE_URL"
git_ssh "$SOURCE_SSH_KEY" \
-C "$REPO_DIR" fetch origin --prune --tags
if [[ -n "$SOURCE_BRANCH" ]]; then
git_ssh "$SOURCE_SSH_KEY" \
-C "$REPO_DIR" checkout "$SOURCE_BRANCH"
git_ssh "$SOURCE_SSH_KEY" \
-C "$REPO_DIR" reset --hard "origin/$SOURCE_BRANCH"
else
local current_branch
current_branch="$(git -C "$REPO_DIR" rev-parse --abbrev-ref HEAD)"
git_ssh "$SOURCE_SSH_KEY" \
-C "$REPO_DIR" reset --hard "origin/$current_branch"
fi
else
log "INFO" "Cloning source repository: $SOURCE_URL"
if [[ -n "$SOURCE_BRANCH" ]]; then
git_ssh "$SOURCE_SSH_KEY" \
clone --origin origin --branch "$SOURCE_BRANCH" "$SOURCE_URL" "$REPO_DIR"
else
git_ssh "$SOURCE_SSH_KEY" \
clone --origin origin "$SOURCE_URL" "$REPO_DIR"
fi
fi
}
get_dest_var() {
local dest_name="$1"
local suffix="$2"
local var_name="DEST_${dest_name}_${suffix}"
printf '%s' "${!var_name-}"
}
push_destination() {
local dest_name="$1"
local dest_url
local dest_branch
local dest_ssh_key
local remote_name
local push_args=()
dest_url="$(get_dest_var "$dest_name" "URL")"
dest_branch="$(get_dest_var "$dest_name" "BRANCH")"
dest_ssh_key="$(get_dest_var "$dest_name" "SSH_KEY")"
[[ -n "$dest_url" ]] || {
log "ERROR" "Destination '$dest_name' has no DEST_${dest_name}_URL"
return 1
}
dest_branch="${dest_branch:-${SOURCE_BRANCH:-}}"
dest_ssh_key="${dest_ssh_key:-$GLOBAL_SSH_KEY}"
remote_name="dest_${dest_name}"
log "INFO" "Pushing destination '$dest_name': $dest_url"
git -C "$REPO_DIR" remote remove "$remote_name" >/dev/null 2>&1 || true
git -C "$REPO_DIR" remote add "$remote_name" "$dest_url"
if [[ "$PUSH_FORCE" == "true" ]]; then
push_args+=(--force)
fi
if [[ -n "$dest_branch" ]]; then
push_args+=(HEAD:"$dest_branch")
else
push_args+=(--all)
fi
if git_ssh "$dest_ssh_key" -C "$REPO_DIR" push "$remote_name" "${push_args[@]}"; then
if [[ "$MIRROR_TAGS" == "true" ]]; then
git_ssh "$dest_ssh_key" -C "$REPO_DIR" push "$remote_name" --tags || {
log "ERROR" "Destination '$dest_name': branch push succeeded, tag push failed"
return 1
}
fi
log "INFO" "Destination '$dest_name': push succeeded"
return 0
else
log "ERROR" "Destination '$dest_name': push failed"
return 1
fi
}
main() {
parse_args "$@"
command -v git >/dev/null 2>&1 || die "Required command not found: git"
command -v date >/dev/null 2>&1 || die "Required command not found: date"
command -v mktemp >/dev/null 2>&1 || die "Required command not found: mktemp"
load_config
prepare_workdir
trap cleanup EXIT
log "INFO" "Git mirror run started"
set_safe_directory
clone_or_update_source
local success_count=0
local failure_count=0
local dest
for dest in "${DESTINATIONS[@]}"; do
if push_destination "$dest"; then
success_count=$((success_count + 1))
else
failure_count=$((failure_count + 1))
fi
done
log "INFO" "Git mirror run finished: success=$success_count failure=$failure_count"
[[ "$failure_count" -eq 0 ]] || exit 2
}
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment