Skip to content

Instantly share code, notes, and snippets.

@Borda
Last active May 12, 2026 14:02
Show Gist options
  • Select an option

  • Save Borda/9f60d0115c1232d8914c356f2d5af101 to your computer and use it in GitHub Desktop.

Select an option

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…
#!/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