Last active
May 12, 2026 14:02
-
-
Save Borda/9f60d0115c1232d8914c356f2d5af101 to your computer and use it in GitHub Desktop.
Scans an org or user account for forked repositories that have never diverged from their upstream, making them obvious candidates for archival. For each fork it fetches the upstream and compares default branches via the GitHub compare API; anything with ahead_by == 0 is written to clean-forks.csv. The script is resilient to per-repo API failures…
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 | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # list-clean-forks.sh | |
| # | |
| # Find forks in an org/user account that never diverged from their upstream, | |
| # so they're safe to archive or delete. | |
| # | |
| # For each non-archived fork, compares the fork's default branch against the | |
| # upstream's default branch via the GitHub compare API. A fork is "clean" if | |
| # ahead_by == 0 — i.e., zero commits on top of upstream. | |
| # | |
| # Resilient to per-repo failures: enterprise PAT policies, deleted upstreams, | |
| # 404s, and other API errors don't abort the run — those forks are logged to | |
| # a separate failed CSV with a short reason. | |
| # | |
| # Usage: bash list-clean-forks.sh <org-or-user> | |
| # Defaults: ORG=Borda LIMIT=1000 OUT=clean-forks.csv FAILED=failed-forks.csv | |
| # Outputs: clean-forks.csv | |
| # repo, visibility, parent, default_branch, url | |
| # failed-forks.csv | |
| # repo, visibility, reason, url | |
| # (visibility blank if the initial repo lookup failed) | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| set -uo pipefail | |
| ORG="${1:-Borda}" | |
| LIMIT="${LIMIT:-1000}" | |
| OUT="${OUT:-clean-forks.csv}" | |
| FAILED="${FAILED:-failed-forks.csv}" | |
| echo 'repo,visibility,parent,default_branch,url' > "$OUT" | |
| echo 'repo,visibility,reason,url' > "$FAILED" | |
| # Step 1: bulk list — slugs ONLY (no `parent` field → no enterprise trigger) | |
| echo "→ Listing fork slugs in: $ORG" >&2 | |
| fork_slugs=$(gh repo list "$ORG" --fork --no-archived --limit "$LIMIT" \ | |
| --json nameWithOwner --jq '.[].nameWithOwner' 2>/dev/null) | |
| count=$(printf '%s\n' "$fork_slugs" | grep -c . || true) | |
| echo "→ Found $count fork(s) to check" >&2 | |
| [[ "$count" == "0" ]] && { echo "→ Nothing to do." >&2; exit 0; } | |
| extract_reason() { | |
| local blob="$1" | |
| if echo "$blob" | grep -qi "Microsoft Open Source"; then echo "enterprise_blocked_ms_oss" | |
| elif echo "$blob" | grep -qi "enterprise forbids"; then echo "enterprise_blocked" | |
| elif echo "$blob" | grep -qi "HTTP 404"; then echo "not_found" | |
| elif echo "$blob" | grep -qi "HTTP 403"; then echo "forbidden" | |
| elif echo "$blob" | grep -qi "No common ancestor"; then echo "no_common_ancestor" | |
| else echo "api_error" | |
| fi | |
| } | |
| # Step 2: per-fork REST — failures are isolated | |
| while IFS= read -r fork; do | |
| [[ -z "$fork" ]] && continue | |
| url="https://github.com/$fork" | |
| info=$(gh api "repos/$fork" 2>&1) | |
| if [[ $? -ne 0 ]]; then | |
| reason=$(extract_reason "$info") | |
| printf '"%s","","%s","%s"\n' "$fork" "$reason" "$url" >> "$FAILED" | |
| printf " %-50s SKIP %s\n" "$fork" "$reason" >&2 | |
| continue | |
| fi | |
| parent=$(echo "$info" | jq -r '.parent.full_name // empty') | |
| fork_branch=$(echo "$info" | jq -r '.default_branch') | |
| visibility=$(echo "$info" | jq -r 'if .private then "private" else "public" end') | |
| if [[ -z "$parent" ]]; then | |
| printf '"%s","%s","%s","%s"\n' "$fork" "$visibility" "parent_deleted" "$url" >> "$FAILED" | |
| printf " %-50s SKIP parent_deleted\n" "$fork" >&2 | |
| continue | |
| fi | |
| parent_branch=$(gh api "repos/$parent" --jq '.default_branch' 2>/dev/null || echo "$fork_branch") | |
| fork_owner="${fork%/*}" | |
| cmp=$(gh api "repos/$parent/compare/$parent_branch...$fork_owner:$fork_branch" 2>&1) | |
| if [[ $? -ne 0 ]]; then | |
| reason=$(extract_reason "$cmp") | |
| printf '"%s","%s","%s","%s"\n' "$fork" "$visibility" "$reason" "$url" >> "$FAILED" | |
| printf " %-50s SKIP %s (parent=%s)\n" "$fork" "$reason" "$parent" >&2 | |
| continue | |
| fi | |
| ahead=$(echo "$cmp" | jq -r '.ahead_by') | |
| printf " %-50s ahead_by=%s [%s]\n" "$fork" "$ahead" "$visibility" >&2 | |
| if [[ "$ahead" == "0" ]]; then | |
| printf '"%s","%s","%s","%s","%s"\n' "$fork" "$visibility" "$parent" "$fork_branch" "$url" >> "$OUT" | |
| fi | |
| done <<< "$fork_slugs" | |
| clean=$(($(wc -l < "$OUT") - 1)) | |
| failed=$(($(wc -l < "$FAILED") - 1)) | |
| echo "→ Done. $clean clean fork(s) → $OUT" >&2 | |
| echo " $failed failed/skipped → $FAILED" >&2 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment