Created
March 1, 2026 15:37
-
-
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.
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 | |
| # 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