arnaucube
arnaucube/gitviewPublic Self-hosted repository
gitview/scripts/mirror-github.sh
scripts/mirror-github.shRaw
  1#!/usr/bin/env bash
  2# Create or refresh read-only mirrors of a GitHub user's public repositories.
  3set -euo pipefail
  4
  5usage() {
  6    printf 'Usage: %s USERNAME [DIRECTORY]\n' "${0##*/}"
  7    printf 'Defaults to this project\047s repos/ directory; use repo.scanPath for a custom location.\n'
  8    printf 'Requires git, curl, python3, and flock. GITHUB_TOKEN is optional for API rate limits.\n'
  9}
 10
 11if [[ ${1:-} == --help || ${1:-} == -h ]]; then
 12    usage
 13    exit 0
 14fi
 15if (( $# < 1 || $# > 2 )); then
 16    usage >&2
 17    exit 1
 18fi
 19username=$1
 20if [[ ! $username =~ ^[a-zA-Z0-9][a-zA-Z0-9-]{0,38}$ ]]; then
 21    printf 'Invalid GitHub username.\n' >&2
 22    exit 1
 23fi
 24for dependency in git curl python3 flock; do
 25    if ! command -v "$dependency" >/dev/null 2>&1; then
 26        printf 'Missing dependency: %s\n' "$dependency" >&2
 27        exit 1
 28    fi
 29done
 30
 31project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
 32destination=${2:-"$project_dir/repos"}
 33mkdir -p -- "$destination"
 34destination=$(cd -- "$destination" && pwd)
 35# Lock the destination so scheduled runs cannot modify the same mirrors concurrently.
 36exec 9>"$destination/.github-mirror.lock"
 37if ! flock -n 9; then
 38    printf 'Another mirror job is already running for %s.\n' "$destination" >&2
 39    exit 1
 40fi
 41staging=$(mktemp -d "$destination/.github-mirror.XXXXXX")
 42trap 'rm -rf -- "$staging"' EXIT
 43trap 'exit 130' INT
 44trap 'exit 143' TERM
 45export GIT_TERMINAL_PROMPT=0
 46
 47# Send optional credentials over stdin, never in curl's command-line arguments.
 48fetch_page() {
 49    local url=$1
 50    {
 51        if [[ -n ${GITHUB_TOKEN:-} ]]; then
 52            printf 'Authorization: Bearer %s\n' "$GITHUB_TOKEN"
 53        fi
 54    } | curl --fail --silent --show-error --retry 3 --connect-timeout 15 --max-time 120 \
 55        --header @- --header 'Accept: application/vnd.github+json' \
 56        --header 'X-GitHub-Api-Version: 2026-03-10' \
 57        --output "$staging/page.json" "$url"
 58}
 59
 60# Only update matching mirrors; never convert or replace an unrelated local repository.
 61mirror_repo() {
 62    local name=$1 branch=$2 description=$3
 63    local url="https://github.com/$username/$name.git"
 64    local target="$destination/$name.git"
 65    if [[ -e $target || -L $target ]]; then
 66        if [[ -L $target || ! -d $target ]] ||
 67            [[ $(git -C "$target" rev-parse --is-bare-repository 2>/dev/null) != true ]] ||
 68            [[ $(git -C "$target" config --get remote.origin.mirror) != true ]] ||
 69            [[ $(git -C "$target" config --get-all remote.origin.fetch) != '+refs/*:refs/*' ]] ||
 70            [[ $(git -C "$target" config --get remote.origin.url) != "$url" ]]; then
 71            printf 'Skipping %s: destination is not a matching GitHub mirror.\n' "$name" >&2
 72            return 1
 73        fi
 74        printf 'Updating %s/%s\n' "$username" "$name"
 75        git -C "$target" fetch --prune origin || return 1
 76    else
 77        printf 'Cloning %s/%s\n' "$username" "$name"
 78        git clone --mirror -- "$url" "$staging/clone" || return 1
 79        mv -T -- "$staging/clone" "$target" || return 1
 80    fi
 81    # Fetch does not update symbolic HEAD when GitHub changes the default branch.
 82    if [[ -n $branch ]] && git -C "$target" show-ref --verify --quiet "refs/heads/$branch"; then
 83        git -C "$target" symbolic-ref HEAD "refs/heads/$branch" || return 1
 84    fi
 85    printf '%s\n' "$description" > "$target/description" || return 1
 86}
 87
 88page=1
 89mirrored=0
 90failed=0
 91while :; do
 92    if ! fetch_page "https://api.github.com/users/$username/repos?type=owner&sort=full_name&direction=asc&per_page=100&page=$page"; then
 93        printf 'GitHub API request failed; check the username, network, and API rate limit.\n' >&2
 94        exit 1
 95    fi
 96    # NUL-separated fields preserve spaces, tabs, and multiline descriptions safely.
 97    python3 - "$staging/page.json" "$staging/records" "$staging/count" <<'PY'
 98import json
 99import re
100import sys
101from pathlib import Path
102
103repos = json.loads(Path(sys.argv[1]).read_text())
104if not isinstance(repos, list):
105    raise SystemExit("Expected a repository list from GitHub.")
106with open(sys.argv[2], "wb") as records:
107    for repo in repos:
108        if repo.get("private") is not False:
109            continue
110        name = repo["name"]
111        if not re.fullmatch(r"[A-Za-z0-9_.-]+", name) or name in (".", ".."):
112            raise SystemExit("Invalid repository name from GitHub.")
113        for value in (name, repo.get("default_branch") or "", repo.get("description") or ""):
114            records.write(value.replace("\0", "").encode() + b"\0")
115Path(sys.argv[3]).write_text(str(len(repos)))
116PY
117    while IFS= read -r -d '' name && IFS= read -r -d '' branch && IFS= read -r -d '' description; do
118        if mirror_repo "$name" "$branch" "$description"; then
119            mirrored=$((mirrored + 1))
120        else
121            printf 'Failed to mirror %s/%s; continuing.\n' "$username" "$name" >&2
122            # Remove only this job's incomplete clone before processing the next repository.
123            rm -rf -- "$staging/clone"
124            failed=$((failed + 1))
125        fi
126    done < "$staging/records"
127    count=$(cat "$staging/count")
128    if (( count < 100 )); then
129        break
130    fi
131    page=$((page + 1))
132done
133printf 'Mirrored %d repositories to %s; %d failed.\n' "$mirrored" "$destination" "$failed"
134(( failed == 0 ))