Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
5049ca7
Push updated heads before retargeting PR bases in the fan-out path
claude Jun 9, 2026
bc5f5a9
Merge remote-tracking branch 'origin/main' into claude/code-review-bu…
claude Jun 9, 2026
f1f2a0e
Fix e2e merge-command assertion broken by the ff-only step
claude Jun 9, 2026
972442a
Drop the e2e assertion fix, split out into its own PR
claude Jun 9, 2026
f516594
Merge remote-tracking branch 'origin/main' into claude/code-review-bu…
claude Jun 9, 2026
7352003
Address PRs by number instead of head branch name
claude Jun 9, 2026
51cdb03
Merge remote-tracking branch 'origin/main' into claude/code-review-bu…
claude Jun 9, 2026
4fff137
Merge branch 'claude/code-review-bug-check-3njvdx' into claude/pr-num…
claude Jun 9, 2026
49cfcc7
Abort the resume when label or comment reads fail
claude Jun 9, 2026
7ac9aea
Trust only our own comments for the state marker
claude Jun 9, 2026
25b9a38
Merge updates from main and squash commit
invalid-email-address Jun 10, 2026
33f91fd
Reword the marker-trust comment
Phlogistique Jun 10, 2026
3b790a2
Merge updates from main and squash commit
invalid-email-address Jun 11, 2026
8e565f3
Merge branch 'main' into claude/fail-loud-resume-reads-3njvdx
Phlogistique Jun 11, 2026
bad55ab
Apply suggestion from @Phlogistique
Phlogistique Jun 11, 2026
6ab8295
Apply suggestion from @Phlogistique
Phlogistique Jun 11, 2026
55e48ae
Apply suggestion from @Phlogistique
Phlogistique Jun 11, 2026
01988fc
Merge remote-tracking branch 'origin/claude/fail-loud-resume-reads-3n…
claude Jun 11, 2026
f9030b4
Update update-pr-stack.sh
Phlogistique Jun 11, 2026
97545d1
Merge updates from main and squash commit
invalid-email-address Jun 11, 2026
d00892a
Merge remote-tracking branch 'origin/claude/marker-author-trust-3njvd…
claude Jun 11, 2026
77ab69f
Die on a malformed state marker instead of commenting
claude Jun 11, 2026
ebf2ae4
Merge remote-tracking branch 'origin/claude/marker-author-trust-3njvd…
claude Jun 11, 2026
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: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ This action tries to fix that in a transparent way. Install it, and hopefully th
1. Triggers when a PR is squash merged
2. Finds PRs that were based on the merged branch (direct children only)
3. Creates a synthetic merge commit with three parents (child tip, deleted branch tip, squash commit) to preserve history without re-introducing code
4. Updates the direct child PRs to base on trunk now that the bottom change has landed
5. Pushes updated branches and deletes the merged branch
4. Pushes the updated branches
5. Updates the direct child PRs to base on trunk now that the bottom change has landed
6. Deletes the merged branch

**Note:** Indirect descendants (grandchildren, etc.) are intentionally not modified. Their PR diffs remain correct because the merge-base calculation still works—the synthetic merge commit includes the original parent commit as an ancestor. When their direct parent is eventually merged, they become direct children and get updated at that point.

Expand Down
2 changes: 2 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ runs:
MERGED_BRANCH: ${{ github.event.pull_request.head.ref }}
TARGET_BRANCH: ${{ github.event.pull_request.base.ref }}
PR_BRANCH: ${{ github.event.pull_request.head.ref }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_BASE: ${{ github.event.pull_request.base.ref }}
run: |
echo "Running in $ACTION_MODE mode"
${{ github.action_path }}/update-pr-stack.sh
Expand Down
4 changes: 2 additions & 2 deletions tests/mock_gh.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ if [[ "$1" == "pr" && "$2" == "list" ]]; then
done

if [[ "$base" == "feature1" ]]; then
# feature2 is a direct child of feature1
echo 'feature2'
# feature2 is a direct child of feature1 (PR #2)
echo '2 feature2'
else
# No other bases have direct children in our test scenario
:
Expand Down
39 changes: 29 additions & 10 deletions tests/test_conflict_resolution_resume.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ ok() { echo "✅ $1"; PASS=$((PASS+1)); }
# Build a configurable gh mock in a temp dir. It records every invocation to
# $CALLS and is driven by env vars set per scenario:
# MOCK_LABELS newline-separated labels returned by `pr view --json labels`
# MOCK_BASE base branch returned by `pr view --json baseRefName`
# MOCK_COMMENTS_FILE file whose contents are returned by `pr view --json comments`
# The PR's base branch is not mocked: the script must take it from PR_BASE
# (event payload), so a baseRefName query is an unhandled call and fails.
make_mock_gh() {
local dir="$1"
cat > "$dir/mock_gh.sh" <<'EOF'
Expand All @@ -32,8 +33,11 @@ echo "gh $*" >> "$CALLS"
if [[ "$1 $2" == "pr view" ]]; then
case "$*" in
*--json\ labels*) printf '%s\n' "${MOCK_LABELS:-}";;
*--json\ baseRefName*) printf '%s\n' "${MOCK_BASE:-}";;
*--json\ comments*) cat "${MOCK_COMMENTS_FILE:-/dev/null}";;
*--json\ comments*)
# The comments file stands for our own comments only, so the query
# must restrict itself to those.
[[ "$*" == *viewerDidAuthor* ]] || { echo "comments query must filter by viewerDidAuthor" >&2; exit 1; }
cat "${MOCK_COMMENTS_FILE:-/dev/null}";;
*) echo "unhandled pr view: $*" >&2; exit 1;;
esac
elif [[ "$1 $2" == "pr comment" ]]; then
Expand Down Expand Up @@ -88,9 +92,9 @@ setup_repo() {
}

run_resume() {
env ACTION_MODE=conflict-resolved PR_BRANCH=child \
env ACTION_MODE=conflict-resolved PR_BRANCH=child PR_NUMBER=5 PR_BASE="$PR_BASE" \
GH="$MOCK_DIR/mock_gh.sh" GIT="$MOCK_DIR/mock_git.sh" \
MOCK_LABELS="$MOCK_LABELS" MOCK_BASE="$MOCK_BASE" \
MOCK_LABELS="$MOCK_LABELS" \
MOCK_COMMENTS_FILE="$MOCK_COMMENTS_FILE" CALLS="$CALLS" \
bash "$ROOT_DIR/update-pr-stack.sh" >"$WORK/out.log" 2>&1 || echo "EXIT=$?" >>"$WORK/out.log"
}
Expand All @@ -103,7 +107,7 @@ marker() { # base target squash
echo "### Scenario A: user manually retargeted the base -> no mutation"
setup_repo
MOCK_LABELS="autorestack-needs-conflict-resolution"
MOCK_BASE="spark" # human changed it; marker says parent
PR_BASE="spark" # human changed it; marker says parent
MOCK_COMMENTS_FILE="$WORK/comments.txt"
{ echo "### conflict"; echo; marker parent main "$SQUASH"; } > "$MOCK_COMMENTS_FILE"
run_resume
Expand All @@ -119,7 +123,7 @@ ok "A: manual retarget detected, no branch mutation, label removed"
echo "### Scenario B: no state marker -> no mutation"
setup_repo
MOCK_LABELS="autorestack-needs-conflict-resolution"
MOCK_BASE="parent"
PR_BASE="parent"
MOCK_COMMENTS_FILE="$WORK/comments.txt"
{ echo "### some old conflict comment with no marker"; } > "$MOCK_COMMENTS_FILE"
run_resume
Expand All @@ -137,13 +141,13 @@ setup_repo
git -C "$WORK" merge -q --no-edit main
git -C "$WORK" push -q origin child
MOCK_LABELS="autorestack-needs-conflict-resolution"
MOCK_BASE="parent" # matches marker -> not a manual retarget
PR_BASE="parent" # matches marker -> not a manual retarget
MOCK_COMMENTS_FILE="$WORK/comments.txt"
{ echo "### conflict"; echo; marker parent main "$SQUASH"; } > "$MOCK_COMMENTS_FILE"
run_resume

grep -q -- "git push origin child" "$CALLS" || fail "C: child not pushed"
grep -q -- "pr edit child --base main" "$CALLS" || fail "C: base not retargeted to main"
grep -q -- "pr edit 5 --base main" "$CALLS" || fail "C: base not retargeted to main"
grep -q "remove-label autorestack-needs-conflict-resolution" "$CALLS" || fail "C: label not removed"
push_line=$(grep -n -- "git push origin child" "$CALLS" | head -1 | cut -d: -f1)
base_line=$(grep -n -- "--base main" "$CALLS" | head -1 | cut -d: -f1)
Expand All @@ -156,7 +160,7 @@ ok "C: resume pushes, retargets base, then removes label"
echo "### Scenario D: recorded target branch is gone -> give up cleanly"
setup_repo
MOCK_LABELS="autorestack-needs-conflict-resolution"
MOCK_BASE="parent" # matches marker -> not a manual retarget
PR_BASE="parent" # matches marker -> not a manual retarget
MOCK_COMMENTS_FILE="$WORK/comments.txt"
{ echo "### conflict"; echo; marker parent ghost-target "$SQUASH"; } > "$MOCK_COMMENTS_FILE"
run_resume
Expand All @@ -167,5 +171,20 @@ grep -q -- "--base" "$CALLS" && fail "D: base must NOT be edited"
[[ "$(git -C "$ORIGIN" rev-parse child)" == "$CHILD_BEFORE" ]] || fail "D: child was pushed"
ok "D: missing target detected, no branch mutation, label removed"

# ---------------------------------------------------------------------------
echo "### Scenario E: malformed state marker -> no mutation"
setup_repo
MOCK_LABELS="autorestack-needs-conflict-resolution"
PR_BASE="parent"
MOCK_COMMENTS_FILE="$WORK/comments.txt"
{ echo "### conflict"; echo; echo '<!-- autorestack-state: base=parent target=main -->'; } > "$MOCK_COMMENTS_FILE"
run_resume

grep -q "remove-label autorestack-needs-conflict-resolution" "$CALLS" || fail "E: label not removed"
grep -q "gh pr comment" "$CALLS" || fail "E: no explanatory comment posted"
grep -q -- "--base" "$CALLS" && fail "E: base must NOT be edited"
[[ "$(git -C "$ORIGIN" rev-parse child)" == "$CHILD_BEFORE" ]] || fail "E: child was pushed"
ok "E: malformed marker handled, no branch mutation, label removed"

echo
echo "All conflict-resume tests passed 🎉 ($PASS scenarios)"
20 changes: 18 additions & 2 deletions tests/test_update_pr_stack.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/bin/bash

set -e
set -eo pipefail

# Get script directory (needed for static mock files)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
Expand Down Expand Up @@ -71,6 +71,8 @@ echo "Simulated Squash commit (via cherry-pick): $SQUASH_COMMIT"

echo "Running update-pr-stack.sh..."
# The update script sources command_utils.sh itself
# Capture stdout+stderr interleaved so command ordering can be asserted.
RUN_LOG="$TEST_REPO/update_run.log"
run_update_pr_stack() {
log_cmd \
env \
Expand All @@ -79,10 +81,24 @@ run_update_pr_stack() {
TARGET_BRANCH=main \
GH="$SCRIPT_DIR/mock_gh.sh" \
GIT="$SCRIPT_DIR/mock_git.sh" \
$SCRIPT_DIR/../update-pr-stack.sh
$SCRIPT_DIR/../update-pr-stack.sh 2>&1 | tee "$RUN_LOG"
}
run_update_pr_stack

# The head must be pushed before the PR is retargeted (a failed push must leave
# the PR untouched on its old base), and the merged branch deleted only after
# the retarget (deleting a PR's base branch closes the PR).
push_line=$(grep -n "git push origin feature2" "$RUN_LOG" | head -1 | cut -d: -f1 || true)
edit_line=$(grep -n "pr edit 2 --base main" "$RUN_LOG" | head -1 | cut -d: -f1 || true)
delete_line=$(grep -n "git push origin :feature1" "$RUN_LOG" | head -1 | cut -d: -f1 || true)
if [[ -n "$push_line" && -n "$edit_line" && -n "$delete_line" \
&& "$push_line" -lt "$edit_line" && "$edit_line" -lt "$delete_line" ]]; then
echo "✅ Ordering: push head, then retarget base, then delete merged branch"
else
echo "❌ Wrong ordering (push=$push_line edit=$edit_line delete=$delete_line)"
exit 1
fi

# Verify the results
cd "$TEST_REPO"

Expand Down
Loading
Loading