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 ))