Created
April 8, 2026 19:06
-
-
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
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
| #!/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