Created
May 26, 2026 16:51
-
-
Save hexsprite/5b205cfa6025531a3305caaccd47f8c5 to your computer and use it in GitHub Desktop.
Rebuild a working branch by cherry-picking your open GitHub PRs onto fresh upstream main. Uses git rerere to replay conflict resolutions across runs.
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 | |
| # pr-restack — rebuild a working branch by cherry-picking my open PRs | |
| # on top of an upstream base. Relies on git rerere to replay conflict | |
| # resolutions across runs. | |
| # | |
| # Usage: | |
| # pr-restack # from inside any git repo with gh access | |
| # pr-restack -b my-branch # custom branch name | |
| # pr-restack -r upstream/main # custom base ref | |
| # pr-restack -o origin -u upstream # remote names | |
| # pr-restack -a @me # gh PR author filter | |
| # pr-restack -B main # gh --base filter | |
| # pr-restack -n # dry run: list PRs only | |
| # | |
| # Defaults: | |
| # - Runs in current git repo. | |
| # - Picks base ref by trying: upstream/main, upstream/master, | |
| # origin/main, origin/master (first that exists). | |
| # - Filters PRs by --author @me --state open --base <main>. | |
| set -euo pipefail | |
| UPSTREAM_REMOTE="upstream" | |
| ORIGIN_REMOTE="origin" | |
| BASE_REF="" | |
| AUTHOR="@me" | |
| BASE_BRANCH="" | |
| BRANCH="" | |
| DRY_RUN=0 | |
| while getopts "b:r:o:u:a:B:nh" opt; do | |
| case "$opt" in | |
| b) BRANCH="$OPTARG" ;; | |
| r) BASE_REF="$OPTARG" ;; | |
| o) ORIGIN_REMOTE="$OPTARG" ;; | |
| u) UPSTREAM_REMOTE="$OPTARG" ;; | |
| a) AUTHOR="$OPTARG" ;; | |
| B) BASE_BRANCH="$OPTARG" ;; | |
| n) DRY_RUN=1 ;; | |
| h) sed -n '2,20p' "$0"; exit 0 ;; | |
| *) exit 2 ;; | |
| esac | |
| done | |
| # Must be inside a git repo. | |
| REPO_TOPLEVEL="$(git rev-parse --show-toplevel 2>/dev/null || true)" | |
| if [[ -z "$REPO_TOPLEVEL" ]]; then | |
| echo "ERROR: not inside a git repo" >&2 | |
| exit 1 | |
| fi | |
| cd "$REPO_TOPLEVEL" | |
| if [[ -n "$(git status --porcelain --untracked-files=no)" ]]; then | |
| echo "ERROR: tracked changes present (commit or stash first)" >&2 | |
| exit 1 | |
| fi | |
| # Auto-detect remotes if defaults missing. | |
| if ! git remote get-url "$UPSTREAM_REMOTE" >/dev/null 2>&1; then | |
| # No upstream remote — fall back to origin for both. | |
| UPSTREAM_REMOTE="$ORIGIN_REMOTE" | |
| fi | |
| git config rerere.enabled true | |
| git config rerere.autoupdate true | |
| echo "==> Fetching $UPSTREAM_REMOTE${UPSTREAM_REMOTE:+ }${ORIGIN_REMOTE}" | |
| git fetch --prune "$UPSTREAM_REMOTE" | |
| if [[ "$ORIGIN_REMOTE" != "$UPSTREAM_REMOTE" ]]; then | |
| git fetch --prune "$ORIGIN_REMOTE" | |
| fi | |
| # Resolve BASE_REF: explicit > first existing of common candidates. | |
| if [[ -z "$BASE_REF" ]]; then | |
| for cand in \ | |
| "$UPSTREAM_REMOTE/main" "$UPSTREAM_REMOTE/master" \ | |
| "$ORIGIN_REMOTE/main" "$ORIGIN_REMOTE/master"; do | |
| if git rev-parse --verify --quiet "$cand" >/dev/null; then | |
| BASE_REF="$cand" | |
| break | |
| fi | |
| done | |
| fi | |
| if [[ -z "$BASE_REF" ]] || ! git rev-parse --verify --quiet "$BASE_REF" >/dev/null; then | |
| echo "ERROR: could not resolve base ref (tried upstream/main, upstream/master, origin/main, origin/master)" >&2 | |
| exit 1 | |
| fi | |
| # Derive --base filter from BASE_REF if not given. | |
| if [[ -z "$BASE_BRANCH" ]]; then | |
| BASE_BRANCH="${BASE_REF##*/}" | |
| fi | |
| # Default branch name. | |
| if [[ -z "$BRANCH" ]]; then | |
| BRANCH="restack-$(date +%Y%m%d-%H%M%S)" | |
| fi | |
| echo "==> Base: $BASE_REF Branch: $BRANCH Author: $AUTHOR" | |
| echo "==> Listing open PRs (--base $BASE_BRANCH --author $AUTHOR)" | |
| PRS_JSON="$(gh pr list \ | |
| --author "$AUTHOR" \ | |
| --state open \ | |
| --base "$BASE_BRANCH" \ | |
| --json number,title,headRefName,headRefOid,headRepositoryOwner \ | |
| --limit 100)" | |
| COUNT=$(echo "$PRS_JSON" | jq 'length') | |
| if [[ "$COUNT" -eq 0 ]]; then | |
| echo "No open PRs found." | |
| exit 0 | |
| fi | |
| echo "Found $COUNT PR(s):" | |
| echo "$PRS_JSON" | jq -r '.[] | " #\(.number) \(.headRefName) — \(.title)"' | |
| if [[ "$DRY_RUN" -eq 1 ]]; then | |
| exit 0 | |
| fi | |
| echo "==> Creating $BRANCH from $BASE_REF" | |
| git checkout -B "$BRANCH" "$BASE_REF" | |
| # Cherry-pick each PR's commit range (BASE_REF..headRefOid). | |
| # Multi-commit PRs preserve their commit boundaries. | |
| echo "$PRS_JSON" | jq -c '.[]' | while read -r pr; do | |
| num=$(echo "$pr" | jq -r '.number') | |
| head=$(echo "$pr" | jq -r '.headRefName') | |
| sha=$(echo "$pr" | jq -r '.headRefOid') | |
| echo | |
| echo "==> PR #$num ($head) — picking $BASE_REF..$sha" | |
| if ! git cat-file -e "$sha^{commit}" 2>/dev/null; then | |
| echo " fetching $head from $ORIGIN_REMOTE" | |
| git fetch "$ORIGIN_REMOTE" "$head" || true | |
| fi | |
| if ! git cat-file -e "$sha^{commit}" 2>/dev/null; then | |
| # gh's pr/<num>/head ref always exists on the upstream repo. | |
| echo " fetching pr/$num from $UPSTREAM_REMOTE" | |
| git fetch "$UPSTREAM_REMOTE" "pull/$num/head:refs/pr-restack/$num" 2>/dev/null || true | |
| if git rev-parse --verify --quiet "refs/pr-restack/$num" >/dev/null; then | |
| sha="$(git rev-parse "refs/pr-restack/$num")" | |
| fi | |
| fi | |
| if ! git cat-file -e "$sha^{commit}" 2>/dev/null; then | |
| echo " ERROR: commit $sha not reachable; skipping" >&2 | |
| continue | |
| fi | |
| if git cherry-pick -x "$BASE_REF..$sha"; then | |
| echo " OK" | |
| else | |
| if [[ -z "$(git ls-files -u)" ]]; then | |
| echo " rerere resolved — continuing" | |
| GIT_EDITOR=true git cherry-pick --continue | |
| else | |
| echo " CONFLICT in PR #$num — resolve, then:" | |
| echo " git cherry-pick --continue # finish this PR" | |
| echo " $0 -b $BRANCH # resume remaining PRs" | |
| exit 1 | |
| fi | |
| fi | |
| done | |
| echo | |
| echo "==> Done. Branch: $BRANCH" | |
| git log --oneline "$BASE_REF..HEAD" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment