Skip to content

Instantly share code, notes, and snippets.

@maximetinu
Last active April 13, 2026 18:16
Show Gist options
  • Select an option

  • Save maximetinu/b6abf4d0a166c3d55ffec220640f9f67 to your computer and use it in GitHub Desktop.

Select an option

Save maximetinu/b6abf4d0a166c3d55ffec220640f9f67 to your computer and use it in GitHub Desktop.
git merge-stack — Cascade-merge a PR stack from bottom to top
#!/usr/bin/env bash
#
# git-merge-stack — Cascade-merge a PR stack from bottom to top.
#
# Setup (run once):
# mkdir -p ~/.local/bin
# cp this-file ~/.local/bin/git-merge-stack
# chmod +x ~/.local/bin/git-merge-stack
# Ensure ~/.local/bin is on your PATH (add to ~/.zshrc if needed):
# export PATH="$HOME/.local/bin:$PATH"
#
# Usage: git merge-stack
# Run from any branch in the stack. Discovers the full chain via `gh`,
# merges each base into its child, and pushes. Aborts on any conflict.
#
set -euo pipefail
ORIGINAL_BRANCH=$(git rev-parse --abbrev-ref HEAD)
trap 'git checkout "$ORIGINAL_BRANCH" 2>/dev/null' EXIT
PR_JSON=$(gh pr list --author @me --state open --json number,headRefName,baseRefName)
if [ "$(echo "$PR_JSON" | jq length)" -eq 0 ]; then
echo "No open PRs found for you."
exit 1
fi
# --- Walk down to the root of the stack containing the current branch ---
current="$ORIGINAL_BRANCH"
while true; do
base=$(echo "$PR_JSON" | jq -r --arg h "$current" \
'[.[] | select(.headRefName == $h)][0].baseRefName // empty')
if [ -z "$base" ]; then
echo "Error: '$current' is not the head of any of your open PRs."
exit 1
fi
parent_head=$(echo "$PR_JSON" | jq -r --arg h "$base" \
'[.[] | select(.headRefName == $h)][0].headRefName // empty')
if [ -z "$parent_head" ]; then
stack_base="$base"
break
fi
current="$base"
done
# --- Build the ordered chain (bottom → top) ---
chain=()
cursor="$current"
while [ -n "$cursor" ]; do
chain+=("$cursor")
# Ensure the stack is linear (no forks)
children=$(echo "$PR_JSON" | jq --arg b "$cursor" \
'[.[] | select(.baseRefName == $b)] | length')
if [ "$children" -gt 1 ]; then
echo "Error: '$cursor' has $children PRs targeting it — not a linear stack."
exit 1
fi
cursor=$(echo "$PR_JSON" | jq -r --arg b "$cursor" \
'[.[] | select(.baseRefName == $b)][0].headRefName // empty')
done
# --- Print the stack ---
echo "Stack:"
echo " $stack_base"
for b in "${chain[@]}"; do
pr=$(echo "$PR_JSON" | jq -r --arg h "$b" \
'[.[] | select(.headRefName == $h)][0].number')
echo " └─ #$pr $b"
done
echo ""
# --- Back up each branch before any changes ---
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
echo "Creating backups (timestamp: $TIMESTAMP) ..."
for branch in "${chain[@]}"; do
safe_name=$(echo "$branch" | tr '/' '-')
backup="backup/${safe_name}_${TIMESTAMP}"
git branch "$backup" "$branch" 2>/dev/null || git branch "$backup" "origin/$branch"
echo " $branch → $backup"
done
echo ""
# --- Fetch once, then merge + push each level ---
git fetch origin
prev_ref="origin/$stack_base"
for branch in "${chain[@]}"; do
pr=$(echo "$PR_JSON" | jq -r --arg h "$branch" \
'[.[] | select(.headRefName == $h)][0].number')
git checkout "$branch" 2>/dev/null
# Fast-forward local to match remote (fail if diverged)
if git rev-parse --verify "origin/$branch" >/dev/null 2>&1; then
git merge "origin/$branch" --ff-only 2>/dev/null || {
echo "Error: local '$branch' has diverged from origin — resolve manually."
exit 1
}
fi
echo "Merging into #$pr $branch ..."
git merge "$prev_ref" --no-edit || {
echo "CONFLICT merging into $branch — aborting."
git merge --abort
exit 1
}
git push origin "$branch"
# Use the local ref going forward (it's now ahead of origin/)
prev_ref="$branch"
done
echo ""
echo "Done — all branches in the stack are up to date."
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment