DEV Community

Salma Alam-Naylor
Salma Alam-Naylor

Posted on • Originally published at whitep4nth3r.com

How to delete all squash-merged local git branches with one terminal command

In 2022 I wrote about how I use a bash function to delete all merged git branches with a single terminal command. This works great for branches that have been merged, but not squashed. Given the team I'm working on right now like to squash and merge pull requests on GitHub, and also how I like to keep my dev environment clean, it was time to update the clean up function to take squashed branches into account.

Now, this was a total rabbit hole I went down, so bear with me.

The difference between "merge" and "squash and merge"

The default option when merging pull requests on GitHub is merge. When you merge a pull request on GitHub, all commits from a feature branch are added to the base branch in a merge commit. A merge commit preserves the full history of the changes being merged, allowing you to see an intertwining history of events that happened on both branches after a feature branch is merged into the base branch.

All checks have passed. 1 neutral, 3 successful checks. No conflicts with base branch. Merging can be performed automatically. Merge pull request call to action green button.

On GitHub, a default merge uses the --no-ff option in git, which means no fast-forward. This option is what creates the merge commit and allows you to inspect the complete history of both the feature and base branches.

When you squash and merge a pull request, all commits on the feature branch are "squashed" into a single commit, using the fast-forward option. A fast-forward commit merges all file changes after the most recent change on the base branch. This loses the distinct feature branch timeline of changes. This can be useful when:

  1. You want to keep your commit history tidy on the base branch
  2. You're merging small changes on short-lived branches
  3. You don't need to preserve the feature branch history

All checks have passed. 1 neutral, 3 successful checks. No conflicts with base branch. Merging can be performed automatically. Squash and merge pull request call to action green button.

Why the original bash script doesn't work for squashed branches

Here's the original bash script I wrote to delete all merged branches. The first line, git checkout main && git branch --merged, checks out the main branch, and lists all branches that can be detected as being merged into the main branch.

# delete all branches merged into main
dam() {
  echo "=== Deleting all merged branches ==="
  git checkout main && git branch --merged | grep -vE "(^\*|main)" | xargs git branch -d
  echo "☑️ Done!"
}
Enter fullscreen mode Exit fullscreen mode

When you do a squash merge, Git takes all the changes from the feature branch, compresses them into a new commit, and applies it to main as the latest change. The original branch’s commits are not in the history, so Git still sees that branch as “not merged". The git branch --merged command won't match any branches, and so won't find any branches to delete.

The script that finds and deletes squashed branches

To delete all squashed branches that merged using the fast-forward option we need to take a different approach. This script is named dams(), which stands for "delete all merged squashed". To use the script:

  • add the following code to your .bashrc or .zshrc file (switch out main for what you usually call your main branches if necessary)
  • save the file
  • source the new changes (or reload your terminal)
  • run dams in a git project directory (without the brackets)
  • enjoy the cleanup!
  • (This may need to be adjusted according to your shell, please don't come for me.)
dams() {
  echo "=== Deleting merged and squash-merged branches ==="

  # Ensure main is checked out and up to date
  git checkout main &&
  git pull &&

  # Loop over all local branches except main
  for branch in $(git for-each-ref --format='%(refname:short)' --exclude=refs/heads/main refs/heads/); do
    # Skip if branch does not exist on remote
    if ! git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
      echo "Skipping $branch (not on remote)"
      continue
    fi

    # Delete only if content is fully in main
    if git diff --quiet "$branch" main; then
      git branch -D "$branch"
      echo "Deleted $branch"
    else
      echo "Keeping $branch (has unmerged changes)"
    fi
  done

  echo "☑️ Done!"
}
Enter fullscreen mode Exit fullscreen mode

Let's break down what the script does, and learn some things about Git!

Check out the main branch and update it

First we want to make sure we checkout main and pull in the latest changes to compare the other branches against.

git checkout main && git pull &&
Enter fullscreen mode Exit fullscreen mode

Loop over local branches and check for changes

Here, the script loops over the branch names returned from the command [git for-each-ref](https://git-scm.com/docs/git-for-each-ref), which iterates over all branches (or refs) that match a pattern and shows them according to the given format. The pattern excludes the main branch (--exclude), and the --format requests the short branch name. refs/heads/ at the end of the line instructs the script to look only at local branches.

for branch in $(git for-each-ref --format='%(refname:short)' --exclude=refs/heads/main refs/heads/); do
Enter fullscreen mode Exit fullscreen mode

Check if a local branch has been pushed to the remote

This following code runs [git ls-remote](https://git-scm.com/docs/git-ls-remote) to check if the branch in the loop has been pushed to the remote to avoid deleting branches that haven't been pushed. The --exit-code flag instructs the command to exit with status "2" when no matching refs are found in the remote repository. In Bash, if considers exit code 0 as true and non-zero as false. Exit code 2 will evaluate to false, so with the ! at the start of the if, we're checking if git ls-remote returns false. If it does, we skip deleting that branch.

The >/dev/null 2>&1 part instructs the script to throw away both stdout and stderr output so that it produces no output at all, to keep the terminal free of noise. The script still produces a success or failure via the exit code, but suppresses all output.

# Skip if branch does not exist on remote
if ! git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
  echo "Skipping $branch (not on remote)"
  continue
fi
Enter fullscreen mode Exit fullscreen mode

Check for diffs and delete the local branch

This part of the script checks for diffs between the branch in the loop and the main branch. Again, in Bash, if considers exit code 0 as true and non-zero as false, so whilst this may look backwards compared traditional programming languages, if there is no diff found between the branches, then the branch will be deleted using [git branch -D](https://git-scm.com/docs/git-branch#Documentation/git-branch.txt--D).

I decided to use the -D flag to force the delete in this script, seeing as I'm operating on my local machine and it's safe to force delete local branches in my case, especially if we have checked at the start of the script if the local branch has been pushed to the remote.

# Delete only if content is fully in main
if git diff --quiet "$branch" main; then
  git branch -D "$branch"
  echo "Deleted $branch"
else
  echo "Keeping $branch (has unmerged changes)"
fi
Enter fullscreen mode Exit fullscreen mode

It uses the --quiet flag to suppress all output, as we don't need to view that whilst the script runs. Without the --quiet flag, here's what the command outputs in isolation when there is a diff:

Running git diff branch-6 main in a terminal showing a diff in the output.

And here's how the output looks when the full script runs and finds some branches to delete that have been squashed and merged on GitHub:

Running the delete all merged and squashed branches script showing main has been checked out and pulled. Then branch 5 was deleted and branch 6 was skipped because it is not on the remote.

Probably don't use this script

You probably shouldn't trust random bash scripts written by random people found on the internet, especially where your precious version-controlled work is concerned, so you probably shouldn't use this. This is my disclaimer. Use this script with caution!

Top comments (0)