Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
2 changes: 2 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ jobs:
bash tests/test_rebase_workflow.sh
bash tests/test_mixed_workflows.sh
bash tests/test_conflict_resolution_resume.sh
bash tests/test_merge_commit_merge.sh
bash tests/test_rebase_merge_skip.sh

e2e-tests:
name: E2E Tests
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ jobs:

### Notes

* Currently only supports squash merges
* Built for squash merges. A PR merged with a merge commit keeps its history, so the action only retargets its children and deletes the branch. Rebase merges are not supported: the action detects them through GitHub's commit-PR association (the merge method itself is recorded nowhere) and comments on each child PR instead of acting.
* If a merge hits a conflict, you'll need to resolve it manually; pushing the resolution automatically continues the stack update
* Very large stacks might hit GitHub rate limits

Expand Down
13 changes: 13 additions & 0 deletions tests/mock_gh.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,19 @@ if [[ "$1" == "pr" && "$2" == "list" ]]; then
elif [[ "$1" == "pr" && "$2" == "edit" ]]; then
# Just log the edit command
echo "Mock: gh pr edit $3 --base $5"
elif [[ "$1" == "pr" && "$2" == "comment" ]]; then
# Just log the comment command
echo "Mock: gh pr comment $3"
elif [[ "$1" == "api" && "$2" == repos/*/commits/*/pulls ]]; then
# Which PRs introduced this trunk commit (already --jq filtered to bare
# numbers). The merge commit belongs to the merged PR, and so does any sha
# listed in MOCK_REBASE_COPIES (space-separated); anything else was not
# introduced by a PR. SQUASH_COMMIT and PR_NUMBER come from the test's env.
sha="${2#*/commits/}"
sha="${sha%/pulls}"
if [[ "$sha" == "$SQUASH_COMMIT" || " ${MOCK_REBASE_COPIES:-} " == *" $sha "* ]]; then
echo "$PR_NUMBER"
fi
else
echo "Unknown gh command: $@" >&2
exit 1
Expand Down
94 changes: 94 additions & 0 deletions tests/test_merge_commit_merge.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
#!/bin/bash
#
# A PR merged with a merge commit (not squashed) keeps its history, so stacked
# children already contain the parent's commits and their heads must not be
# rewritten. The action only retargets the children and deletes the merged
# branch.

set -ueo pipefail

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

simulate_push() {
log_cmd git update-ref "refs/remotes/origin/$1" "$1"
}

TEST_REPO=$(mktemp -d)
cd "$TEST_REPO"
echo "Created test repo at $TEST_REPO"

log_cmd git init -b main
log_cmd git config user.email "test@example.com"
log_cmd git config user.name "Test User"

echo "line" > file.txt
log_cmd git add file.txt
log_cmd git commit -m "Initial commit"
simulate_push main

log_cmd git checkout -b feature1
echo "f1" >> file.txt
log_cmd git add file.txt
log_cmd git commit -m "Add feature 1"
simulate_push feature1

log_cmd git checkout -b feature2
echo "f2" >> file.txt
log_cmd git add file.txt
log_cmd git commit -m "Add feature 2"
simulate_push feature2

# Merge feature1 into main with a real merge commit
log_cmd git checkout main
log_cmd git merge --no-ff --no-edit feature1
MERGE_COMMIT=$(git rev-parse HEAD)
simulate_push main

FEATURE2_BEFORE=$(git rev-parse feature2)

OUT=$(env \
SQUASH_COMMIT="$MERGE_COMMIT" \
MERGED_BRANCH=feature1 \
PR_NUMBER=1 \
TARGET_BRANCH=main \
GH="$SCRIPT_DIR/mock_gh.sh" \
GIT="$SCRIPT_DIR/mock_git.sh" \
"$SCRIPT_DIR/../update-pr-stack.sh" 2>&1)
echo "$OUT"

if ! grep -q "merged with a merge commit" <<<"$OUT"; then
echo "❌ Expected the merge-commit message"
exit 1
fi
if [[ "$(git rev-parse feature2)" != "$FEATURE2_BEFORE" ]]; then
echo "❌ feature2's head must not be rewritten"
exit 1
fi

# Children must be retargeted before the merged branch is deleted (deleting a
# PR's base branch closes the PR).
EDIT_LINE=$(grep -n "pr edit 2 --base main" <<<"$OUT" | cut -d: -f1 | head -1 || true)
DELETE_LINE=$(grep -n "push origin :feature1" <<<"$OUT" | cut -d: -f1 | head -1 || true)
if [[ -z "$EDIT_LINE" ]]; then
echo "❌ Child PR must be retargeted onto main"
exit 1
fi
if [[ -z "$DELETE_LINE" ]]; then
echo "❌ Merged branch must be deleted"
exit 1
fi
if [[ "$EDIT_LINE" -gt "$DELETE_LINE" ]]; then
echo "❌ Retarget must happen before the branch deletion (edit=$EDIT_LINE delete=$DELETE_LINE)"
exit 1
fi

# The untouched head keeps a clean diff against the new base
ACTUAL_DIFF=$(git diff main...feature2 | grep '^[+-]' | grep -v '^[+-][+-][+-]')
if [[ "$ACTUAL_DIFF" != "+f2" ]]; then
echo "❌ Diff main...feature2 should show only feature2's change, got:"
echo "$ACTUAL_DIFF"
exit 1
fi

