Skip to content

Instantly share code, notes, and snippets.

@RichardFevrier
Last active March 13, 2026 18:07
Show Gist options
  • Select an option

  • Save RichardFevrier/447c90f54baa9f029c25040f9bf718e5 to your computer and use it in GitHub Desktop.

Select an option

Save RichardFevrier/447c90f54baa9f029c25040f9bf718e5 to your computer and use it in GitHub Desktop.
#!/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