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
5 changes: 4 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ jobs:
uses: actions/checkout@v4

- name: Run unit tests
run: bash tests/test_update_pr_stack.sh
run: |
bash tests/test_update_pr_stack.sh
bash tests/test_rebase_workflow.sh
bash tests/test_mixed_workflows.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 @@ -10,7 +10,7 @@ If you use squash & merge instead, your main branch history stays nice and clean

### The solution

This action tries to fix that in a transparent way. Install it, and hopefully the workflow of stacking + merge during dev + squash merge when landing works.
This action tries to fix that in a transparent way. Install it, and hopefully the workflow of stacking + merge during dev + squash merge when landing works. It also works if you rebase during development instead of merging.

---

Expand Down
222 changes: 222 additions & 0 deletions tests/test_mixed_workflows.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
#!/bin/bash
#
# Test mixed merge/rebase workflows where feature2 is NOT fully up to date
# with feature1 at the time of squash-merge.
#
# Scenarios:
# A) feature2 rebased onto an intermediate state of feature1, then feature1
# got more commits that feature2 never picked up.
# B) feature2 merged feature1 once, then rebased onto a later feature1 state,
# but feature1 got yet more commits after that.

set -e

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"
}

# Extract just the +/- lines from a diff (ignoring headers and context)
diff_changes() {
grep '^[+-]' | grep -v '^[+-][+-][+-]'
}

run_scenario() {
local SCENARIO_NAME="$1"
echo ""
echo "============================================"
echo " Scenario $SCENARIO_NAME"
echo "============================================"
echo ""

TEST_REPO=$(mktemp -d)
cd "$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"

# 10-line file to keep changes well-separated
for i in $(seq 1 10); do echo "line $i" >> file.txt; done
log_cmd git add file.txt
log_cmd git commit -m "Initial commit"
simulate_push main
}

assert_diff_is() {
local LABEL="$1"
local EXPECTED="$2"
local ACTUAL
ACTUAL=$(log_cmd git diff main...feature2 | diff_changes)
if [[ "$ACTUAL" == "$EXPECTED" ]]; then
echo "✅ $LABEL"
else
echo "❌ $LABEL"
echo "Expected changed lines:"
echo "$EXPECTED"
echo "Actual changed lines:"
echo "$ACTUAL"
exit 1
fi
}

assert_idempotent() {
local BEFORE AFTER
BEFORE=$(git rev-parse feature2)
run_update_pr_stack
cd "$TEST_REPO"
AFTER=$(git rev-parse feature2)
if [[ "$BEFORE" == "$AFTER" ]]; then
echo "✅ Idempotent"
else
echo "❌ Idempotence failed"
exit 1
fi
}

squash_merge_feature1() {
log_cmd git checkout main
log_cmd git merge --squash feature1
log_cmd git commit -m "Squash merge feature1"
SQUASH_COMMIT=$(git rev-parse HEAD)
simulate_push main
echo "Squash commit: $SQUASH_COMMIT"
}

run_update_pr_stack() {
log_cmd \
env \
SQUASH_COMMIT=$SQUASH_COMMIT \
MERGED_BRANCH=feature1 \
TARGET_BRANCH=main \
GH="$SCRIPT_DIR/mock_gh.sh" \
GIT="$SCRIPT_DIR/mock_git.sh" \
$SCRIPT_DIR/../update-pr-stack.sh
}

########################################################################
# Scenario A: rebase onto intermediate feature1, then feature1 advances
########################################################################
run_scenario "A: rebased onto intermediate feature1, then feature1 advances"

# feature1: two commits (lines 2 and 3)
log_cmd git checkout -b feature1
sed -i '2s/.*/f1 commit 1/' file.txt
log_cmd git add file.txt
log_cmd git commit -m "f1: commit 1"
simulate_push feature1

# feature2: branched from feature1 after commit 1, changes line 9
log_cmd git checkout -b feature2
sed -i '9s/.*/f2 content/' file.txt
log_cmd git add file.txt
log_cmd git commit -m "f2: change line 9"
simulate_push feature2

# feature1 gets commit 2
log_cmd git checkout feature1
sed -i '3s/.*/f1 commit 2/' file.txt
log_cmd git add file.txt
log_cmd git commit -m "f1: commit 2"
simulate_push feature1

# Developer rebases feature2 onto feature1 (picks up commit 2)
log_cmd git checkout feature2
log_cmd git rebase feature1
simulate_push feature2

# feature1 gets commit 3 — feature2 does NOT pick this up
log_cmd git checkout feature1
sed -i '4s/.*/f1 commit 3/' file.txt
log_cmd git add file.txt
log_cmd git commit -m "f1: commit 3"
simulate_push feature1

echo ""
echo "=== Graph before squash-merge ==="
log_cmd git log --graph --oneline --all
echo ""

squash_merge_feature1
run_update_pr_stack
cd "$TEST_REPO"

# The diff should show only feature2's unique change
assert_diff_is "Scenario A: diff shows only f2's change" "$(cat <<'EOF'
-line 9
+f2 content
EOF
)"
assert_idempotent

########################################################################
# Scenario B: merge then rebase, then feature1 advances again
########################################################################
run_scenario "B: merge then rebase, then feature1 advances again"

# feature1: commit 1 (line 2)
log_cmd git checkout -b feature1
sed -i '2s/.*/f1 commit 1/' file.txt
log_cmd git add file.txt
log_cmd git commit -m "f1: commit 1"
simulate_push feature1

# feature2 from feature1, changes line 9
log_cmd git checkout -b feature2
sed -i '9s/.*/f2 content/' file.txt
log_cmd git add file.txt
log_cmd git commit -m "f2: change line 9"
simulate_push feature2

# feature1 gets commit 2
log_cmd git checkout feature1
sed -i '3s/.*/f1 commit 2/' file.txt
log_cmd git add file.txt
log_cmd git commit -m "f1: commit 2"
simulate_push feature1

# Developer merges feature1 into feature2
log_cmd git checkout feature2
log_cmd git merge --no-edit feature1
simulate_push feature2

# feature1 gets commit 3
log_cmd git checkout feature1
sed -i '4s/.*/f1 commit 3/' file.txt
log_cmd git add file.txt
log_cmd git commit -m "f1: commit 3"
simulate_push feature1

# Developer rebases feature2 onto latest feature1 (rewrite away the merge)
log_cmd git checkout feature2
log_cmd git rebase feature1
simulate_push feature2

# feature1 gets commit 4 — feature2 does NOT pick this up
log_cmd git checkout feature1
sed -i '5s/.*/f1 commit 4/' file.txt
log_cmd git add file.txt
log_cmd git commit -m "f1: commit 4"
simulate_push feature1

echo ""
echo "=== Graph before squash-merge ==="
log_cmd git log --graph --oneline --all
echo ""

squash_merge_feature1
run_update_pr_stack
cd "$TEST_REPO"

assert_diff_is "Scenario B: diff shows only f2's change" "$(cat <<'EOF'
-line 9
+f2 content
EOF
)"
assert_idempotent

########################################################################
echo ""
echo "All mixed-workflow tests passed! 🎉"
142 changes: 142 additions & 0 deletions tests/test_rebase_workflow.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
#!/bin/bash
#
# Test that the update-pr-stack mechanism works when PRs are rebased during
# development (as opposed to using merge commits to sync with the parent).
#
# Scenario:
# 1. main ← feature1 ← feature2 (linear stack)
# 2. feature1 gets an additional commit AFTER feature2 was branched
# 3. Developer rebases feature2 onto the updated feature1
# 4. feature1 is squash-merged into main
# 5. Action runs → feature2 should get a correct PR diff against main

set -e

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

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

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"

