diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 52fd93e..425201e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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 diff --git a/tests/test_merged_branch_already_deleted.sh b/tests/test_merged_branch_already_deleted.sh new file mode 100755 index 0000000..4c2d9c8 --- /dev/null +++ b/tests/test_merged_branch_already_deleted.sh @@ -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" diff --git a/update-pr-stack.sh b/update-pr-stack.sh index 6f98cc2..eee4a2d 100755 --- a/update-pr-stack.sh +++ b/update-pr-stack.sh @@ -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() { @@ -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 @@ -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