Skip to content

Instantly share code, notes, and snippets.

@alexlib
Created April 8, 2026 19:06
Show Gist options
  • Select an option

  • Save alexlib/2e4d36adc253d692ea6ec1c54b1a406d to your computer and use it in GitHub Desktop.

Select an option

Save alexlib/2e4d36adc253d692ea6ec1c54b1a406d to your computer and use it in GitHub Desktop.
github action that allows me to sync all the forks and store a report in markdown file, generated by github copilot
#!/usr/bin/env bash
set -euo pipefail
# Config
USER_LOGIN="${1:-alexlib}"
REPORT_FILE="${2:-fork-sync-report.md}"
SYNC="${SYNC:-1}" # SYNC=1 to sync clean forks, SYNC=0 to only report
PER_PAGE="${PER_PAGE:-100}" # pagination size
tmp_json="$(mktemp)"
trap 'rm -f "$tmp_json"' EXIT
echo "# Fork sync report for \`$USER_LOGIN\`" > "$REPORT_FILE"
echo "" >> "$REPORT_FILE"
echo "_Generated: $(date -u +"%Y-%m-%d %H:%M:%SZ")_" >> "$REPORT_FILE"
echo "" >> "$REPORT_FILE"
echo "| Fork | Upstream | Default branch | Behind | Ahead | Action | Notes |" >> "$REPORT_FILE"
echo "|---|---|---:|---:|---:|---|---|" >> "$REPORT_FILE"
page=1
total_processed=0
while :; do
# Fetch forks for the user (only repositories where fork=true)
gh api "users/${USER_LOGIN}/repos?type=owner&per_page=${PER_PAGE}&page=${page}" > "$tmp_json"
# Stop if empty
if [[ "$(jq 'length' < "$tmp_json")" -eq 0 ]]; then
break
fi
# Iterate repos; filter fork=true
jq -c '.[] | select(.fork == true) | {full_name, html_url, default_branch, archived, disabled}' < "$tmp_json" \
| while read -r repo; do
total_processed=$((total_processed+1))
full_name="$(jq -r '.full_name' <<<"$repo")"
fork_url="$(jq -r '.html_url' <<<"$repo")"
default_branch="$(jq -r '.default_branch' <<<"$repo")"
archived="$(jq -r '.archived' <<<"$repo")"
disabled="$(jq -r '.disabled' <<<"$repo")"
action="skipped"
notes=""
# Determine upstream via the repo API (parent exists for forks)
parent_full_name="$(gh api "repos/${full_name}" --jq '.parent.full_name // empty' 2>/dev/null || true)"
parent_url="$(gh api "repos/${full_name}" --jq '.parent.html_url // empty' 2>/dev/null || true)"
if [[ -z "${parent_full_name}" ]]; then
# Not all forks expose parent (or permissions), but usually they do.
echo "| [\`${full_name}\`](${fork_url}) | (unknown) | \`${default_branch}\` | | | skipped | no upstream parent found (not a fork? permissions?) |" >> "$REPORT_FILE"
continue
fi
# Skip archived/disabled forks
if [[ "${archived}" == "true" || "${disabled}" == "true" ]]; then
notes="archived/disabled"
echo "| [\`${full_name}\`](${fork_url}) | [\`${parent_full_name}\`](${parent_url}) | \`${default_branch}\` | | | skipped | ${notes} |" >> "$REPORT_FILE"
continue
fi
# Compare upstream default branch to fork default branch.
# This works even if branch names differ, but we use each repo's default branch.
upstream_default_branch="$(gh api "repos/${parent_full_name}" --jq '.default_branch')"
# Compare: base=upstream, head=fork (needs owner:branch syntax)
compare_json="$(gh api "repos/${parent_full_name}/compare/${upstream_default_branch}...${full_name}:${default_branch}" 2>/dev/null || true)"
if [[ -z "${compare_json}" ]]; then
echo "| [\`${full_name}\`](${fork_url}) | [\`${parent_full_name}\`](${parent_url}) | \`${default_branch}\` | | | skipped | compare failed (branch mismatch? force-push? permissions?) |" >> "$REPORT_FILE"
continue
fi
behind_by="$(jq -r '.behind_by // empty' <<<"$compare_json")"
ahead_by="$(jq -r '.ahead_by // empty' <<<"$compare_json")"
status="$(jq -r '.status // empty' <<<"$compare_json")"
# Decide action
if [[ "${behind_by}" =~ ^[0-9]+$ && "${ahead_by}" =~ ^[0-9]+$ ]]; then
if [[ "${behind_by}" -gt 0 && "${ahead_by}" -eq 0 ]]; then
if [[ "${SYNC}" == "1" ]]; then
if gh repo sync "${full_name}" --branch "${default_branch}" >/dev/null 2>&1; then
action="synced"
notes="clean fork; fast-forwarded from upstream (${upstream_default_branch})"
else
action="failed"
notes="sync command failed"
fi
else
action="would-sync"
notes="clean fork; report-only mode"
fi
elif [[ "${behind_by}" -eq 0 && "${ahead_by}" -eq 0 ]]; then
action="up-to-date"
notes="no changes"
elif [[ "${ahead_by}" -gt 0 && "${behind_by}" -eq 0 ]]; then
action="skipped"
notes="fork is ahead of upstream (local commits); not syncing"
else
action="skipped"
notes="diverged (ahead and behind); not syncing"
fi
else
action="skipped"
notes="could not parse ahead/behind"
fi
# Add helpful compare link
compare_url="https://github.com/${parent_full_name}/compare/${upstream_default_branch}...${full_name}:${default_branch}"
echo "| [\`${full_name}\`](${fork_url}) | [\`${parent_full_name}\`](${parent_url}) | \`${default_branch}\` | ${behind_by:-} | ${ahead_by:-} | ${action} | ${notes}. [Compare](${compare_url}) |" >> "$REPORT_FILE"
done
page=$((page+1))
done
echo "" >> "$REPORT_FILE"
echo "## How to read this report" >> "$REPORT_FILE"
echo "" >> "$REPORT_FILE"
echo "- **synced**: fork was behind and had no local commits; it was fast-forwarded." >> "$REPORT_FILE"
echo "- **skipped (ahead/diverged)**: fork has commits not in upstream and needs manual review (PR/rebase/cherry-pick)." >> "$REPORT_FILE"
echo "- **compare failed**: usually branch mismatch or permission/API limitation; open the repo and verify default branch/upstream." >> "$REPORT_FILE"
echo "Wrote report to: ${REPORT_FILE}" >&2
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment