-
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
·302 lines (263 loc) · 11.8 KB
/
Copy pathupdate-pr-stack.sh
File metadata and controls
executable file
·302 lines (263 loc) · 11.8 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
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
#!/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 a branch already has the squash commit merged (squash-merge mode only)
# Requires SQUASH_COMMIT ref to be set via git update-ref
#
# Note: This uses local branch refs. The caller must ensure both branches
# exist locally before calling (e.g., via git checkout).
has_squash_commit() {
local BRANCH="$1"
local BASE="$2"
git merge-base --is-ancestor "$BASE" "$BRANCH" \
&& git merge-base --is-ancestor SQUASH_COMMIT "$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"
# Checkout first to ensure the local branch exists (created from origin if
# needed). This allows has_squash_commit to compare local refs, which matters
# for testing where the script may run multiple times in the same repo.
log_cmd git checkout "$BRANCH"
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)"
CONFLICTS=()
local BASE_MERGE_CLEAN=true
log_cmd git update-ref BEFORE_MERGE HEAD
if ! log_cmd git merge --no-edit "origin/$MERGED_BRANCH"; then
CONFLICTS+=("origin/$MERGED_BRANCH")
BASE_MERGE_CLEAN=false
log_cmd git merge --abort
fi
# Only try merging the pre-squash target state if it's not already
# included in the merged branch — otherwise the first merge covers it.
if ! git merge-base --is-ancestor SQUASH_COMMIT~ "origin/$MERGED_BRANCH"; then
if ! log_cmd git merge --no-edit SQUASH_COMMIT~; then
CONFLICTS+=( "$(git rev-parse SQUASH_COMMIT~)" )
log_cmd git merge --abort
fi
fi
if [[ "${#CONFLICTS[@]}" -gt 0 ]]; then
# When the base-branch merge was clean, HEAD now holds it (the
# conflicting pre-squash merge was aborted back to it). Push it before
# asking for help: the user resolves on top of it, and the head stays a
# descendant of its base so the PR stays mergeable and the synchronize
# event that resumes this action still fires. GitHub does not run
# pull_request workflows on a PR conflicting with its base, which would
# otherwise strand the branch for good. If the base merge itself
# conflicted we have nothing safe to pre-push, so we just ask for help.
# Note: ordering is important here: if we label before pushing, we
# re-trigger ourselves immediately.
if [[ "$BASE_MERGE_CLEAN" == true ]]; then
log_cmd git push origin "$BRANCH"
fi
{
echo "### ⚠️ Automatic update blocked by merge conflicts"
echo
echo -n "I tried to merge "
format_branch_list_for_text "${CONFLICTS[@]}"
echo " into this branch while updating the pull request stack and hit conflicts."
echo
echo "#### How to resolve"
echo '```bash'
echo "git fetch origin"
echo "git switch $BRANCH"
echo -n "git pull --ff-only origin $BRANCH"
if [[ "$BASE_MERGE_CLEAN" == true ]]; then
echo " # pick up the base merge this action already pushed"
else
echo
fi
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 '```'
echo
echo "Once you push, this action will resume and finish updating this pull request."
} | 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"
if [[ "${GITHUB_ACTIONS:-}" == "true" ]]; then
COMMIT_MSG="$COMMIT_MSG
See $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
fi
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
}
# 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}$"
}
# Check if any other PRs with conflict label still depend on a given base branch
# Returns 0 (true) if siblings exist, 1 (false) if no siblings
has_sibling_conflicts() {
local BASE_BRANCH="$1"
local EXCLUDE_BRANCH="$2"
# Find all open PRs with the conflict label that are based on BASE_BRANCH
local CONFLICTED_SIBLINGS
CONFLICTED_SIBLINGS=$(gh pr list --base "$BASE_BRANCH" --label "$CONFLICT_LABEL" --json headRefName --jq '.[].headRefName' 2>/dev/null || echo "")
for SIBLING in $CONFLICTED_SIBLINGS; do
if [[ "$SIBLING" != "$EXCLUDE_BRANCH" ]]; then
return 0 # Found a sibling still in conflict
fi
done
return 1 # No siblings with same base
}
# 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..."
# Get the current base branch (the old base that was kept during conflict)
local OLD_BASE
OLD_BASE=$(gh pr view "$PR_BRANCH" --json baseRefName --jq '.baseRefName')
echo "Current base branch: $OLD_BASE"
# The synchronize payload is the child PR, so SQUASH_COMMIT / MERGED_BRANCH /
# TARGET_BRANCH from the original squash-merge run are not in the environment.
# Reconstruct them from the merged parent PR: OLD_BASE is the parent branch,
# and the merged PR whose head is OLD_BASE gives the new target (its base) and
# the squash commit (its merge commit).
local NEW_TARGET SQUASH_HASH
read -r NEW_TARGET SQUASH_HASH < <(gh pr list --head "$OLD_BASE" --state merged \
--json baseRefName,mergeCommit --jq '.[0] | "\(.baseRefName // "") \(.mergeCommit.oid // "")"')
if [[ -z "$NEW_TARGET" || -z "$SQUASH_HASH" ]]; then
echo "⚠️ Could not find where '$OLD_BASE' was merged to; skipping base branch and deletion updates"
# Don't update base or delete old branch - leave things as they are
else
echo "Old base '$OLD_BASE' was merged to '$NEW_TARGET' as $SQUASH_HASH"
# The squash-merge run pushed the base merge and asked the user to resolve
# the pre-squash merge, but it never recorded the squash itself. Finish
# that now: re-run the same merge sequence as the squash-merge path. With
# the user's resolution in place the base merge and pre-squash merge are
# no-ops; only the "-s ours" squash record gets applied, keeping the diff
# against the new base clean. has_squash_commit makes this idempotent.
log_cmd git update-ref SQUASH_COMMIT "$SQUASH_HASH"
MERGED_BRANCH="$OLD_BASE"
TARGET_BRANCH="$NEW_TARGET"
if ! update_direct_target "$PR_BRANCH" "$NEW_TARGET"; then
echo "⚠️ '$PR_BRANCH' still conflicts; re-posted the conflict comment, will retry on next push"
return 1
fi
log_cmd git push origin "$PR_BRANCH"
# Remove the conflict label
log_cmd gh pr edit "$PR_BRANCH" --remove-label "$CONFLICT_LABEL"
# Update the PR's base branch to the new target
log_cmd gh pr edit "$PR_BRANCH" --base "$NEW_TARGET"
# Check if old base branch should be deleted
if has_sibling_conflicts "$OLD_BASE" "$PR_BRANCH"; then
echo "⚠️ Keeping branch '$OLD_BASE' - still referenced by other conflicted PRs"
else
echo "Deleting old base branch '$OLD_BASE' (no other PRs depend on it)"
log_cmd git push origin ":$OLD_BASE" || echo "⚠️ Could not delete '$OLD_BASE' (may already be deleted)"
fi
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'))
# Track successfully updated vs conflicted branches separately
UPDATED_TARGETS=()
CONFLICTED_TARGETS=()
for BRANCH in "${INITIAL_TARGETS[@]}"; do
if update_direct_target "$BRANCH" "$TARGET_BRANCH"; then
UPDATED_TARGETS+=("$BRANCH")
else
CONFLICTED_TARGETS+=("$BRANCH")
fi
done
# Only update base branches for successfully updated PRs
for BRANCH in "${UPDATED_TARGETS[@]}"; do
log_cmd gh pr edit "$BRANCH" --base "$TARGET_BRANCH"
done
# Push updated branches; only delete merged branch if no conflicts
if [[ "${#CONFLICTED_TARGETS[@]}" -eq 0 ]]; then
# No conflicts - safe to delete merged branch
log_cmd git push origin ":$MERGED_BRANCH" "${UPDATED_TARGETS[@]}"
else
# Some conflicts - keep merged branch for reference during manual resolution
if [[ "${#UPDATED_TARGETS[@]}" -gt 0 ]]; then
log_cmd git push origin "${UPDATED_TARGETS[@]}"
fi
echo "⚠️ Keeping branch '$MERGED_BRANCH' - still referenced by conflicted PRs: ${CONFLICTED_TARGETS[*]}"
fi
}
# 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