#!/usr/bin/env bash # mirror_refs.sh — Additive ref mirroring between two Gitea instances. # # Mirrors branches and tags between dadeschools and prgs Timesheet repos. # Safe by default: dry-run, additive-only, divergence-skipping. # # Usage: # ./mirror_refs.sh # dry-run (default) — shows what would happen # ./mirror_refs.sh --apply # actually push # ./mirror_refs.sh --apply --force # also force-push diverged branches # # Auth: # dadeschools: HTTPS via `git credential fill` (SSH:2222 flaky) # prgs: SSH via ssh://git@gitea-ssh.prgs.cc:2222 (SSH ok) # # Safety: # - Additive only: never deletes branches/tags. # - Divergence protection: if a shared branch has diverged (no FF path), # skip + warn unless --force is passed. # - Won't auto-close/merge anything — just ref mirroring. set -euo pipefail # ── Configuration ───────────────────────────────────────────────────────────── DS_HOST="gitea.dadeschools.net" DS_ORG="Contractor" DS_REPO="Timesheet" PRGS_SSH_URL="ssh://git@gitea-ssh.prgs.cc:2222/Scaled-Tech-Consulting/Timesheet.git" CACHE_DIR="/tmp/gitea-mirror-${DS_REPO}" # ── Flag parsing ────────────────────────────────────────────────────────────── DRY_RUN=true FORCE=false for arg in "$@"; do case "$arg" in --apply) DRY_RUN=false ;; --force) FORCE=true ;; --help|-h) sed -n '2,/^$/{ s/^# //; s/^#$//; p; }' "$0" exit 0 ;; *) echo "Unknown flag: $arg (try --help)" >&2 exit 1 ;; esac done if $DRY_RUN; then echo "═══ DRY RUN (pass --apply to execute) ═══" echo "" fi # ── Helpers ─────────────────────────────────────────────────────────────────── log() { printf " %s\n" "$*"; } ok() { printf " ✓ %s\n" "$*"; } warn() { printf " ⚠ %s\n" "$*" >&2; } err() { printf " ✗ %s\n" "$*" >&2; } # Build an authenticated HTTPS URL for dadeschools using the macOS keychain. get_ds_url() { local creds user pass creds=$(printf "host=%s\nprotocol=https\n\n" "$DS_HOST" | git credential fill 2>/dev/null) || true user=$(printf '%s\n' "$creds" | sed -n 's/^username=//p') pass=$(printf '%s\n' "$creds" | sed -n 's/^password=//p') if [[ -z "$user" || -z "$pass" ]]; then err "Could not get credentials for $DS_HOST" err "Ensure you've logged in via HTTPS at least once (git credential fill)." exit 1 fi echo "https://${user}:${pass}@${DS_HOST}/${DS_ORG}/${DS_REPO}.git" } # Run a git command, or print it if dry-run. run_or_dry() { if $DRY_RUN; then log "[dry-run] $*" else "$@" fi } # ── Set up bare mirror cache ───────────────────────────────────────────────── setup_cache() { if [[ ! -d "$CACHE_DIR" ]]; then log "Creating mirror cache at $CACHE_DIR" git init --bare "$CACHE_DIR" --quiet fi local ds_url ds_url=$(get_ds_url) # Set/update remotes git -C "$CACHE_DIR" remote remove ds 2>/dev/null || true git -C "$CACHE_DIR" remote remove prgs 2>/dev/null || true git -C "$CACHE_DIR" remote add ds "$ds_url" git -C "$CACHE_DIR" remote add prgs "$PRGS_SSH_URL" } # ── Fetch ───────────────────────────────────────────────────────────────────── fetch_both() { echo "── Fetching dadeschools ──" git -C "$CACHE_DIR" fetch ds --tags --prune --quiet 2>&1 | sed 's/^/ /' ok "dadeschools fetched" echo "── Fetching prgs ──" git -C "$CACHE_DIR" fetch prgs --tags --prune --quiet 2>&1 | sed 's/^/ /' ok "prgs fetched" echo "" } # ── Branch mirroring ────────────────────────────────────────────────────────── mirror_branches() { echo "── Branch comparison ──" # Collect branch names from each remote (strip "refs/remotes//") local -A ds_branches prgs_branches local ref while IFS= read -r ref; do local name="${ref#refs/remotes/ds/}" [[ "$name" == "HEAD" ]] && continue ds_branches["$name"]=$(git -C "$CACHE_DIR" rev-parse "$ref") done < <(git -C "$CACHE_DIR" for-each-ref --format='%(refname)' refs/remotes/ds/) while IFS= read -r ref; do local name="${ref#refs/remotes/prgs/}" [[ "$name" == "HEAD" ]] && continue prgs_branches["$name"]=$(git -C "$CACHE_DIR" rev-parse "$ref") done < <(git -C "$CACHE_DIR" for-each-ref --format='%(refname)' refs/remotes/prgs/) local total_ds=${#ds_branches[@]} local total_prgs=${#prgs_branches[@]} log "dadeschools: $total_ds branches, prgs: $total_prgs branches" local pushed=0 skipped=0 # All unique branch names local -A all_branches for b in "${!ds_branches[@]}"; do all_branches["$b"]=1; done for b in "${!prgs_branches[@]}"; do all_branches["$b"]=1; done for b in $(printf '%s\n' "${!all_branches[@]}" | sort); do local ds_sha="${ds_branches[$b]:-}" local prgs_sha="${prgs_branches[$b]:-}" if [[ -n "$ds_sha" && -z "$prgs_sha" ]]; then # Only on dadeschools → push to prgs ok "$b → prgs (new)" run_or_dry git -C "$CACHE_DIR" push prgs "refs/remotes/ds/${b}:refs/heads/${b}" ((pushed++)) || true elif [[ -z "$ds_sha" && -n "$prgs_sha" ]]; then # Only on prgs → push to dadeschools ok "$b → dadeschools (new)" run_or_dry git -C "$CACHE_DIR" push ds "refs/remotes/prgs/${b}:refs/heads/${b}" ((pushed++)) || true elif [[ "$ds_sha" == "$prgs_sha" ]]; then # In sync — nothing to do log "$b ✔ in sync (${ds_sha:0:8})" else # Both have it, different SHAs — check fast-forward if git -C "$CACHE_DIR" merge-base --is-ancestor "$ds_sha" "$prgs_sha" 2>/dev/null; then # dadeschools is behind prgs — push prgs → ds ok "$b → dadeschools (ff: ${ds_sha:0:8}..${prgs_sha:0:8})" run_or_dry git -C "$CACHE_DIR" push ds "refs/remotes/prgs/${b}:refs/heads/${b}" ((pushed++)) || true elif git -C "$CACHE_DIR" merge-base --is-ancestor "$prgs_sha" "$ds_sha" 2>/dev/null; then # prgs is behind dadeschools — push ds → prgs ok "$b → prgs (ff: ${prgs_sha:0:8}..${ds_sha:0:8})" run_or_dry git -C "$CACHE_DIR" push prgs "refs/remotes/ds/${b}:refs/heads/${b}" ((pushed++)) || true else # Diverged if $FORCE; then warn "$b DIVERGED — force-pushing dadeschools → prgs" run_or_dry git -C "$CACHE_DIR" push prgs --force "refs/remotes/ds/${b}:refs/heads/${b}" ((pushed++)) || true else warn "$b DIVERGED (ds: ${ds_sha:0:8}, prgs: ${prgs_sha:0:8}) — skipping (use --force)" ((skipped++)) || true fi fi fi done echo "" log "Branches: $pushed pushed, $skipped skipped" echo "" } # ── Tag mirroring ───────────────────────────────────────────────────────────── mirror_tags() { echo "── Tag comparison ──" local -A ds_tags prgs_tags local ref while IFS= read -r ref; do [[ -z "$ref" ]] && continue local name="${ref#refs/tags/}" # Dereference annotated tags to the underlying commit ds_tags["$name"]=$(git -C "$CACHE_DIR" rev-parse "${ref}^{}" 2>/dev/null || git -C "$CACHE_DIR" rev-parse "$ref") done < <(git -C "$CACHE_DIR" for-each-ref --format='%(refname)' "refs/tags/" 2>/dev/null) # Tags are global in git (not per-remote), so after fetching both with --tags, # they're merged into refs/tags/. We need to check which remote actually has # each tag by checking if the tagged commit is reachable from that remote's refs. # Simpler approach: fetch tags explicitly per remote and compare via ls-remote. local -A ds_remote_tags prgs_remote_tags while IFS=$'\t' read -r sha ref; do [[ -z "$ref" ]] && continue local name="${ref#refs/tags/}" name="${name%\^\{\}}" # strip ^{} suffix from dereferenced tags ds_remote_tags["$name"]="$sha" done < <(git -C "$CACHE_DIR" ls-remote --tags ds 2>/dev/null) while IFS=$'\t' read -r sha ref; do [[ -z "$ref" ]] && continue local name="${ref#refs/tags/}" name="${name%\^\{\}}" prgs_remote_tags["$name"]="$sha" done < <(git -C "$CACHE_DIR" ls-remote --tags prgs 2>/dev/null) local total_ds=${#ds_remote_tags[@]} local total_prgs=${#prgs_remote_tags[@]} log "dadeschools: $total_ds tags, prgs: $total_prgs tags" local pushed=0 skipped=0 # Combine all tag names local -A all_tags for t in "${!ds_remote_tags[@]}"; do all_tags["$t"]=1; done for t in "${!prgs_remote_tags[@]}"; do all_tags["$t"]=1; done for t in $(printf '%s\n' "${!all_tags[@]}" | sort -V); do local ds_sha="${ds_remote_tags[$t]:-}" local prgs_sha="${prgs_remote_tags[$t]:-}" if [[ -n "$ds_sha" && -z "$prgs_sha" ]]; then ok "$t → prgs (new tag)" run_or_dry git -C "$CACHE_DIR" push prgs "refs/tags/${t}:refs/tags/${t}" ((pushed++)) || true elif [[ -z "$ds_sha" && -n "$prgs_sha" ]]; then ok "$t → dadeschools (new tag)" run_or_dry git -C "$CACHE_DIR" push ds "refs/tags/${t}:refs/tags/${t}" ((pushed++)) || true elif [[ "$ds_sha" == "$prgs_sha" ]]; then log "$t ✔ in sync" else warn "$t MISMATCH (ds: ${ds_sha:0:8}, prgs: ${prgs_sha:0:8}) — tags shouldn't diverge, skipping" ((skipped++)) || true fi done echo "" log "Tags: $pushed pushed, $skipped skipped" echo "" } # ── Main ────────────────────────────────────────────────────────────────────── main() { echo "" echo "╔══════════════════════════════════════════════╗" echo "║ Gitea Ref Mirror: dadeschools ⇄ prgs ║" echo "╚══════════════════════════════════════════════╝" echo "" setup_cache fetch_both mirror_branches mirror_tags if $DRY_RUN; then echo "═══ DRY RUN complete. Pass --apply to execute. ═══" else echo "═══ Mirror complete. ═══" fi } main