# Initial commit on main — use enough lines to avoid adjacent-change conflicts
for i in $(seq 1 10); do echo "line $i" >> file.txt; done
log_cmd git add file.txt
log_cmd git commit -m "Initial commit"
simulate_push main

# feature1: modify line 2 (top region)
log_cmd git checkout -b feature1
sed -i '2s/.*/Feature 1 first change/' file.txt
log_cmd git add file.txt
log_cmd git commit -m "feature1: first commit"
simulate_push feature1

# feature2: branched from feature1, modify line 9 (bottom region, far away)
log_cmd git checkout -b feature2
sed -i '9s/.*/Feature 2 content/' file.txt
log_cmd git add file.txt
log_cmd git commit -m "feature2: change line 9"
simulate_push feature2

# Now feature1 gets an additional commit modifying line 3 (still top region)
log_cmd git checkout feature1
sed -i '3s/.*/Feature 1 second change/' file.txt
log_cmd git add file.txt
log_cmd git commit -m "feature1: second commit"
simulate_push feature1

FEATURE1_TIP=$(git rev-parse HEAD)

# Developer rebases feature2 onto the updated feature1
log_cmd git checkout feature2
log_cmd git rebase feature1
simulate_push feature2

echo ""
echo "=== State before squash-merge ==="
log_cmd git log --graph --oneline --all
echo ""

# Squash-merge feature1 into main (simulated via cherry-pick --no-commit + commit)
log_cmd git checkout main
# Use merge --squash to properly squash all of feature1's commits
log_cmd git merge --squash feature1
log_cmd git commit -m "Squash merge feature1"
SQUASH_COMMIT=$(git rev-parse HEAD)
simulate_push main

echo ""
echo "Squash commit: $SQUASH_COMMIT"
echo ""

# Run the action
run_update_pr_stack() {
log_cmd \
env \
SQUASH_COMMIT=$SQUASH_COMMIT \
MERGED_BRANCH=feature1 \
TARGET_BRANCH=main \
GH="$SCRIPT_DIR/mock_gh.sh" \
GIT="$SCRIPT_DIR/mock_git.sh" \
$SCRIPT_DIR/../update-pr-stack.sh
}
run_update_pr_stack

cd "$TEST_REPO"

# Verify: squash commit is an ancestor of feature2
if log_cmd git merge-base --is-ancestor "$SQUASH_COMMIT" feature2; then
echo "✅ feature2 includes the squash commit"
else
echo "❌ feature2 does not include the squash commit"
log_cmd git log --graph --oneline --all
exit 1
fi

# Verify: triple-dot diff main...feature2 shows ONLY feature2's unique change
# We expect the diff to show only feature2's unique change (line 9).
# Strip index lines and hunk header function-name context for stable comparison.
EXPECTED_DIFF=$(cat <<'EOF'
-line 9
+Feature 2 content
EOF
)
ACTUAL_DIFF=$(log_cmd git diff main...feature2 | grep '^[+-]' | grep -v '^[+-][+-][+-]')
if [[ "$ACTUAL_DIFF" == "$EXPECTED_DIFF" ]]; then
echo "✅ Triple-dot diff main...feature2 shows only feature2's unique changes"
else
echo "❌ Triple-dot diff is wrong"
echo "Expected:"
echo "$EXPECTED_DIFF"
echo "Actual:"
echo "$ACTUAL_DIFF"
diff <(echo "$EXPECTED_DIFF") <(echo "$ACTUAL_DIFF") || true
exit 1
fi

# Verify idempotence
FEATURE2_BEFORE=$(git rev-parse feature2)
run_update_pr_stack
cd "$TEST_REPO"
FEATURE2_AFTER=$(git rev-parse feature2)

if [[ "$FEATURE2_BEFORE" == "$FEATURE2_AFTER" ]]; then
echo "✅ Idempotence: re-running produces no new commits"
else
echo "❌ Idempotence failed"
exit 1
fi

echo ""
echo "All rebase-workflow tests passed! 🎉"
echo "Test repository remains at: $TEST_REPO for inspection"