|
6 | 6 | # SQUASH_COMMIT - The hash of the squash commit that was merged |
7 | 7 | # MERGED_BRANCH - The name of the branch that was merged and will be deleted |
8 | 8 | # TARGET_BRANCH - The name of the branch that the PR was merged into |
| 9 | +# PR_NUMBER - The number of the PR that was merged |
9 | 10 | # |
10 | 11 | # Required environment variables (conflict-resolved mode): |
11 | 12 | # PR_BRANCH - The head branch of the PR being resumed |
@@ -85,6 +86,63 @@ has_squash_commit() { |
85 | 86 | && git merge-base --is-ancestor SQUASH_COMMIT "$BRANCH" |
86 | 87 | } |
87 | 88 |
|
| 89 | +# Args: a commit sha. Echoes the numbers of the pull requests that introduced |
| 90 | +# the commit to the repository, one per line. |
| 91 | +commit_pull_numbers() { |
| 92 | + gh api "repos/{owner}/{repo}/commits/$1/pulls" --jq '.[].number' \ |
| 93 | + || { echo "❌ Could not list the pull requests that introduced commit $1" >&2; return 1; } |
| 94 | +} |
| 95 | + |
| 96 | +# Args: the merge commit sha, the merged PR's number. The association is |
| 97 | +# computed asynchronously, some time after the merge. The merge commit always |
| 98 | +# belongs to the merged PR, so once it shows up the index has caught up with |
| 99 | +# this merge; until then, an empty answer for any commit of the merge means |
| 100 | +# nothing. Exits if the association never appears. |
| 101 | +wait_for_pull_association() { |
| 102 | + local MERGE_SHA="$1" PR_NUMBER="$2" |
| 103 | + local NUMBERS |
| 104 | + for _ in $(seq 1 24); do |
| 105 | + NUMBERS=$(commit_pull_numbers "$MERGE_SHA") || exit 1 |
| 106 | + if grep -qx "$PR_NUMBER" <<<"$NUMBERS"; then |
| 107 | + return 0 |
| 108 | + fi |
| 109 | + sleep "${ASSOCIATION_POLL_SECONDS:-5}" |
| 110 | + done |
| 111 | + echo "❌ GitHub never associated $MERGE_SHA with PR #$PR_NUMBER; cannot tell a squash from a rebase" >&2 |
| 112 | + exit 1 |
| 113 | +} |
| 114 | + |
| 115 | +# Args: the merged PR's number. The event payload does not say which merge |
| 116 | +# method was used (GitHub records it nowhere), but GitHub associates every |
| 117 | +# trunk commit with the PR that introduced it. A squash introduces a single |
| 118 | +# commit, so the commit below SQUASH_COMMIT belongs to an older PR or to |
| 119 | +# none; a rebase introduces a copy of each PR commit, so with two or more |
| 120 | +# commits the one below SQUASH_COMMIT still belongs to this PR. A |
| 121 | +# single-commit PR merges identically under rebase and squash and correctly |
| 122 | +# reads as a squash here. |
| 123 | +is_rebase_merge() { |
| 124 | + local PR_NUMBER="$1" |
| 125 | + local MERGE_SHA PARENT_SHA NUMBERS |
| 126 | + MERGE_SHA=$(git rev-parse SQUASH_COMMIT) |
| 127 | + PARENT_SHA=$(git rev-parse SQUASH_COMMIT~) |
| 128 | + |
| 129 | + NUMBERS=$(commit_pull_numbers "$PARENT_SHA") || exit 1 |
| 130 | + if [[ -z "$NUMBERS" ]]; then |
| 131 | + # Ambiguous: "no PR introduced this commit" (a squash on top of a |
| 132 | + # direct push) and "not indexed yet" (a rebase copy) both come back |
| 133 | + # empty. Wait until the index has caught up with this merge, then ask |
| 134 | + # again; this time empty really means no PR. |
| 135 | + wait_for_pull_association "$MERGE_SHA" "$PR_NUMBER" |
| 136 | + NUMBERS=$(commit_pull_numbers "$PARENT_SHA") || exit 1 |
| 137 | + fi |
| 138 | + grep -qx "$PR_NUMBER" <<<"$NUMBERS" |
| 139 | +} |
| 140 | + |
| 141 | +# Echoes "<number> <head branch>" for each open PR based on the merged branch. |
| 142 | +list_child_prs() { |
| 143 | + log_cmd gh pr list --base "$MERGED_BRANCH" --json number,headRefName --jq '.[] | "\(.number) \(.headRefName)"' |
| 144 | +} |
| 145 | + |
88 | 146 | # Args: head branch, base branch, PR number. git commands use the branch; gh |
89 | 147 | # commands use the number, since a head branch can carry several PRs. |
90 | 148 | update_direct_target() { |
@@ -308,17 +366,46 @@ main() { |
308 | 366 | check_env_var "SQUASH_COMMIT" |
309 | 367 | check_env_var "MERGED_BRANCH" |
310 | 368 | check_env_var "TARGET_BRANCH" |
| 369 | + check_env_var "PR_NUMBER" |
311 | 370 |
|
312 | 371 | log_cmd git update-ref SQUASH_COMMIT "$SQUASH_COMMIT" |
313 | 372 |
|
| 373 | + # A merge-commit merge does not rewrite history: each child's head already |
| 374 | + # contains the merged branch's commits, and the merge commit carries them |
| 375 | + # into TARGET_BRANCH. The heads need no synthetic merge; just retarget the |
| 376 | + # children and delete the merged branch. |
| 377 | + if git rev-parse --verify --quiet SQUASH_COMMIT^2 >/dev/null; then |
| 378 | + echo "✓ '$MERGED_BRANCH' was merged with a merge commit, not squashed; retargeting children without touching their heads" |
| 379 | + while read -r NUMBER BRANCH; do |
| 380 | + [[ -n "$BRANCH" ]] || continue |
| 381 | + log_cmd gh pr edit "$NUMBER" --base "$TARGET_BRANCH" |
| 382 | + done < <(list_child_prs) |
| 383 | + # Deleting a PR's base branch closes the PR, so the retargets come first. |
| 384 | + log_cmd git push origin ":$MERGED_BRANCH" |
| 385 | + return 0 |
| 386 | + fi |
| 387 | + |
| 388 | + # Rebase merges are not supported: the copies on the target are new |
| 389 | + # commits, so a child retargeted as-is would show its parent's changes in |
| 390 | + # its diff, and the squash sequence can raise spurious conflicts against |
| 391 | + # the intermediate copies. Tell the children and leave everything alone. |
| 392 | + if is_rebase_merge "$PR_NUMBER"; then |
| 393 | + echo "⚠️ '$MERGED_BRANCH' looks rebase-merged; rebase merges are not supported, leaving the stack alone" |
| 394 | + while read -r NUMBER BRANCH; do |
| 395 | + [[ -n "$BRANCH" ]] || continue |
| 396 | + log_cmd gh pr comment "$NUMBER" --body "ℹ️ The base branch \`$MERGED_BRANCH\` of this PR was merged with \"Rebase and merge\", which autorestack does not support. Update this PR manually. \`$MERGED_BRANCH\` was kept so this PR stays open." |
| 397 | + done < <(list_child_prs) |
| 398 | + return 0 |
| 399 | + fi |
| 400 | + |
314 | 401 | # Find all PRs directly targeting the merged PR's head |
315 | 402 | INITIAL_NUMBERS=() |
316 | 403 | INITIAL_TARGETS=() |
317 | 404 | while read -r NUMBER BRANCH; do |
318 | 405 | [[ -n "$BRANCH" ]] || continue |
319 | 406 | INITIAL_NUMBERS+=("$NUMBER") |
320 | 407 | INITIAL_TARGETS+=("$BRANCH") |
321 | | - done < <(log_cmd gh pr list --base "$MERGED_BRANCH" --json number,headRefName --jq '.[] | "\(.number) \(.headRefName)"') |
| 408 | + done < <(list_child_prs) |
322 | 409 |
|
323 | 410 | # Track successfully updated vs conflicted branches separately |
324 | 411 | UPDATED_TARGETS=() |
|
0 commit comments