echo "✅ Merge-commit merge: children retargeted, heads untouched, branch deleted"
1 change: 1 addition & 0 deletions tests/test_mixed_workflows.sh
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ run_update_pr_stack() {
env \
SQUASH_COMMIT=$SQUASH_COMMIT \
MERGED_BRANCH=feature1 \
PR_NUMBER=1 \
TARGET_BRANCH=main \
GH="$SCRIPT_DIR/mock_gh.sh" \
GIT="$SCRIPT_DIR/mock_git.sh" \
Expand Down
91 changes: 91 additions & 0 deletions tests/test_rebase_merge_skip.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
#!/bin/bash
#
# A PR merged with "Rebase and merge" is not supported: the commits on the
# target are new copies, so neither retargeting children as-is nor the squash
# sequence gives a correct result. The action must detect the rebase (the
# commit below the merge sha was also introduced by this PR, per GitHub's
# commit-PR association), comment on the children, and leave the stack alone.

set -ueo pipefail

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

simulate_push() {
log_cmd git update-ref "refs/remotes/origin/$1" "$1"
}

TEST_REPO=$(mktemp -d)
cd "$TEST_REPO"
echo "Created test repo at $TEST_REPO"

log_cmd git init -b main
log_cmd git config user.email "test@example.com"
log_cmd git config user.name "Test User"

for i in $(seq 1 15); do echo "line $i" >> file.txt; done
log_cmd git add file.txt
log_cmd git commit -m "Initial commit"
simulate_push main

# feature1: two commits, so the rebase leaves a copy below the merge sha
log_cmd git checkout -b feature1
sed -i '2s/.*/Feature 1 change A/' file.txt
log_cmd git add file.txt
log_cmd git commit -m "feature1: commit A"
sed -i '6s/.*/Feature 1 change B/' file.txt
log_cmd git add file.txt
log_cmd git commit -m "feature1: commit B"
simulate_push feature1

log_cmd git checkout -b feature2
sed -i '14s/.*/Feature 2 content/' file.txt
log_cmd git add file.txt
log_cmd git commit -m "feature2: change"
simulate_push feature2

# main advances independently, then feature1 is rebase-merged: GitHub copies
# each PR commit onto the target, which cherry-pick reproduces
log_cmd git checkout main
sed -i '10s/.*/Main hotfix/' file.txt
log_cmd git add file.txt
log_cmd git commit -m "main: hotfix"
log_cmd git cherry-pick feature1~1 feature1
MERGE_COMMIT=$(git rev-parse HEAD)
simulate_push main

FEATURE2_BEFORE=$(git rev-parse feature2)

OUT=$(env \
SQUASH_COMMIT="$MERGE_COMMIT" \
MERGED_BRANCH=feature1 \
PR_NUMBER=1 \
TARGET_BRANCH=main \
MOCK_REBASE_COPIES="$(git rev-parse "$MERGE_COMMIT~")" \
GH="$SCRIPT_DIR/mock_gh.sh" \
GIT="$SCRIPT_DIR/mock_git.sh" \
"$SCRIPT_DIR/../update-pr-stack.sh" 2>&1)
echo "$OUT"

if ! grep -q "rebase merges are not supported" <<<"$OUT"; then
echo "❌ Expected the rebase-merge skip message"
exit 1
fi
if ! grep -q "Mock: gh pr comment 2" <<<"$OUT"; then
echo "❌ The child PR must be told the stack was not updated"
exit 1
fi
if grep -q "pr edit" <<<"$OUT"; then
echo "❌ No PR must be retargeted"
exit 1
fi
if grep -q "push origin :feature1" <<<"$OUT"; then
echo "❌ The merged branch must be kept"
exit 1
fi
if [[ "$(git rev-parse feature2)" != "$FEATURE2_BEFORE" ]]; then
echo "❌ feature2's head must not be rewritten"
exit 1
fi

echo "✅ Rebase merge detected: children warned, stack left alone"
1 change: 1 addition & 0 deletions tests/test_rebase_workflow.sh
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ run_update_pr_stack() {
env \
SQUASH_COMMIT=$SQUASH_COMMIT \
MERGED_BRANCH=feature1 \
PR_NUMBER=1 \
TARGET_BRANCH=main \
GH="$SCRIPT_DIR/mock_gh.sh" \
GIT="$SCRIPT_DIR/mock_git.sh" \
Expand Down
1 change: 1 addition & 0 deletions tests/test_update_pr_stack.sh
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ run_update_pr_stack() {
env \
SQUASH_COMMIT=$SQUASH_COMMIT \
MERGED_BRANCH=feature1 \
PR_NUMBER=1 \
TARGET_BRANCH=main \
GH="$SCRIPT_DIR/mock_gh.sh" \
GIT="$SCRIPT_DIR/mock_git.sh" \
Expand Down
89 changes: 88 additions & 1 deletion update-pr-stack.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
# 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
# PR_NUMBER - The number of the PR that was merged
#
# Required environment variables (conflict-resolved mode):
# PR_BRANCH - The head branch of the PR being resumed
Expand Down Expand Up @@ -79,6 +80,63 @@ has_squash_commit() {
&& git merge-base --is-ancestor SQUASH_COMMIT "$BRANCH"
}

# Args: a commit sha. Echoes the numbers of the pull requests that introduced
# the commit to the repository, one per line.
commit_pull_numbers() {
gh api "repos/{owner}/{repo}/commits/$1/pulls" --jq '.[].number' \
|| { echo "❌ Could not list the pull requests that introduced commit $1" >&2; return 1; }
}

# Args: the merge commit sha, the merged PR's number. The association is
# computed asynchronously, some time after the merge. The merge commit always
# belongs to the merged PR, so once it shows up the index has caught up with
# this merge; until then, an empty answer for any commit of the merge means
# nothing. Exits if the association never appears.
wait_for_pull_association() {
local MERGE_SHA="$1" PR_NUMBER="$2"
local NUMBERS
for _ in $(seq 1 24); do
NUMBERS=$(commit_pull_numbers "$MERGE_SHA") || exit 1
if grep -qx "$PR_NUMBER" <<<"$NUMBERS"; then
return 0
fi
sleep "${ASSOCIATION_POLL_SECONDS:-5}"
done
echo "❌ GitHub never associated $MERGE_SHA with PR #$PR_NUMBER; cannot tell a squash from a rebase" >&2
exit 1
}

# Args: the merged PR's number. The event payload does not say which merge
# method was used (GitHub records it nowhere), but GitHub associates every
# trunk commit with the PR that introduced it. A squash introduces a single
# commit, so the commit below SQUASH_COMMIT belongs to an older PR or to
# none; a rebase introduces a copy of each PR commit, so with two or more
# commits the one below SQUASH_COMMIT still belongs to this PR. A
# single-commit PR merges identically under rebase and squash and correctly
# reads as a squash here.
is_rebase_merge() {
local PR_NUMBER="$1"
local MERGE_SHA PARENT_SHA NUMBERS
MERGE_SHA=$(git rev-parse SQUASH_COMMIT)
PARENT_SHA=$(git rev-parse SQUASH_COMMIT~)

NUMBERS=$(commit_pull_numbers "$PARENT_SHA") || exit 1
if [[ -z "$NUMBERS" ]]; then
# Ambiguous: "no PR introduced this commit" (a squash on top of a
# direct push) and "not indexed yet" (a rebase copy) both come back
# empty. Wait until the index has caught up with this merge, then ask
# again; this time empty really means no PR.
wait_for_pull_association "$MERGE_SHA" "$PR_NUMBER"
NUMBERS=$(commit_pull_numbers "$PARENT_SHA") || exit 1
fi
grep -qx "$PR_NUMBER" <<<"$NUMBERS"
}

# Echoes "<number> <head branch>" for each open PR based on the merged branch.
list_child_prs() {
log_cmd gh pr list --base "$MERGED_BRANCH" --json number,headRefName --jq '.[] | "\(.number) \(.headRefName)"'
}

# 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 @@ -298,17 +356,46 @@ main() {
check_env_var "SQUASH_COMMIT"
check_env_var "MERGED_BRANCH"
check_env_var "TARGET_BRANCH"
check_env_var "PR_NUMBER"

log_cmd git update-ref SQUASH_COMMIT "$SQUASH_COMMIT"

# A merge-commit merge does not rewrite history: each child's head already
# contains the merged branch's commits, and the merge commit carries them
# into TARGET_BRANCH. The heads need no synthetic merge; just retarget the
# children and delete the merged branch.
if git rev-parse --verify --quiet SQUASH_COMMIT^2 >/dev/null; then
echo "✓ '$MERGED_BRANCH' was merged with a merge commit, not squashed; retargeting children without touching their heads"
while read -r NUMBER BRANCH; do
[[ -n "$BRANCH" ]] || continue
log_cmd gh pr edit "$NUMBER" --base "$TARGET_BRANCH"
done < <(list_child_prs)
# Deleting a PR's base branch closes the PR, so the retargets come first.
log_cmd git push origin ":$MERGED_BRANCH"
return 0
fi

# Rebase merges are not supported: the copies on the target are new
# commits, so a child retargeted as-is would show its parent's changes in
# its diff, and the squash sequence can raise spurious conflicts against
# the intermediate copies. Tell the children and leave everything alone.
if is_rebase_merge "$PR_NUMBER"; then
echo "⚠️ '$MERGED_BRANCH' looks rebase-merged; rebase merges are not supported, leaving the stack alone"
while read -r NUMBER BRANCH; do
[[ -n "$BRANCH" ]] || continue
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."
done < <(list_child_prs)
return 0
fi

# Find all PRs directly targeting the merged PR's head
INITIAL_NUMBERS=()
INITIAL_TARGETS=()
while read -r NUMBER BRANCH; do
[[ -n "$BRANCH" ]] || continue
INITIAL_NUMBERS+=("$NUMBER")
INITIAL_TARGETS+=("$BRANCH")
done < <(log_cmd gh pr list --base "$MERGED_BRANCH" --json number,headRefName --jq '.[] | "\(.number) \(.headRefName)"')
done < <(list_child_prs)

# Track successfully updated vs conflicted branches separately
UPDATED_TARGETS=()
Expand Down
Loading