A development tool I cannot live without: bin/merge_master_into_all_git_branches

One of my favorite "damage control" scripts on small teams (10-20 devs) is a tool that takes the latest master code and attempts to merge it into all other active branches. This has a few concrete use cases.

  1. Stopping CI errors from propagating to random feature branches. If someone merges code with a broken test (e.g., test or SDK generation failures), anyone who subsequently merges master into their feature branch will inherit this failure. Typically, they will assume the failure is related to their own changes and waste time debugging it. Therefore, it makes sense to have a way to quickly merge a fixed master into all branches before too many people get confused.

  2. Major dependency changes (e.g., Django versions). Switching branches between code with Django v4 vs. v5, for example, is a frustrating experience. It requires re-installing libraries and carrying the mental load of tracking version differences. Furthermore, there is a risk of bugs arising when engineers develop against one version, only to realize their changes are incompatible with master when it is too late.

  3. Stopping serious bugs quickly. This is vital for bugs that cause the development or staging environment to crash. You need a way to propagate the fix immediately.

I've copied the script in full below, but here is the gist of what it does:

  • Fetches all open GitHub PRs under active development. We don't look at all branches because most are irrelevant for these purposes.

  • Handles migration conflicts using Django’s built-in tools and our own layers on top of it. (That's a topic for another blog post!)

  • Attempts to merge each branch in a loop. If one branch has conflicts, it skips it and moves on to the next one—but creates a report of PRs with conflicts so you can communicate this to the team for immediate triage.

  • Pushes it automatically to GitHub

The code

#!/usr/bin/env bash
# PURPOSE: Sometimes every branch needs the latest master in order
# to avoid a serious breaking change -- such as a library upgrade without
# which it would be impossible to stage a PR for review. Another situation
# is when failing tests are contagious on many branches due to making
# it into master.
#
# This script merges master into all PRs that open in GitHub.
#
# It provides a report of branches that were successfully merged and which
# ones had conflicts. The ones with conflicts will need to be manually addressed.
#
# USAGE: ./bin/git/merge_master_into_all_branches

merged_branches=()
conflicted_branches=()

# Exit script if any uncommited files or changes
if ! git diff-index --quiet HEAD --; then
  echo "You have uncommitted changes. Please commit or stash them before running this script."
  exit 1
fi

# Loop through a list of open pull requests
while read -r pr; do
  branch=$(jq -r '.headRefName' <<< "$pr")
  title=$(jq -r '.title' <<< "$pr")
  echo
  echo "Merging master into $branch..."
  echo

  # Checkout the pull request branch, going to next iteration if failed
  # skip the hook to make faster
  git -c core.hooksPath=/dev/null checkout "$branch" || continue

  # Pull latest changes
  git pull

  # Fetch the latest changes from master
  git fetch origin master

  # Merge master into the current branch
  if git merge master --no-edit; then

    # Handle merge migrations
    ./bin/manage makemigrations --merge --noinput

    # Commit only if there are changes to the merge files
    find -E . -type f -regex '.*/[0-9]+_merge_[0-9]+_[0-9]+\.py' | xargs -o git add
    if ! git diff --exit-code --quiet; then
      echo -e "\nCommitting merge migrations...\n\n"
      git commit -m "Add merge migration via bin/merge_master_into_all_branches"
    fi

    # Push changes to origin
    git push origin "$branch"
    echo "Successfully merged and pushed $branch."
    merged_branches+=("$title")
  else
    echo "-----Merge conflict in $branch."
    # Handle merge conflicts
    # Add the conflicted branch to the list
    conflicted_branches+=("$title")

    # Continue to the next pull request
    git reset --hard
    git clean -fd

    echo "Failed to merge master into $branch. Moving on to the next PR."
    continue
  fi
  # Need to use process substitution with while loop in order to avoid clobbering conflicted_branches array
done < <(gh pr list --state open --json number,title,headRefName -q '.[]')

# Print a report of branches with merge conflicts
echo -e "\nBranches with merge conflicts:"
for conflicted_branch in "${conflicted_branches[@]}"; do
  echo "- $conflicted_branch"
done

# Print a report of branches successfully merged
if [ ${#merged_branches[@]} -gt 0 ]; then
  echo -e "\nBranches successfully merged:"
  for merged_branch in "${merged_branches[@]}"; do
    echo "- $merged_branch"
  done
else
  echo "No branches successfully merged."
fi