Created
January 9, 2026 23:53
-
-
Save Esl1h/c129f30397478bc255e0190856b08cd2 to your computer and use it in GitHub Desktop.
Git multi-remote sync tool that automatically adds, creates, and synchronizes repositories across multiple Git providers (GitHub, GitLab, Codeberg, Keybase) with aggregated push via origin.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| # ============================================================================= | |
| # CONFIGURATION - Edit these values | |
| # ============================================================================= | |
| # Usernames for each provider | |
| GITHUB_USER="user" | |
| GITLAB_USER="user" | |
| CODEBERG_USER="user" | |
| KEYBASE_USER="user" | |
| # Enable/disable providers (true/false) | |
| ENABLE_GITHUB=true | |
| ENABLE_GITLAB=true | |
| ENABLE_CODEBERG=true | |
| ENABLE_KEYBASE=true | |
| # Default directory containing git repositories | |
| DEFAULT_GIT_DIR="$HOME/GIT" | |
| # ============================================================================= | |
| # END OF CONFIGURATION | |
| # ============================================================================= | |
| DRY_RUN=false | |
| CREATE_IF_MISSING=false | |
| GIT_DIR="$DEFAULT_GIT_DIR" | |
| SINGLE_REPO="" | |
| while [[ $# -gt 0 ]]; do | |
| case $1 in | |
| -n|--dry-run) DRY_RUN=true ;; | |
| -c|--create) CREATE_IF_MISSING=true ;; | |
| -r|--repo) SINGLE_REPO="$2"; shift ;; | |
| -h|--help) | |
| cat <<EOF | |
| Usage: $(basename "$0") [options] [directory] | |
| Options: | |
| -n, --dry-run Simulate execution without making changes | |
| -c, --create Create remote repositories if they don't exist | |
| -r, --repo PATH Process only the specified repository | |
| -h, --help Show this help message | |
| Environment variables (required with --create): | |
| GITLAB_TOKEN GitLab access token (scope: api) | |
| CODEBERG_TOKEN Codeberg access token (scope: repo) | |
| Features: | |
| - Adds missing remotes (github, gitlab, codeberg, keybase) | |
| - Creates remote repos if --create is specified | |
| - Configures origin as aggregated push for all remotes | |
| - Syncs all branches | |
| Examples: | |
| $(basename "$0") --dry-run | |
| $(basename "$0") --create ~/GIT | |
| $(basename "$0") -r ~/GIT/my-repo --create | |
| $(basename "$0") -n -c -r ./dotfiles | |
| EOF | |
| exit 0 | |
| ;; | |
| -*) echo "Unknown option: $1" >&2; exit 1 ;; | |
| *) GIT_DIR="$1" ;; | |
| esac | |
| shift | |
| done | |
| # Build REMOTE_URLS based on enabled providers | |
| declare -A REMOTE_URLS=() | |
| declare -a ENABLED_PROVIDERS=() | |
| if $ENABLE_GITHUB; then | |
| REMOTE_URLS[github]="[email protected]:${GITHUB_USER}/%s.git" | |
| ENABLED_PROVIDERS+=(github) | |
| fi | |
| if $ENABLE_GITLAB; then | |
| REMOTE_URLS[gitlab]="[email protected]:${GITLAB_USER}/%s.git" | |
| ENABLED_PROVIDERS+=(gitlab) | |
| fi | |
| if $ENABLE_CODEBERG; then | |
| REMOTE_URLS[codeberg]="[email protected]:${CODEBERG_USER}/%s.git" | |
| ENABLED_PROVIDERS+=(codeberg) | |
| fi | |
| if $ENABLE_KEYBASE; then | |
| REMOTE_URLS[keybase]="keybase://private/${KEYBASE_USER}/%s" | |
| ENABLED_PROVIDERS+=(keybase) | |
| fi | |
| REMOTE_PRIORITY=("${ENABLED_PROVIDERS[@]}" origin) | |
| log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*"; } | |
| err() { printf '[%s] ERROR: %s\n' "$(date +%H:%M:%S)" "$*" >&2; } | |
| dry() { $DRY_RUN && echo " [DRY-RUN] $*" && return 0 || return 1; } | |
| check_tokens() { | |
| local missing=() | |
| if $CREATE_IF_MISSING; then | |
| $ENABLE_GITLAB && [[ -z "${GITLAB_TOKEN:-}" ]] && missing+=(GITLAB_TOKEN) | |
| $ENABLE_CODEBERG && [[ -z "${CODEBERG_TOKEN:-}" ]] && missing+=(CODEBERG_TOKEN) | |
| if [[ ${#missing[@]} -gt 0 ]]; then | |
| err "Missing tokens for --create: ${missing[*]}" | |
| err "Set via: export GITLAB_TOKEN=xxx CODEBERG_TOKEN=xxx" | |
| exit 1 | |
| fi | |
| fi | |
| } | |
| check_enabled_providers() { | |
| if [[ ${#ENABLED_PROVIDERS[@]} -eq 0 ]]; then | |
| err "No providers enabled. Edit the script configuration." | |
| exit 1 | |
| fi | |
| log "Enabled providers: ${ENABLED_PROVIDERS[*]}" | |
| echo | |
| } | |
| get_repo_name() { | |
| local url | |
| for remote in "${REMOTE_PRIORITY[@]}"; do | |
| if git remote | grep -qx "$remote"; then | |
| url=$(git remote get-url "$remote" 2>/dev/null) || continue | |
| basename -s .git "$url" | sed 's|.*/||' | |
| return 0 | |
| fi | |
| done | |
| return 1 | |
| } | |
| remote_exists() { | |
| git remote | grep -qx "$1" | |
| } | |
| url_is_accessible() { | |
| local url=$1 | |
| if [[ $url == keybase://* ]]; then | |
| local repo_name | |
| repo_name=$(basename "$url") | |
| keybase git list 2>/dev/null | grep -q "^$repo_name$" | |
| return $? | |
| fi | |
| git ls-remote "$url" &>/dev/null | |
| } | |
| create_remote_repo() { | |
| local provider=$1 repo_name=$2 | |
| local response http_code | |
| case $provider in | |
| gitlab) | |
| if dry "curl POST gitlab.com/api/v4/projects name=$repo_name"; then | |
| return 0 | |
| fi | |
| response=$(curl -s -w "\n%{http_code}" \ | |
| --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \ | |
| -X POST "https://gitlab.com/api/v4/projects" \ | |
| --data-urlencode "name=$repo_name" \ | |
| -d "visibility=private") | |
| ;; | |
| codeberg) | |
| if dry "curl POST codeberg.org/api/v1/{CODEBERG_USER}/repos name=$repo_name"; then | |
| return 0 | |
| fi | |
| response=$(curl -s -w "\n%{http_code}" \ | |
| --header "Authorization: token $CODEBERG_TOKEN" \ | |
| --header "Content-Type: application/json" \ | |
| -X POST "https://codeberg.org/api/v1/{CODEBERG_USER}/repos" \ | |
| -d '{"name":"'"$repo_name"'","private":true}') | |
| ;; | |
| keybase) | |
| if dry "keybase git create $repo_name"; then | |
| return 0 | |
| fi | |
| if keybase git list 2>/dev/null | grep -q "^$repo_name$"; then | |
| log " │ └─ keybase: already exists" | |
| return 0 | |
| fi | |
| if keybase git create "$repo_name" 2>&1; then | |
| log " │ └─ keybase: repo created" | |
| sleep 2 | |
| return 0 | |
| else | |
| err " │ └─ keybase: failed to create" | |
| return 1 | |
| fi | |
| ;; | |
| github) | |
| # GitHub creation not implemented (assumed as primary/source) | |
| return 1 | |
| ;; | |
| *) | |
| return 1 | |
| ;; | |
| esac | |
| http_code=$(echo "$response" | tail -1) | |
| if [[ "$http_code" =~ ^2 ]]; then | |
| log " │ └─ $provider: repo created" | |
| sleep 1 | |
| return 0 | |
| else | |
| local body | |
| body=$(echo "$response" | sed '$d') | |
| err " │ └─ $provider: failed to create (HTTP $http_code)" | |
| [[ -n "$body" ]] && err " │ $body" | |
| return 1 | |
| fi | |
| } | |
| ensure_remote_repo_exists() { | |
| local remote_name=$1 url=$2 repo_name=$3 | |
| if url_is_accessible "$url"; then | |
| return 0 | |
| fi | |
| if $CREATE_IF_MISSING; then | |
| log " │ └─ $remote_name: remote repo doesn't exist, creating..." | |
| create_remote_repo "$remote_name" "$repo_name" | |
| return $? | |
| else | |
| log " │ └─ $remote_name: remote repo doesn't exist (use --create)" | |
| return 1 | |
| fi | |
| } | |
| add_remote_if_missing() { | |
| local remote_name=$1 repo_name=$2 | |
| local url | |
| printf -v url "${REMOTE_URLS[$remote_name]}" "$repo_name" | |
| if remote_exists "$remote_name"; then | |
| log " ├─ $remote_name: exists" | |
| return 0 | |
| fi | |
| if ! ensure_remote_repo_exists "$remote_name" "$url" "$repo_name"; then | |
| return 1 | |
| fi | |
| if dry "git remote add $remote_name $url"; then | |
| return 0 | |
| fi | |
| git remote add "$remote_name" "$url" | |
| log " ├─ $remote_name: remote added" | |
| return 0 | |
| } | |
| get_primary_provider() { | |
| # Returns first enabled provider as primary (for origin fetch URL) | |
| echo "${ENABLED_PROVIDERS[0]}" | |
| } | |
| setup_origin_multipush() { | |
| local repo_name=$1 | |
| local primary_provider primary_url | |
| log " ├─ origin: configuring aggregated push" | |
| primary_provider=$(get_primary_provider) | |
| printf -v primary_url "${REMOTE_URLS[$primary_provider]}" "$repo_name" | |
| # Remove origin if exists | |
| if remote_exists origin; then | |
| if ! dry "git remote remove origin"; then | |
| git remote remove origin 2>/dev/null || true | |
| fi | |
| fi | |
| # Create origin with primary provider as base | |
| if ! dry "git remote add origin $primary_url"; then | |
| git remote add origin "$primary_url" | |
| fi | |
| # Add push URLs for all accessible remotes | |
| for remote_name in "${ENABLED_PROVIDERS[@]}"; do | |
| local url | |
| printf -v url "${REMOTE_URLS[$remote_name]}" "$repo_name" | |
| if url_is_accessible "$url"; then | |
| if ! dry "git remote set-url --add --push origin $url"; then | |
| git remote set-url --add --push origin "$url" | |
| log " │ └─ push: $remote_name" | |
| fi | |
| fi | |
| done | |
| } | |
| sync_via_origin() { | |
| log " ├─ sync: push via origin (all remotes)" | |
| if dry "git push origin --all"; then | |
| return 0 | |
| fi | |
| local branches | |
| branches=$(git for-each-ref --format='%(refname:short)' refs/heads/) | |
| for branch in $branches; do | |
| if git push origin "$branch" 2>/dev/null; then | |
| log " │ └─ $branch → all ✓" | |
| else | |
| log " │ └─ $branch → all (noop/error)" | |
| fi | |
| done | |
| } | |
| process_repo() { | |
| local repo_path=$1 | |
| local repo_name | |
| cd "$repo_path" || return 1 | |
| [[ -d .git ]] || { err "Not a git repository: $repo_path"; return 1; } | |
| repo_name=$(get_repo_name) || { | |
| err "No detectable remote in $repo_path" | |
| return 1 | |
| } | |
| log "📁 $repo_name" | |
| # Add individual remotes (for fetch) | |
| for remote_name in "${ENABLED_PROVIDERS[@]}"; do | |
| add_remote_if_missing "$remote_name" "$repo_name" | |
| done | |
| # Configure origin as aggregated push | |
| setup_origin_multipush "$repo_name" | |
| # Fetch from all remotes | |
| for remote_name in "${ENABLED_PROVIDERS[@]}"; do | |
| if remote_exists "$remote_name"; then | |
| if ! dry "git fetch $remote_name"; then | |
| git fetch "$remote_name" 2>/dev/null && log " │ └─ fetch: $remote_name ✓" || log " │ └─ fetch: $remote_name failed" | |
| fi | |
| fi | |
| done | |
| # Single push via origin | |
| sync_via_origin | |
| echo | |
| } | |
| main() { | |
| check_enabled_providers | |
| check_tokens | |
| $DRY_RUN && log "=== DRY-RUN MODE ===" && echo | |
| $CREATE_IF_MISSING && log "=== CREATE ENABLED ===" && echo | |
| if [[ -n "$SINGLE_REPO" ]]; then | |
| local repo_path | |
| repo_path=$(realpath "$SINGLE_REPO" 2>/dev/null) || { | |
| err "Invalid path: $SINGLE_REPO" | |
| exit 1 | |
| } | |
| process_repo "$repo_path" | |
| else | |
| [[ -d "$GIT_DIR" ]] || { err "Directory not found: $GIT_DIR"; exit 1; } | |
| for dir in "$GIT_DIR"/*/; do | |
| [[ -d "$dir" ]] && process_repo "$dir" | |
| done | |
| fi | |
| log "Done." | |
| } | |
| main |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment