Last active
March 13, 2026 18:07
-
-
Save RichardFevrier/447c90f54baa9f029c25040f9bf718e5 to your computer and use it in GitHub Desktop.
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
| #!/bin/sh | |
| # find_local_clean_forks.sh | |
| # Recursively finds GitHub forks where nothing needs to be committed or pushed. | |
| # | |
| # Usage (installed): | |
| # find_local_clean_forks.sh <search_path> | |
| # | |
| # Usage (direct from gist, argument passed to sh via -s): | |
| # curl -fsSL https://gist.github.com/RichardFevrier/447c90f54baa9f029c25040f9bf718e5/raw/find_local_clean_forks.sh | sh -s <search_path> | |
| # | |
| # Optional env: | |
| # GITHUB_TOKEN=ghp_xxx — for private repos or to avoid API rate limits | |
| # Do NOT use set -e: boolean-returning functions would cause premature exit | |
| # ── Argument handling ──────────────────────────────────────────────────────── | |
| if [ $# -ne 1 ]; then | |
| printf 'Usage: %s <search_path>\n' "$0" >&2 | |
| printf ' or: curl -fsSL <gist_url> | sh -s <search_path>\n' >&2 | |
| exit 1 | |
| fi | |
| SEARCH_ROOT="$1" | |
| if [ ! -d "$SEARCH_ROOT" ]; then | |
| printf 'Error: "%s" is not a directory\n' "$SEARCH_ROOT" >&2 | |
| exit 1 | |
| fi | |
| # ── Dependency check ───────────────────────────────────────────────────────── | |
| for _cmd in git find; do | |
| if ! command -v "$_cmd" >/dev/null 2>&1; then | |
| printf 'Error: "%s" is required but not found\n' "$_cmd" >&2 | |
| exit 1 | |
| fi | |
| done | |
| if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then | |
| printf 'Error: curl or wget is required for GitHub API calls\n' >&2 | |
| exit 1 | |
| fi | |
| # ── Helpers ────────────────────────────────────────────────────────────────── | |
| # _api_get <url> — fetch URL following redirects, always print body (even on error) | |
| _api_get() ( | |
| url="$1" | |
| if command -v curl >/dev/null 2>&1; then | |
| if [ -n "$GITHUB_TOKEN" ]; then | |
| curl -sL -H "Authorization: Bearer ${GITHUB_TOKEN}" "$url" | |
| else | |
| curl -sL "$url" | |
| fi | |
| else | |
| if [ -n "$GITHUB_TOKEN" ]; then | |
| wget -qO- --header="Authorization: Bearer ${GITHUB_TOKEN}" "$url" | |
| else | |
| wget -qO- "$url" | |
| fi | |
| fi | |
| ) | |
| # _slug_from_url <remote_url> — extract "owner/repo" from a github.com remote URL | |
| _slug_from_url() ( | |
| printf '%s' "$1" \ | |
| | sed 's|.*github\.com[:/]\(.*\)|\1|' \ | |
| | sed 's|\.git$||' | |
| ) | |
| # ── Core predicates ─────────────────────────────────────────────────────────── | |
| # is_github_fork <repo_path> — returns 0 if any remote is a GitHub fork | |
| is_github_fork() ( | |
| repo="$1" | |
| remotes=$(git -C "$repo" remote -v 2>/dev/null | awk '/\(fetch\)/{print $2}') | |
| [ -n "$remotes" ] || exit 1 | |
| for url in $remotes; do | |
| case "$url" in *github.com*) ;; *) continue ;; esac | |
| slug=$(_slug_from_url "$url") | |
| [ -n "$slug" ] || continue | |
| resp=$(_api_get "https://api.github.com/repos/${slug}") || continue | |
| # Empty response — network error or curl/wget failure | |
| if [ -z "$resp" ]; then | |
| printf 'Warning: no response from GitHub API for %s\n' "$slug" >&2 | |
| continue | |
| fi | |
| # Detect API errors: error responses have "message" but no "fork" field. | |
| if printf '%s' "$resp" | grep -q '"message"' && | |
| ! printf '%s' "$resp" | grep -q '"fork"'; then | |
| msg=$(printf '%s' "$resp" | tr -d '\n' | sed 's/.*"message"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/') | |
| # 404 Not Found is silent: repo may be private, deleted, or renamed | |
| case "$msg" in | |
| 'Not Found') continue ;; | |
| esac | |
| printf 'Warning: GitHub API error for %s: %s\n' "$slug" "$msg" >&2 | |
| continue | |
| fi | |
| printf '%s' "$resp" | grep -q '"fork": *true' && exit 0 | |
| done | |
| exit 1 | |
| ) | |
| # is_clean_and_synced <repo_path> — returns 0 if: | |
| # • working tree is clean (no staged or unstaged changes) | |
| # • at least one branch has a tracked upstream | |
| # • no local branch is ahead of its upstream | |
| is_clean_and_synced() ( | |
| repo="$1" | |
| # Refresh remote tracking refs | |
| git -C "$repo" fetch --all --quiet 2>/dev/null || true | |
| # Uncommitted changes? | |
| git -C "$repo" diff --quiet 2>/dev/null || exit 1 | |
| git -C "$repo" diff --cached --quiet 2>/dev/null || exit 1 | |
| has_upstream=0 | |
| branches=$(git -C "$repo" for-each-ref --format='%(refname:short)' refs/heads/ 2>/dev/null) | |
| for branch in $branches; do | |
| # Skip branches with no upstream tracking ref | |
| upstream=$(git -C "$repo" rev-parse --abbrev-ref "${branch}@{upstream}" 2>/dev/null) \ | |
| || continue | |
| ahead=$(git -C "$repo" rev-list --count "${upstream}..${branch}" 2>/dev/null) \ | |
| || continue | |
| [ "$ahead" -gt 0 ] && exit 1 # unpushed commits | |
| has_upstream=1 | |
| done | |
| # Require at least one branch to have ever been pushed | |
| [ "$has_upstream" -eq 1 ] || exit 1 | |
| exit 0 | |
| ) | |
| # ── Main ────────────────────────────────────────────────────────────────────── | |
| find "$SEARCH_ROOT" -type d -name ".git" 2>/dev/null | while IFS= read -r git_dir; do | |
| repo="${git_dir%/.git}" | |
| if is_github_fork "$repo" && is_clean_and_synced "$repo"; then | |
| printf '%s\n' "$repo" | |
| fi | |
| done |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment