Skip to content

Instantly share code, notes, and snippets.

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

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

Select an option

Save RichardFevrier/dcfcc55ed38e16be4e6f1288c22399fe to your computer and use it in GitHub Desktop.
#!/bin/sh
# check_forks_upstream.sh
# Lists forks on your GitHub account where you have never contributed:
# 1. No commits on default branch authored by you
# 2. No orphan branches (branches not present in upstream) whose first commit is yours
#
# Usage (GitHub Actions):
# curl -fsSL https://gist.github.com/RichardFevrier/dcfcc55ed38e16be4e6f1288c22399fe/raw/check_forks_upstream.sh \
# | GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }} sh
#
# Usage (local):
# curl -fsSL <url> | GITHUB_TOKEN=ghp_xxx sh
# ── Helpers ──────────────────────────────────────────────────────────────────
die() { printf 'Error: %s\n' "$1" >&2; exit 1; }
_api() (
curl -sL \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com${1}"
)
_api_ok() (
resp="$1"
required_key="$2"
[ -n "$resp" ] || return 1
if printf '%s' "$resp" | grep -q '"message"' &&
! printf '%s' "$resp" | grep -q "\"${required_key}\""; then
msg=$(printf '%s' "$resp" | tr -d '\n' \
| sed 's/.*"message"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')
case "$msg" in
'Not Found') return 1 ;;
esac
printf 'Warning: GitHub API error: %s\n' "$msg" >&2
return 1
fi
return 0
)
_branch_names() (
printf '%s' "$1" \
| grep -o '"name"[[:space:]]*:[[:space:]]*"[^"]*"' \
| sed 's/"name"[[:space:]]*:[[:space:]]*"\([^"]*\)"/\1/'
)
# ── Preflight ─────────────────────────────────────────────────────────────────
[ -n "$GITHUB_TOKEN" ] || die "GITHUB_TOKEN is not set"
command -v curl >/dev/null 2>&1 || die "curl is required"
user_resp=$(_api "/user")
_api_ok "$user_resp" "login" || die "Could not authenticate with GitHub API"
LOGIN=$(printf '%s' "$user_resp" \
| grep -o '"login"[[:space:]]*:[[:space:]]*"[^"]*"' \
| head -1 \
| sed 's/"login"[[:space:]]*:[[:space:]]*"\([^"]*\)"/\1/')
[ -n "$LOGIN" ] || die "Could not determine GitHub login"
printf 'Checking forks for: %s\n' "$LOGIN" >&2
# ── Main ──────────────────────────────────────────────────────────────────────
never_contributed=""
page=1
while true; do
resp=$(_api "/user/repos?type=fork&per_page=100&page=${page}")
_api_ok "$resp" "full_name" || break
names=$(printf '%s' "$resp" \
| grep -o '"full_name"[[:space:]]*:[[:space:]]*"[^"]*"' \
| sed 's/"full_name"[[:space:]]*:[[:space:]]*"\([^"]*\)"/\1/')
[ -n "$names" ] || break
for full_name in $names; do
# Fetch repo details to get parent (reused across all checks)
repo_resp=$(_api "/repos/${full_name}")
_api_ok "$repo_resp" "full_name" || continue
parent=$(printf '%s' "$repo_resp" \
| tr -d '\n' \
| grep -o '"parent"[[:space:]]*:[[:space:]]*{[^}]*}' \
| grep -o '"full_name"[[:space:]]*:[[:space:]]*"[^"]*"' \
| sed 's/"full_name"[[:space:]]*:[[:space:]]*"\([^"]*\)"/\1/')
[ -n "$parent" ] || continue
# Verify upstream still exists — if it was deleted, skip the fork entirely
upstream_check=$(_api "/repos/${parent}")
_api_ok "$upstream_check" "full_name" || continue
# 1a. Default branch ahead of upstream? (catches merges, auto-commits)
compare_resp=$(_api "/repos/${parent}/compare/HEAD...${LOGIN}:HEAD")
_api_ok "$compare_resp" "ahead_by" || continue
ahead=$(printf '%s' "$compare_resp" \
| grep -o '"ahead_by"[[:space:]]*:[[:space:]]*[0-9]*' \
| sed 's/[^0-9]//g')
[ "${ahead:-0}" -eq 0 ] || continue
# 1b. Any commit on default branch authored by LOGIN?
commits_resp=$(_api "/repos/${full_name}/commits?author=${LOGIN}&per_page=1")
has_commit=$(printf '%s' "$commits_resp" | grep -c '"sha"' || true)
[ "${has_commit:-0}" -eq 0 ] || continue
# 2. Any orphan branches (not present in upstream)?
# Paginate fork branches
fork_branches=""
bp=1
while true; do
page_resp=$(_api "/repos/${full_name}/branches?per_page=100&page=${bp}")
page_names=$(_branch_names "$page_resp")
[ -n "$page_names" ] || break
fork_branches="${fork_branches}${page_names}
"
bp=$((bp + 1))
done
# Paginate upstream branches
upstream_branches=""
bp=1
while true; do
page_resp=$(_api "/repos/${parent}/branches?per_page=100&page=${bp}")
page_names=$(_branch_names "$page_resp")
[ -n "$page_names" ] || break
upstream_branches="${upstream_branches}${page_names}
"
bp=$((bp + 1))
done
# For each orphan branch, check if its first (oldest) commit was authored by LOGIN
contributed=0
for branch in $fork_branches; do
# Skip if branch exists in upstream
for ub in $upstream_branches; do
[ "$branch" = "$ub" ] && continue 2
done
# Orphan branch — get its first commit (oldest = last page, last item)
# We use the since/until trick: walk to the last page
first_commit_author=""
cp=1
while true; do
cpage_resp=$(_api "/repos/${full_name}/commits?sha=${branch}&per_page=100&page=${cp}")
page_shas=$(printf '%s' "$cpage_resp" | grep -c '"sha"' || true)
[ "${page_shas:-0}" -gt 0 ] || break
last_resp="$cpage_resp"
cp=$((cp + 1))
done
# Extract the last commit's author login from the last page
first_commit_author=$(printf '%s' "$last_resp" \
| tr -d '\n' \
| grep -o '"author"[[:space:]]*:[[:space:]]*{[^}]*}' \
| tail -1 \
| grep -o '"login"[[:space:]]*:[[:space:]]*"[^"]*"' \
| sed 's/"login"[[:space:]]*:[[:space:]]*"\([^"]*\)"/\1/')
if [ "$first_commit_author" = "$LOGIN" ]; then
contributed=1
break
fi
done
[ "$contributed" -eq 0 ] || continue
never_contributed="${never_contributed}${full_name}
"
printf 'Never contributed: %s\n' "$full_name" >&2
done
page=$((page + 1))
done
# ── GitHub Actions step summary ───────────────────────────────────────────────
if [ -n "$GITHUB_STEP_SUMMARY" ]; then
{
printf '## Forks with no contribution from %s\n\n' "$LOGIN"
if [ -n "$never_contributed" ]; then
printf '%s' "$never_contributed" | while IFS= read -r repo; do
[ -n "$repo" ] && printf '%s\n' "- [${repo}](https://github.com/${repo})"
done
else
printf '_You have authored commits in all your forks._\n'
fi
} >> "$GITHUB_STEP_SUMMARY"
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment