Skip to content

Commit 9b07d07

Browse files
authored
Abort the run when a git/gh command fails unexpectedly (#52)
`set -e` is suppressed inside `if`/`&&`/`||` conditions and everything they call, including the whole body of `update_direct_target`, which both entry points invoke as a condition. So most failures there fell through silently: a failed `git checkout` let git-merge-onto re-parent the previously checked-out branch (corrupting it, in main's loop over several child PRs), and a failed conflict comment still added the label, leaving a resume with no state marker. Every `log_cmd` call site now states its failure policy: `run` (log, execute, die on failure; die's explicit `exit` aborts from any context) or `try` (log, execute, hand the status to the caller) for the commands whose failure is an expected outcome. The explicit read-failure aborts from #50 become `die` too, with regression scenarios for both reads. `set -e` stays as a backstop only. https://claude.ai/code/session_01STkeSJ7cLrmrNn4aTDYkwH
1 parent e886bf0 commit 9b07d07

5 files changed

Lines changed: 162 additions & 39 deletions

File tree

.github/workflows/tests.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ jobs:
1313

1414
- name: Run unit tests
1515
run: |
16+
bash tests/test_command_utils.sh
1617
bash tests/test_update_pr_stack.sh
1718
bash tests/test_rebase_workflow.sh
1819
bash tests/test_mixed_workflows.sh

command_utils.sh

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,26 @@ log_cmd() {
77
printf "\n" >&2
88
"$@"
99
}
10+
11+
die() {
12+
echo "$*" >&2
13+
exit 1
14+
}
15+
16+
# Log and execute a command, aborting the run if it fails. The explicit exit
17+
# in die aborts from any context; `set -e` does not, because it is suppressed
18+
# inside if/&&/|| conditions and everything they call, including the whole
19+
# body of a function invoked as a condition.
20+
#
21+
# Note: inside a command substitution, exit only leaves the subshell, so
22+
# `VAR=$(run ...)` does not abort the script. Use `VAR=$(try ...) || die ...`
23+
# instead.
24+
run() {
25+
log_cmd "$@" || die "command failed (exit $?): $*"
26+
}
27+
28+
# Log and execute a command whose failure is an expected outcome (e.g. a
29+
# merge that may conflict), handing the exit status to the caller.
30+
try() {
31+
log_cmd "$@"
32+
}

tests/test_command_utils.sh

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
#!/bin/bash
2+
#
3+
# Tests for the run/try/die wrappers. The property that matters: run aborts
4+
# the whole script even where `set -e` is suppressed, i.e. inside an `if`
5+
# condition, including the body of a function invoked as the condition. That
6+
# is exactly where update-pr-stack.sh does most of its work, so `set -e`
7+
# alone cannot be relied on there.
8+
9+
set -ueo pipefail
10+
11+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
12+
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
13+
14+
PASS=0
15+
fail() { echo "$1"; exit 1; }
16+
ok() { echo "$1"; PASS=$((PASS+1)); }
17+
18+
# Baseline first, to document the trap that motivates run: with plain set -e,
19+
# a failure inside a function called as an if-condition does NOT stop it.
20+
OUT=$(bash -c '
21+
set -ueo pipefail
22+
f() { false; echo "after-false"; }
23+
if f; then :; fi
24+
' 2>&1)
25+
grep -q "after-false" <<<"$OUT" || fail "baseline: expected set -e to be suppressed in condition context"
26+
ok "baseline: set -e is suppressed inside an if-condition function"
27+
28+
# run must abort both the function and the script from that same context.
29+
STATUS=0
30+
OUT=$(ROOT_DIR="$ROOT_DIR" bash -c '
31+
set -ueo pipefail
32+
source "$ROOT_DIR/command_utils.sh"
33+
f() { run false; echo "after-run"; }
34+
if f; then :; fi
35+
echo "survived"
36+
' 2>&1) || STATUS=$?
37+
[[ "$STATUS" -ne 0 ]] || fail "run: script should exit nonzero"
38+
grep -q "after-run" <<<"$OUT" && fail "run: function continued after the failure"
39+
grep -q "survived" <<<"$OUT" && fail "run: script continued after the failure"
40+
grep -q "command failed" <<<"$OUT" || fail "run: no failure message printed"
41+
ok "run aborts the script from a condition context"
42+
43+
# try hands the status back without aborting.
44+
OUT=$(ROOT_DIR="$ROOT_DIR" bash -c '
45+
set -ueo pipefail
46+
source "$ROOT_DIR/command_utils.sh"
47+
if ! try false; then echo "handled"; fi
48+
try true || exit 9
49+
echo "done"
50+
' 2>&1)
51+
grep -q "handled" <<<"$OUT" || fail "try: failure status not handed to caller"
52+
grep -q "done" <<<"$OUT" || fail "try: success path broken"
53+
ok "try returns the status to the caller"
54+
55+
echo
56+
echo "All command_utils tests passed 🎉 ($PASS)"

tests/test_conflict_resolution_resume.sh

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ ok() { echo "✅ $1"; PASS=$((PASS+1)); }
2222
# $CALLS and is driven by env vars set per scenario:
2323
# MOCK_LABELS newline-separated labels returned by `pr view --json labels`
2424
# MOCK_COMMENTS_FILE file served as the body of our own PR comments
25+
# MOCK_LABELS_FAIL set to 1 to make `pr view --json labels` fail
2526
# The PR's base branch is not mocked: the script must take it from PR_BASE
2627
# (event payload), so a baseRefName query is an unhandled call and fails.
2728
make_mock_gh() {
@@ -32,7 +33,9 @@ set -ueo pipefail
3233
echo "gh $*" >> "$CALLS"
3334
if [[ "$1 $2" == "pr view" ]]; then
3435
case "$*" in
35-
*--json\ labels*) printf '%s\n' "${MOCK_LABELS:-}";;
36+
*--json\ labels*)
37+
[[ "${MOCK_LABELS_FAIL:-}" == 1 ]] && { echo "mock gh: labels API down" >&2; exit 1; }
38+
printf '%s\n' "${MOCK_LABELS:-}";;
3639
*) echo "unhandled pr view: $*" >&2; exit 1;;
3740
esac
3841
elif [[ "$1 $2" == "api graphql" ]]; then
@@ -95,7 +98,7 @@ run_resume() {
9598
env ACTION_MODE=conflict-resolved PR_BRANCH=child PR_NUMBER=5 PR_BASE="$PR_BASE" \
9699
GITHUB_REPOSITORY=tester/repo \
97100
GH="$MOCK_DIR/mock_gh.sh" GIT="$MOCK_DIR/mock_git.sh" \
98-
MOCK_LABELS="$MOCK_LABELS" \
101+
MOCK_LABELS="$MOCK_LABELS" MOCK_LABELS_FAIL="${MOCK_LABELS_FAIL:-}" \
99102
MOCK_COMMENTS_FILE="$MOCK_COMMENTS_FILE" CALLS="$CALLS" \
100103
bash "$ROOT_DIR/update-pr-stack.sh" >"$WORK/out.log" 2>&1 || echo "EXIT=$?" >>"$WORK/out.log"
101104
}
@@ -218,5 +221,40 @@ grep -q -- "--base" "$CALLS" && fail "F: base must NOT be edited"
218221
[[ "$(git -C "$ORIGIN" rev-parse child)" == "$CHILD_BEFORE" ]] || fail "F: child was pushed"
219222
ok "F: missing base branch detected, no crash, label removed"
220223

224+
# ---------------------------------------------------------------------------
225+
echo "### Scenario G: reading the PR comments fails -> fail the run, keep the label"
226+
setup_repo
227+
# An API failure must not pass for "no state marker": that path removes the
228+
# conflict label and permanently cancels the auto-resume. Fail loudly instead,
229+
# so the label stays on and the next push retries.
230+
MOCK_LABELS="autorestack-needs-conflict-resolution"
231+
PR_BASE="parent"
232+
MOCK_COMMENTS_FILE="$WORK/does-not-exist" # makes the mock gh fail on the comments query
233+
run_resume
234+
235+
grep -q "EXIT=" "$WORK/out.log" || fail "G: run should have failed"
236+
grep -q "remove-label" "$CALLS" && fail "G: label must NOT be removed on an API failure"
237+
grep -q "gh pr comment" "$CALLS" && fail "G: no comment must be posted on an API failure"
238+
[[ "$(git -C "$ORIGIN" rev-parse child)" == "$CHILD_BEFORE" ]] || fail "G: child was pushed"
239+
ok "G: comments API failure fails the run and keeps the resume armed"
240+
241+
# ---------------------------------------------------------------------------
242+
echo "### Scenario H: reading the labels fails -> fail the run, do nothing"
243+
setup_repo
244+
# An API failure must not pass for "no conflict label": that ends the run
245+
# green without resuming anything.
246+
MOCK_LABELS=""
247+
MOCK_LABELS_FAIL=1
248+
PR_BASE="parent"
249+
MOCK_COMMENTS_FILE="$WORK/comments.txt"
250+
{ echo "### conflict"; echo; marker parent main "$SQUASH"; } > "$MOCK_COMMENTS_FILE"
251+
run_resume
252+
MOCK_LABELS_FAIL=""
253+
254+
grep -q "EXIT=" "$WORK/out.log" || fail "H: run should have failed, not ended green"
255+
grep -q "remove-label" "$CALLS" && fail "H: label must NOT be touched on an API failure"
256+
[[ "$(git -C "$ORIGIN" rev-parse child)" == "$CHILD_BEFORE" ]] || fail "H: child was pushed"
257+
ok "H: labels API failure fails the run instead of skipping the resume"
258+
221259
echo
222260
echo "All conflict-resume tests passed 🎉 ($PASS scenarios)"

update-pr-stack.sh

Lines changed: 42 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@
2121
# possible, so the logged commands are self-contained and reproducible
2222
# - We strive to keep commands as simple as possible
2323

24-
set -ueo pipefail # Exit on error, undefined var, or pipeline failure
24+
# set -u and pipefail do the real work here; set -e is only a backstop. It is
25+
# suppressed inside if/&&/|| conditions and everything they call, including
26+
# the whole body of update_direct_target (always invoked as a condition), so
27+
# failure handling is explicit instead: run/try/die from command_utils.sh.
28+
set -ueo pipefail
2529

2630
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
2731
source "$SCRIPT_DIR/command_utils.sh"
@@ -45,7 +49,7 @@ format_state_marker() {
4549
read_state_marker() {
4650
local PR_NUMBER="$1"
4751
local BODIES
48-
if ! BODIES=$(gh api graphql --paginate \
52+
BODIES=$(gh api graphql --paginate \
4953
-F owner="${GITHUB_REPOSITORY%/*}" -F repo="${GITHUB_REPOSITORY#*/}" \
5054
-F number="$PR_NUMBER" -f query='
5155
query($owner: String!, $repo: String!, $number: Int!, $endCursor: String) {
@@ -57,10 +61,8 @@ read_state_marker() {
5761
}
5862
}
5963
}
60-
}' --jq '.data.repository.pullRequest.comments.nodes[] | select(.viewerDidAuthor) | .body'); then
61-
echo "Error: could not read comments of PR #$PR_NUMBER" >&2
62-
exit 1
63-
fi
64+
}' --jq '.data.repository.pullRequest.comments.nodes[] | select(.viewerDidAuthor) | .body') \
65+
|| die "could not read comments of PR #$PR_NUMBER"
6466
{ grep -F "$STATE_MARKER_PREFIX" <<<"$BODIES" || true; } | tail -n1
6567
}
6668

@@ -147,8 +149,11 @@ is_rebase_merge() {
147149
}
148150

149151
# Echoes "<number> <head branch>" for each open PR based on the merged branch.
152+
# try, not run: callers consume this through a process substitution, which
153+
# swallows the exit status either way; a die in here would only leave that
154+
# subshell. Making the callers notice the failure is a separate concern.
150155
list_child_prs() {
151-
log_cmd gh api "repos/{owner}/{repo}/pulls?base=$MERGED_BRANCH&state=open&per_page=100" \
156+
try gh api "repos/{owner}/{repo}/pulls?base=$MERGED_BRANCH&state=open&per_page=100" \
152157
--paginate --jq '.[] | "\(.number) \(.head.ref)"'
153158
}
154159

@@ -158,7 +163,7 @@ list_child_prs() {
158163
# abort when a merge is actually in progress.
159164
abort_merge_if_in_progress() {
160165
if git rev-parse --verify --quiet MERGE_HEAD >/dev/null; then
161-
log_cmd git merge --abort
166+
run git merge --abort
162167
fi
163168
}
164169

@@ -169,7 +174,7 @@ update_direct_target() {
169174
local BASE_BRANCH="$2"
170175
local PR_NUMBER="$3"
171176

172-
log_cmd git checkout "$BRANCH"
177+
run git checkout "$BRANCH"
173178

174179
# The target branch is never checked out, so it has no local ref, only the
175180
# remote-tracking one; a bare $TARGET_BRANCH would not resolve.
@@ -193,13 +198,12 @@ See $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
193198
# while keeping the child's own changes -- the merge equivalent of
194199
# `git rebase --onto`, done by the vendored git-merge-onto.
195200
local RC=0
196-
log_cmd python3 "$SCRIPT_DIR/git-merge-onto" -m "$MERGE_MSG" SQUASH_COMMIT "origin/$MERGED_BRANCH" || RC=$?
201+
try python3 "$SCRIPT_DIR/git-merge-onto" -m "$MERGE_MSG" SQUASH_COMMIT "origin/$MERGED_BRANCH" || RC=$?
197202
if [[ "$RC" -eq 0 ]]; then
198203
return 0
199204
fi
200205
if [[ "$RC" -ne 1 ]]; then
201-
echo "❌ git-merge-onto failed (exit $RC) while re-parenting $BRANCH" >&2
202-
exit 1
206+
die "git-merge-onto failed (exit $RC) while re-parenting $BRANCH"
203207
fi
204208

205209
# Conflict (exit 1): git-merge-onto committed nothing and left the merge in
@@ -208,6 +212,8 @@ See $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
208212
# firing. Clean the runner's tree, ask the user to resolve, and record the state
209213
# so the next push can resume. The label comes last: it is what re-triggers us.
210214
abort_merge_if_in_progress
215+
local SQUASH_HASH_FOR_MARKER
216+
SQUASH_HASH_FOR_MARKER=$(git rev-parse SQUASH_COMMIT) || die "cannot resolve SQUASH_COMMIT"
211217
{
212218
echo "### ⚠️ Automatic update blocked by a merge conflict"
213219
echo
@@ -227,21 +233,20 @@ See $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
227233
echo
228234
echo "Once you push, this action will resume and finish updating this pull request."
229235
echo
230-
format_state_marker "$MERGED_BRANCH" "$TARGET_BRANCH" "$(git rev-parse SQUASH_COMMIT)"
231-
} | log_cmd gh pr comment "$PR_NUMBER" -F -
236+
format_state_marker "$MERGED_BRANCH" "$TARGET_BRANCH" "$SQUASH_HASH_FOR_MARKER"
237+
} | try gh pr comment "$PR_NUMBER" -F - \
238+
|| die "could not post the conflict-resolution comment on PR #$PR_NUMBER"
232239
gh label create "$CONFLICT_LABEL" --description "PR needs manual conflict resolution" --color "d73a4a" 2>/dev/null || true
233-
log_cmd gh pr edit "$PR_NUMBER" --add-label "$CONFLICT_LABEL"
240+
run gh pr edit "$PR_NUMBER" --add-label "$CONFLICT_LABEL"
234241
return 1
235242
}
236243

237244
# Check if a PR has the conflict resolution label.
238245
pr_has_conflict_label() {
239246
local PR_NUMBER="$1"
240247
local LABELS
241-
if ! LABELS=$(gh pr view "$PR_NUMBER" --json labels --jq '.labels[].name'); then
242-
echo "Error: could not read labels of PR #$PR_NUMBER" >&2
243-
exit 1
244-
fi
248+
LABELS=$(gh pr view "$PR_NUMBER" --json labels --jq '.labels[].name') \
249+
|| die "could not read labels of PR #$PR_NUMBER"
245250
echo "$LABELS" | grep -q "^${CONFLICT_LABEL}$"
246251
}
247252

@@ -271,8 +276,9 @@ has_sibling_conflicts() {
271276
abandon_resume() {
272277
local PR_NUMBER="$1"
273278
local MESSAGE="$2"
274-
echo "$MESSAGE" | log_cmd gh pr comment "$PR_NUMBER" -F -
275-
log_cmd gh pr edit "$PR_NUMBER" --remove-label "$CONFLICT_LABEL"
279+
echo "$MESSAGE" | try gh pr comment "$PR_NUMBER" -F - \
280+
|| die "could not comment on PR #$PR_NUMBER"
281+
run gh pr edit "$PR_NUMBER" --remove-label "$CONFLICT_LABEL"
276282
}
277283

278284
# Continue processing after user manually resolved conflicts
@@ -297,7 +303,7 @@ continue_after_resolution() {
297303
# Recover them from the marker the squash-merge run left in the conflict
298304
# comment.
299305
local MARKER
300-
MARKER=$(read_state_marker "$PR_NUMBER")
306+
MARKER=$(read_state_marker "$PR_NUMBER") || die "could not read the state marker of PR #$PR_NUMBER"
301307
if [[ -z "$MARKER" ]]; then
302308
echo "⚠️ No autorestack state marker on $PR_BRANCH; cannot resume safely. Removing the label."
303309
abandon_resume "$PR_NUMBER" "ℹ️ autorestack could not find its state marker on this PR, so it will not update the stack automatically. If this PR still needs its base updated, update its base manually."
@@ -309,8 +315,7 @@ continue_after_resolution() {
309315
echo "Recorded state: base=$OLD_BASE target=$NEW_TARGET squash=$SQUASH_HASH"
310316

311317
if [[ -z "$OLD_BASE" || -z "$NEW_TARGET" || -z "$SQUASH_HASH" ]]; then
312-
echo "Error: malformed state marker on $PR_BRANCH: $MARKER" >&2
313-
exit 1
318+
die "malformed state marker on $PR_BRANCH: $MARKER"
314319
fi
315320

316321
# The PR was left based on the merged parent branch. If the payload shows a
@@ -347,8 +352,8 @@ continue_after_resolution() {
347352
# the merge. Its forced base is the old parent, where the lines the user just
348353
# resolved still differ from the trunk, so a re-merge would re-raise the very
349354
# conflict they fixed. A plain ancestry check is all the resume needs.
350-
log_cmd git update-ref SQUASH_COMMIT "$SQUASH_HASH"
351-
log_cmd git checkout "$PR_BRANCH"
355+
run git update-ref SQUASH_COMMIT "$SQUASH_HASH"
356+
run git checkout "$PR_BRANCH"
352357
if ! git merge-base --is-ancestor SQUASH_COMMIT "$PR_BRANCH"; then
353358
# Fail loudly rather than silently: the user pushed without finishing the
354359
# re-parent, so a red run is the signal they need to look again.
@@ -362,16 +367,16 @@ continue_after_resolution() {
362367
# Push the cleaned-up head before retargeting so the head already contains
363368
# NEW_TARGET when the base flips to it, keeping the PR mergeable (GitHub
364369
# suppresses CI on a PR that conflicts with its base).
365-
log_cmd git push origin "$PR_BRANCH"
366-
log_cmd gh pr edit "$PR_NUMBER" --base "$NEW_TARGET"
367-
log_cmd gh pr edit "$PR_NUMBER" --remove-label "$CONFLICT_LABEL"
370+
run git push origin "$PR_BRANCH"
371+
run gh pr edit "$PR_NUMBER" --base "$NEW_TARGET"
372+
run gh pr edit "$PR_NUMBER" --remove-label "$CONFLICT_LABEL"
368373

369374
# Check if old base branch should be deleted
370375
if has_sibling_conflicts "$OLD_BASE" "$PR_BRANCH"; then
371376
echo "⚠️ Keeping branch '$OLD_BASE' - still referenced by other conflicted PRs"
372377
else
373378
echo "Deleting old base branch '$OLD_BASE' (no other PRs depend on it)"
374-
log_cmd git push origin ":$OLD_BASE" || echo "⚠️ Could not delete '$OLD_BASE' (may already be deleted)"
379+
try git push origin ":$OLD_BASE" || echo "⚠️ Could not delete '$OLD_BASE' (may already be deleted)"
375380
fi
376381
}
377382

@@ -382,7 +387,7 @@ main() {
382387
check_env_var "TARGET_BRANCH"
383388
check_env_var "PR_NUMBER"
384389

385-
log_cmd git update-ref SQUASH_COMMIT "$SQUASH_COMMIT"
390+
run git update-ref SQUASH_COMMIT "$SQUASH_COMMIT"
386391

387392
# A merge-commit merge does not rewrite history: each child's head already
388393
# contains the merged branch's commits, and the merge commit carries them
@@ -392,10 +397,10 @@ main() {
392397
echo "✓ '$MERGED_BRANCH' was merged with a merge commit, not squashed; retargeting children without touching their heads"
393398
while read -r NUMBER BRANCH; do
394399
[[ -n "$BRANCH" ]] || continue
395-
log_cmd gh pr edit "$NUMBER" --base "$TARGET_BRANCH"
400+
run gh pr edit "$NUMBER" --base "$TARGET_BRANCH"
396401
done < <(list_child_prs)
397402
# Deleting a PR's base branch closes the PR, so the retargets come first.
398-
log_cmd git push origin ":$MERGED_BRANCH"
403+
run git push origin ":$MERGED_BRANCH"
399404
return 0
400405
fi
401406

@@ -407,7 +412,7 @@ main() {
407412
echo "⚠️ '$MERGED_BRANCH' looks rebase-merged; rebase merges are not supported, leaving the stack alone"
408413
while read -r NUMBER BRANCH; do
409414
[[ -n "$BRANCH" ]] || continue
410-
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."
415+
run 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."
411416
done < <(list_child_prs)
412417
return 0
413418
fi
@@ -439,17 +444,17 @@ main() {
439444
# intact on its old base, and the head already contains TARGET_BRANCH when
440445
# the base flips to it.
441446
if [[ "${#UPDATED_TARGETS[@]}" -gt 0 ]]; then
442-
log_cmd git push origin "${UPDATED_TARGETS[@]}"
447+
run git push origin "${UPDATED_TARGETS[@]}"
443448
fi
444449

445450
for NUMBER in "${UPDATED_NUMBERS[@]}"; do
446-
log_cmd gh pr edit "$NUMBER" --base "$TARGET_BRANCH"
451+
run gh pr edit "$NUMBER" --base "$TARGET_BRANCH"
447452
done
448453

449454
# Deleting a PR's base branch closes the PR, so this must come after the
450455
# retargets. Keep the branch for reference while conflicted PRs remain.
451456
if [[ "${#CONFLICTED_TARGETS[@]}" -eq 0 ]]; then
452-
log_cmd git push origin ":$MERGED_BRANCH"
457+
run git push origin ":$MERGED_BRANCH"
453458
else
454459
echo "⚠️ Keeping branch '$MERGED_BRANCH' - still referenced by conflicted PRs: ${CONFLICTED_TARGETS[*]}"
455460
fi

0 commit comments

Comments
 (0)