dd6f1308c1
- mirror_refs.sh: additive branch+tag mirroring between dadeschools (HTTPS) and prgs (SSH:2222). Dry-run default, --apply to execute, --force for diverged branches. Uses bare repo cache for isolation. - test_mirror_refs.py: flag parsing, safety defaults, brace-delimited refspec validation, and local bare-repo integration tests (FF detection, branch/tag comparison). - README.md: document mirror_refs.sh, test suite, and multi-instance auth.
300 lines
11 KiB
Bash
Executable File
300 lines
11 KiB
Bash
Executable File
#!/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/<remote>/")
|
|
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
|