Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ jobs:
bash tests/test_merge_commit_merge.sh
bash tests/test_rebase_merge.sh
bash tests/test_merged_branch_moved.sh
bash tests/test_merged_branch_already_deleted.sh

e2e-tests:
name: E2E Tests
Expand Down
54 changes: 54 additions & 0 deletions tests/test_merged_branch_already_deleted.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#!/bin/bash
#
# Someone can click "Delete branch" on the merged PR while the action is still
# running. The deletion is the action's last step, so the run must still end
# green.

set -ueo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"

fail() { echo "❌ $1"; exit 1; }

WORK=$(mktemp -d)
ORIGIN=$(mktemp -d)
MOCK_DIR=$(mktemp -d)
git init -q --bare "$ORIGIN"
git init -q -b main "$WORK"
cd "$WORK"
git config user.email t@e.com && git config user.name t
git remote add origin "$ORIGIN"

echo base > f.txt && git add f.txt && git commit -qm initial
git checkout -q -b parent
echo parent > p.txt && git add p.txt && git commit -qm parent
MERGED_TIP=$(git rev-parse parent)
git checkout -q -b child
echo child > c.txt && git add c.txt && git commit -qm child
git checkout -q main
git merge -q --squash parent && git commit -qm "Squash merge parent"
SQUASH_COMMIT=$(git rev-parse main)
git push -q origin main parent child
git fetch -q origin

# The branch disappears from the remote after the action's checkout.
git --git-dir="$ORIGIN" update-ref -d refs/heads/parent

cat > "$MOCK_DIR/gh" <<'MOCK'
#!/bin/bash
if [[ "$1" == "api" ]]; then echo "2 child"; fi
MOCK
chmod +x "$MOCK_DIR/gh"

RC=0
env SQUASH_COMMIT="$SQUASH_COMMIT" MERGED_BRANCH=parent MERGED_TIP="$MERGED_TIP" \
TARGET_BRANCH=main PR_NUMBER=1 GH="$MOCK_DIR/gh" \
bash "$ROOT_DIR/update-pr-stack.sh" >"$MOCK_DIR/out.log" 2>&1 || RC=$?

[[ "$RC" -eq 0 ]] || { cat "$MOCK_DIR/out.log"; fail "the run must succeed when the merged branch is already deleted"; }
grep -q "'parent' was already deleted" "$MOCK_DIR/out.log" || fail "expected a warning about the deleted branch"
git fetch -q origin
git merge-base --is-ancestor "$SQUASH_COMMIT" origin/child || fail "child must be re-parented onto main"

echo "✅ Already-deleted merged branch: warned, not failed"
25 changes: 18 additions & 7 deletions update-pr-stack.sh
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,21 @@ post_conflict_comment() {
run gh pr edit "$PR_NUMBER" --add-label "$CONFLICT_LABEL"
}

# Deleting a PR's base branch closes the PR, so callers must retarget first.
# The lease keeps commits pushed to the branch after the merge: they are in
# neither the squash commit nor the children. The branch can also be gone
# already: a human clicking "Delete branch" right after the merge races this run.
# Everything that matters is done at that point, so only that case stays green.
delete_merged_branch() {
try git push --force-with-lease="$MERGED_BRANCH:MERGED_TIP" origin ":$MERGED_BRANCH" && return 0
local RC=0
try git ls-remote --exit-code --heads origin "refs/heads/$MERGED_BRANCH" >/dev/null || RC=$?
if [[ "$RC" -ne 2 ]]; then
die "could not delete '$MERGED_BRANCH'"
fi
echo "⚠️ '$MERGED_BRANCH' was already deleted"
}

# Args: head branch, base branch, PR number. git commands use the branch; gh
# commands use the number, since a head branch can carry several PRs.
update_direct_target() {
Expand Down Expand Up @@ -389,8 +404,7 @@ main() {
[[ -n "$BRANCH" ]] || continue
run gh pr edit "$NUMBER" --base "$TARGET_BRANCH"
done <<<"$CHILDREN"
# Deleting a PR's base branch closes the PR, so the retargets come first.
run git push --force-with-lease="$MERGED_BRANCH:MERGED_TIP" origin ":$MERGED_BRANCH"
delete_merged_branch
return 0
fi

Expand Down Expand Up @@ -435,12 +449,9 @@ main() {
run gh pr edit "$NUMBER" --base "$TARGET_BRANCH"
done

# Deleting a PR's base branch closes the PR, so this must come after the
# retargets. Keep the branch for reference while conflicted PRs remain.
# The lease keeps commits pushed to the branch after the merge: they are in
# neither the squash commit nor the children.
# Keep the branch for reference while conflicted PRs remain.
if [[ "${#CONFLICTED_TARGETS[@]}" -eq 0 ]]; then
run git push --force-with-lease="$MERGED_BRANCH:MERGED_TIP" origin ":$MERGED_BRANCH"
delete_merged_branch
else
echo "⚠️ Keeping branch '$MERGED_BRANCH' - still referenced by conflicted PRs: ${CONFLICTED_TARGETS[*]}"
fi
Expand Down
Loading