Skip to content

Instantly share code, notes, and snippets.

@mikaelvesavuori
Created March 1, 2026 15:37
Show Gist options
  • Select an option

  • Save mikaelvesavuori/1b836d6c6a6f1199ec6bf717ea473fa9 to your computer and use it in GitHub Desktop.

Select an option

Save mikaelvesavuori/1b836d6c6a6f1199ec6bf717ea473fa9 to your computer and use it in GitHub Desktop.
Script to backup or migrate from GitHub. Has support for public and private repos, organizations, "since" support, and more.
#!/usr/bin/env bash
set -euo pipefail
# Example:
# export GITHUB_TOKEN=ghp_...
# ./github-backup.sh --org my-org --out ./backup --mirror --since 2026-02-01
API_BASE="https://api.github.com"
OWNER_TYPE="" # org | user
OWNER_NAME=""
OUT_DIR="./github-repos"
MODE="mirror" # mirror | normal
PROTO="https" # https | ssh
INCLUDE_PRIVATE="1" # if token present, include private by default
INCLUDE_FORKS="1"
INCLUDE_ARCHIVED="1"
FORCE="0" # delete & re-clone existing
PER_PAGE="100"
SINCE="" # YYYY-MM-DD (interpreted as YYYY-MM-DDT00:00:00Z)
usage() {
cat <<'USAGE'
Usage:
github-backup.sh --org <name> | --user <name> [options]
Options:
--out <dir> Output directory (default: ./github-repos)
--mirror Mirror clone (default; best for backups/migrations)
--normal Normal clone (working tree)
--ssh Use SSH clone URLs
--https Use HTTPS clone URLs (default)
--exclude-private Skip private repos (even if token set)
--exclude-forks Skip forks
--exclude-archived Skip archived repos
--force Delete existing repo dir and re-clone
--per-page <n> Page size (default 100)
--since <YYYY-MM-DD> Only repos pushed on/after this date (UTC midnight)
-h, --help Show help
Auth:
export GITHUB_TOKEN=... (Recommended; required for private repos)
Behavior:
If repo already exists and --force is NOT set:
- mirror repo: git remote update --prune
- normal repo: git fetch --all --prune
Examples:
./github-backup.sh --org my-org --since 2026-01-15
GITHUB_TOKEN=... ./github-backup.sh --org my-org --ssh --mirror --since 2026-02-01
USAGE
}
die() { echo "Error: $*" >&2; exit 1; }
need() { command -v "$1" >/dev/null 2>&1 || die "Missing command: $1"; }
validate_since() {
[[ "$SINCE" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || die "--since must be YYYY-MM-DD"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--org) OWNER_TYPE="org"; OWNER_NAME="${2:-}"; shift 2 ;;
--user) OWNER_TYPE="user"; OWNER_NAME="${2:-}"; shift 2 ;;
--out) OUT_DIR="${2:-}"; shift 2 ;;
--mirror) MODE="mirror"; shift ;;
--normal) MODE="normal"; shift ;;
--ssh) PROTO="ssh"; shift ;;
--https) PROTO="https"; shift ;;
--exclude-private) INCLUDE_PRIVATE="0"; shift ;;
--exclude-forks) INCLUDE_FORKS="0"; shift ;;
--exclude-archived) INCLUDE_ARCHIVED="0"; shift ;;
--force) FORCE="1"; shift ;;
--per-page) PER_PAGE="${2:-}"; shift 2 ;;
--since) SINCE="${2:-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) die "Unknown option: $1 (use --help)" ;;
esac
done
[[ -n "$OWNER_TYPE" && -n "$OWNER_NAME" ]] || { usage; die "Must provide --org <name> or --user <name>"; }
need curl
need git
need jq
need sed
need grep
need mktemp
mkdir -p "$OUT_DIR"
auth=()
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
auth=(-H "Authorization: Bearer $GITHUB_TOKEN")
fi
# If no token, we can only see public repos.
type_param="public"
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
type_param="all"
else
INCLUDE_PRIVATE="0"
fi
if [[ -n "$SINCE" ]]; then
validate_since
# Compare pushed_at lexicographically (ISO 8601), so build a full ISO timestamp
SINCE_ISO="${SINCE}T00:00:00Z"
else
SINCE_ISO=""
fi
if [[ "$OWNER_TYPE" == "org" ]]; then
base="$API_BASE/orgs/$OWNER_NAME/repos"
else
base="$API_BASE/users/$OWNER_NAME/repos"
fi
clone_or_update() {
local full="$1" https_url="$2" ssh_url="$3"
local owner repo dest url
owner="${full%%/*}"
repo="${full##*/}"
mkdir -p "$OUT_DIR/$owner"
if [[ "$MODE" == "mirror" ]]; then
dest="$OUT_DIR/$owner/$repo.git"
else
dest="$OUT_DIR/$owner/$repo"
fi
if [[ "$PROTO" == "ssh" ]]; then
url="$ssh_url"
else
url="$https_url"
fi
if [[ -e "$dest" ]]; then
if [[ "$FORCE" == "1" ]]; then
rm -rf "$dest"
else
echo "Updating: $full"
if [[ "$MODE" == "mirror" ]]; then
git -C "$dest" remote update --prune
else
git -C "$dest" fetch --all --prune
fi
return 0
fi
fi
echo "Cloning: $full"
if [[ "$MODE" == "mirror" ]]; then
git clone --mirror "$url" "$dest"
else
git clone "$url" "$dest"
fi
}
next_link() {
grep -i '^Link:' "$1" | sed -n 's/.*<\([^>]*\)>; rel="next".*/\1/p'
}
url="$base?per_page=$PER_PAGE&sort=pushed&type=$type_param&direction=desc"
total=0
picked=0
while [[ -n "$url" ]]; do
headers="$(mktemp)"
body="$(mktemp)"
trap 'rm -f "$headers" "$body"' RETURN
curl -sS -D "$headers" \
-H "Accept: application/vnd.github+json" \
"${auth[@]}" \
"$url" > "$body"
# Build jq filter. We filter by:
# - forks/archived/private toggles
# - pushed_at >= SINCE_ISO if provided
# Output: full_name, clone_url, ssh_url
jq -r \
--arg forks "$INCLUDE_FORKS" \
--arg archived "$INCLUDE_ARCHIVED" \
--arg priv "$INCLUDE_PRIVATE" \
--arg since "$SINCE_ISO" '
.[] as $r
| select((($r.fork|tostring)=="false" or $forks=="1"))
| select((($r.archived|tostring)=="false" or $archived=="1"))
| select((($r.private|tostring)=="false" or $priv=="1"))
| select(($since == "") or ($r.pushed_at >= $since))
| [$r.full_name, $r.clone_url, $r.ssh_url]
| @tsv
' "$body" | while IFS=$'\t' read -r full https_url ssh_url; do
((picked+=1)) || true
clone_or_update "$full" "$https_url" "$ssh_url"
done
page_count="$(jq 'length' "$body")"
total=$((total + page_count))
url="$(next_link "$headers" || true)"
rm -f "$headers" "$body"
trap - RETURN
done
echo
echo "Done."
echo "Repos seen: $total"
echo "Repos processed (after filters): $picked"
echo "Output: $OUT_DIR"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment