-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathupdate-pr-stack.sh
More file actions
executable file
·271 lines (234 loc) · 9.29 KB
/
Copy pathupdate-pr-stack.sh
File metadata and controls
executable file
·271 lines (234 loc) · 9.29 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
#!/bin/bash
#
# Updates PR stack after merging a PR
#
# Required environment variables:
# SQUASH_COMMIT - The hash of the squash commit that was merged
# MERGED_BRANCH - The name of the branch that was merged and will be deleted
# TARGET_BRANCH - The name of the branch that the PR was merged into
#
# Design note:
# This script aims to output a transcript of "plain" git/gh commands that a
# human could follow through manually. For this reason:
# - We use git refs (e.g., SQUASH_COMMIT) instead of shell variables where
# possible, so the logged commands are self-contained and reproducible
# - We strive to keep commands as simple as possible
set -ueo pipefail # Exit on error, undefined var, or pipeline failure
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/command_utils.sh"
CONFLICT_LABEL="autorestack-needs-conflict-resolution"
# Allow replacing git and gh
[ -v GIT ] && git() { "$GIT" "$@"; }
[ -v GH ] && gh() { "$GH" "$@"; }
# Function to check if a required environment variable is set
check_env_var() {
if [ -z "${!1}" ]; then
echo "Error: $1 is not set" >&2
exit 1
fi
}
# Check if BASE is already an ancestor of BRANCH (simple merge check)
is_base_ancestor() {
local BRANCH="$1"
local BASE="$2"
git merge-base --is-ancestor "origin/$BASE" "origin/$BRANCH"
}
# Check if a branch already has the squash commit merged (squash-merge mode only)
# Requires SQUASH_COMMIT ref to be set via git update-ref
has_squash_commit() {
local BRANCH="$1"
local BASE="$2"
is_base_ancestor "$BRANCH" "$BASE" \
&& git merge-base --is-ancestor SQUASH_COMMIT "origin/$BRANCH"
}
format_branch_list_for_text() {
for ((i=1; i<=$#; i++)); do
case $i in
1) format='`%s`';;
$#) format=', and `%s`';;
*) format=', `%s`';;
esac
printf "$format" "${!i}"
done
}
update_direct_target() {
local BRANCH="$1"
local BASE_BRANCH="$2"
if has_squash_commit "$BRANCH" "$TARGET_BRANCH"; then
echo "✓ $BRANCH already up-to-date; skipping"
return 0
fi
echo "Updating direct target $BRANCH (from $MERGED_BRANCH to $BASE_BRANCH)"
log_cmd git checkout "$BRANCH"
CONFLICTS=()
log_cmd git update-ref BEFORE_MERGE HEAD
if ! log_cmd git merge --no-edit "origin/$MERGED_BRANCH"; then
CONFLICTS+=("origin/$MERGED_BRANCH")
log_cmd git merge --abort
fi
if ! log_cmd git merge --no-edit SQUASH_COMMIT~; then
CONFLICTS+=( "$(git rev-parse SQUASH_COMMIT~)" )
log_cmd git merge --abort
fi
if [[ "${#CONFLICTS[@]}" -gt 0 ]]; then
{
echo "### ⚠️ Automatic update blocked by merge conflicts"
echo
echo -n "I tried to merge "
format_branch_list_for_text "${CONFLICTS[@]}"
echo
echo "into this branch while updating the PR stack and hit conflicts."
echo
echo "#### How to resolve"
echo '```bash'
echo "git fetch origin"
echo "git switch $BRANCH"
for conflict in "${CONFLICTS[@]}"; do
echo "git merge $conflict"
echo "# ..."
echo "# fix conflicts, for instance with `git mergetool`"
echo "# ..."
echo "git commit"
done
echo "git push"
echo '```'
} | log_cmd gh pr comment "$BRANCH" -F -
# Create the label if it doesn't exist, then add it to the PR
gh label create "$CONFLICT_LABEL" --description "PR needs manual conflict resolution" --color "d73a4a" 2>/dev/null || true
log_cmd gh pr edit "$BRANCH" --add-label "$CONFLICT_LABEL"
return 1
else
log_cmd git merge --no-edit -s ours "$SQUASH_COMMIT"
log_cmd git update-ref MERGE_RESULT "HEAD^{tree}"
COMMIT_MSG="Merge updates from $BASE_BRANCH and squash commit"
CUSTOM_COMMIT=$(log_cmd git commit-tree MERGE_RESULT -p BEFORE_MERGE -p "origin/$MERGED_BRANCH" -p SQUASH_COMMIT -m "$COMMIT_MSG")
log_cmd git reset --hard "$CUSTOM_COMMIT"
fi
return 0
}
update_indirect_target() {
local BRANCH="$1"
local BASE_BRANCH="$2"
# For indirect targets, we only need to check if the parent is already
# an ancestor. If so, the branch already has all updates from the parent.
# (No SQUASH_COMMIT check needed - that's only for direct targets)
if is_base_ancestor "$BRANCH" "$BASE_BRANCH"; then
echo "✓ $BRANCH already up-to-date with $BASE_BRANCH; skipping"
return
fi
echo "Updating indirect target $BRANCH (based on $BASE_BRANCH)"
log_cmd git checkout "$BRANCH"
log_cmd git merge --no-edit "$BASE_BRANCH"
}
ALL_CHILDREN=()
update_branch_recursive() {
local BRANCH="$1"
# Find and update branches based on this one
CHILD_BRANCHES=$(log_cmd gh pr list --base "$BRANCH" --json headRefName --jq '.[].headRefName')
ALL_CHILDREN+=($CHILD_BRANCHES)
for CHILD_BRANCH in $CHILD_BRANCHES; do
update_indirect_target "$CHILD_BRANCH" "$BRANCH"
update_branch_recursive "$CHILD_BRANCH"
done
}
# Check if a PR has the conflict resolution label
pr_has_conflict_label() {
local BRANCH="$1"
local LABELS
LABELS=$(gh pr view "$BRANCH" --json labels --jq '.labels[].name' 2>/dev/null || echo "")
echo "$LABELS" | grep -q "^${CONFLICT_LABEL}$"
}
# Continue processing after user manually resolved conflicts
continue_after_resolution() {
check_env_var "PR_BRANCH"
echo "Checking if $PR_BRANCH needs continuation after conflict resolution..."
# Check if the PR has the conflict label
if ! pr_has_conflict_label "$PR_BRANCH"; then
echo "✓ $PR_BRANCH does not have conflict label; nothing to do"
return
fi
echo "Found conflict label on $PR_BRANCH, continuing stack update..."
# Remove the conflict label
log_cmd gh pr edit "$PR_BRANCH" --remove-label "$CONFLICT_LABEL"
# Post a comment acknowledging the resolution
echo "✅ Conflict resolved! Continuing to update dependent PRs..." | log_cmd gh pr comment "$PR_BRANCH" -F -
# Find and update child PRs (PRs based on this branch)
CHILD_BRANCHES=$(log_cmd gh pr list --base "$PR_BRANCH" --json headRefName --jq '.[].headRefName')
if [[ -z "$CHILD_BRANCHES" ]]; then
echo "✓ No child PRs to update"
return
fi
ALL_CHILDREN=()
for CHILD_BRANCH in $CHILD_BRANCHES; do
echo "Updating child branch $CHILD_BRANCH based on $PR_BRANCH"
log_cmd git checkout "$CHILD_BRANCH"
if ! log_cmd git merge --no-edit "origin/$PR_BRANCH"; then
echo "⚠️ Merge conflict updating $CHILD_BRANCH"
log_cmd git merge --abort
# Add conflict label to the child PR
gh label create "$CONFLICT_LABEL" --description "PR needs manual conflict resolution" --color "d73a4a" 2>/dev/null || true
log_cmd gh pr edit "$CHILD_BRANCH" --add-label "$CONFLICT_LABEL"
{
echo "### ⚠️ Automatic update blocked by merge conflicts"
echo
echo "I tried to merge \`origin/$PR_BRANCH\` into this branch while continuing the PR stack update and hit conflicts."
echo
echo "#### How to resolve"
echo '```bash'
echo "git fetch origin"
echo "git switch $CHILD_BRANCH"
echo "git merge origin/$PR_BRANCH"
echo "# ..."
echo "# fix conflicts, for instance with \`git mergetool\`"
echo "# ..."
echo "git commit"
echo "git push"
echo '```'
} | log_cmd gh pr comment "$CHILD_BRANCH" -F -
continue
fi
ALL_CHILDREN+=("$CHILD_BRANCH")
update_branch_recursive "$CHILD_BRANCH"
done
# Push all updated branches
if [[ "${#ALL_CHILDREN[@]}" -gt 0 ]]; then
log_cmd git push origin "${ALL_CHILDREN[@]}"
fi
}
main() {
# Check required environment variables
check_env_var "SQUASH_COMMIT"
check_env_var "MERGED_BRANCH"
check_env_var "TARGET_BRANCH"
log_cmd git update-ref SQUASH_COMMIT "$SQUASH_COMMIT"
# Find all PRs directly targeting the merged PR's head
INITIAL_TARGETS=($(log_cmd gh pr list --base "$MERGED_BRANCH" --json headRefName --jq '.[].headRefName'))
for BRANCH in "${INITIAL_TARGETS[@]}"; do
if update_direct_target "$BRANCH" "$TARGET_BRANCH"; then
update_branch_recursive "$BRANCH"
else
echo "⚠️ Skipping descendants of $BRANCH until conflicts are resolved"
fi
done
# Update base branches for direct target PRs
for BRANCH in "${INITIAL_TARGETS[@]}"; do
log_cmd gh pr edit "$BRANCH" --base "$TARGET_BRANCH"
done
# Push all updated branches and delete the merged branch
log_cmd git push origin ":$MERGED_BRANCH" "${INITIAL_TARGETS[@]}" "${ALL_CHILDREN[@]}"
}
# Only run if the script is executed directly (not sourced)
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
case "${ACTION_MODE:-squash-merge}" in
squash-merge)
main
;;
conflict-resolved)
continue_after_resolution
;;
*)
echo "Error: Unknown ACTION_MODE: $ACTION_MODE" >&2
exit 1
;;
esac
fi