13
\$\begingroup\$

I have written a shell script utility for deleting all merged local branches.

#!/usr/bin/env bash

# Remove references to remote branches that no longer exist.
git remote prune origin
# Create a file containing list of all merged branches.
git branch --merged > 5db3bb3c-718a-444c-b1ce-d90a5a0d1cb3.clc

nano 5db3bb3c-718a-444c-b1ce-d90a5a0d1cb3.clc
# Trim trailing and leading whitespace etc.
sed 's/^[ \t]*//;s/[ \t]*$//' < 5db3bb3c-718a-444c-b1ce-d90a5a0d1cb3.clc
# Soft delete all branches left in the file and then remove tmp file.
xargs git branch -d < 5db3bb3c-718a-444c-b1ce-d90a5a0d1cb3.clc
rm 5db3bb3c-718a-444c-b1ce-d90a5a0d1cb3.clc

Although I wrote this only for personal use, I will likely create a repository for it on my github account and I am not particularly familiar with shell script best practices.

(Edit: The script eventually ended up on github here)

\$\endgroup\$
  • 1
    \$\begingroup\$ I might be wrong, but I don't think that sed command will actually do anything useful: you've directed its input from the file, but its output will just be to standard output, so the file won't be edited. I think you need the -i option to edit the file in place. \$\endgroup\$ – IMSoP Feb 21 '18 at 10:00
  • \$\begingroup\$ @IMSoP you appear to be right. I should have tested that really. \$\endgroup\$ – Rudi Kershaw Feb 21 '18 at 10:09
  • \$\begingroup\$ xargs already reads in lines with leading/trailing whitespace removed making that sed redundant. A strange looking file that is - is it some kind of Magic number? \$\endgroup\$ – shalomb Feb 21 '18 at 20:28
  • \$\begingroup\$ @shalomb It was just a UUID as the name of a temp file. The hope was to avoid clashes with other file names, but I've since changed it to use mktemp. \$\endgroup\$ – Rudi Kershaw Feb 22 '18 at 9:00
16
\$\begingroup\$

Improve your file handling:

  • Don't use a hard coded file name for temporary files.
  • Don't spew temporary files into the current directory.
  • Make sure you clean up temporary files even on error.

The first two can be addressed by leveraging mktemp. The last can be addressed with a trap.

branchesfile=$(mktemp)
trap "{ rm -f $branchesfile; }" EXIT

You should also honor the user's choice of default editor, rather than require nano. You can get the user's editor preference with git var GIT_EDITOR:

$(git var GIT_EDITOR) $branchesfile

Git tools typically honor the exit code of the editor. If the editor exited with error, this should stop the script:

if [ $? -ne 0 ]; then
    exit 1
fi

This allows the user to cancel the operation if they spot a problem they don't want to fix at the moment.

Putting this all together:

#!/usr/bin/env bash
branchesfile=$(mktemp)
trap "{ rm -f $branchesfile; }" EXIT

# Remove references to remote branches that no longer exist.
git remote prune origin
# Create a file containing list of all merged branches.
git branch --merged > $branchesfile

$(git var GIT_EDITOR) $branchesfile
if [ $? -ne 0 ]; then
    exit 1
fi

# Trim trailing and leading whitespace etc.
sed 's/^[ \t]*//;s/[ \t]*$//' < $branchesfile
# Soft delete all branches left in the file and then remove tmp file.
xargs git branch -d < $branchesfile

There's one other issue: your script will try to keep going if any other commands error out. set -e is one option to solve this, but there are caveats to using it. If you choose to use set -e, you'll need to revise the exit code checking of the editor, since bash will exit before it gets to the if.

\$\endgroup\$
14
\$\begingroup\$

DRY - don't repeat yourself

"Don't repeat yourself" is a popular programming principle. You could apply it here by not repeating your filename over and over.

MY_BRANCHES="5db3bb3c-718a-444c-b1ce-d90a5a0d1cb3.clc"
git branch --merged > $MY_BRANCHES

nano $MY_BRANCHES
# Trim trailing and leading whitespace etc.
sed 's/^[ \t]*//;s/[ \t]*$//' < $MY_BRANCHES
# Soft delete all branches left in the file and then remove tmp file.
xargs git branch -d < $MY_BRANCHES
rm $MY_BRANCHES
\$\endgroup\$
  • \$\begingroup\$ jpmc26 provides a more complete answer. \$\endgroup\$ – chicks Feb 21 '18 at 14:16
7
\$\begingroup\$

I am assuming you are starting nano so that you may remove possible occurrences of master branch (or some other static branch) from the list. You can do the same in a single line command:

git branch --merged | grep -vE "(^\*|master|other|fixed|names)" | xargs git branch -d

The ^* check will remove the currently checkedout branch, and you can put in the other branch names in the grep pattern.

\$\endgroup\$
  • 1
    \$\begingroup\$ Actually, my intention was to allow interactive changes. But perhaps I'll add an interactive mode argument and do this by default. \$\endgroup\$ – Rudi Kershaw Feb 20 '18 at 18:15
  • 2
    \$\begingroup\$ A better idea would be to not delete branches that track a remote branch. \$\endgroup\$ – jpmc26 Feb 20 '18 at 22:33
3
\$\begingroup\$

I've actually written a script to do the same thing myself. My tips:

Advice

  • I wouldn't use git branch, its output is only meant to be human readable (in git parlance, it's part of git's porcelain). A better suited command is git for-each-ref which outputs its data in a machine readable format (part of git's 'plumbing').

  • I'd also be more explicit about which branch you care about being merged into. I believe by default git branch --merged will show you all branches already merged into the current branch. That works if you're on master or whatever your main/trunk/integration branch is. But, if you happen to be on a different branch, you'll end up deleting a bunch of branches that you didn't mean to.

  • There's also no need to save the branch names to a file, just operate on them iteratively.

  • Make sure you're not deleting your main branch (it will show as merged into itself).

  • I also wouldn't just willy-nilly delete the branches without asking the user first.

  • I'd also use bash's ability to stop immediately when any command has an error.

Putting it all together

Stop the script immediately on any command failure:

set -e

To get the "main" branch of your repo you can do:

main_branch=$(git rev-parse --abbrev-ref HEAD)

To get the local branches that have been merged into your main branch:

git for-each-ref --merged "${main_branch}" --format '%(refname:short)' 'refs/heads/'

Now you'll want to iterate over that list. Git branch names are usually safe from oddball characters that might mess with shell parsing, so we could probably do:

for branch in $(git for-each-ref --merged "${main_branch}" --format '%(refname:short)' 'refs/heads/') ; do
  # something here
done

I've found to be more explicit that we're dealing with one entry per line, and to take the entire line as the branch name no matter what characters we might find there, the following form is better:

while read branch ; do
  # something here
done < <(git for-each-ref --merged "${main_branch}" --format '%(refname:short)' 'refs/heads/')

Now, what code do we put inside the loop? Well, first we'll want to make sure we don't delete the main branch:

  test "${main_branch}" = "${branch}" && continue

Build the delete branch command

  cmd="git branch -d '${branch}';"$'\n'

Ask the user about running the delete command (make sure we read from the terminal since we're inside the while loop which is reading from the git for-each-ref command):

  read -p "${cmd}Execute(y/N)? " run < /dev/tty

Only if the user responded with exactly 'y' do we run the command:

  test "${run}" = "y" && eval "${cmd}"

The whole shebang

#!/usr/bin/env bash

set -e

main_branch=$(git rev-parse --abbrev-ref HEAD)

while read branch ; do
done < <(git for-each-ref --merged "${main_branch}" --format '%(refname:short)' 'refs/heads/')
  test "${main_branch}" = "${branch}" ] && continue

  cmd="git branch -d '${branch}';"$'\n'
  read -p "${cmd}Execute(y/N)? " run < /dev/tty
  test "${run}" = "y" && eval "${cmd}"
done < <(git for-each-ref --merged "${main_branch}" --format '%(refname:short)' 'refs/heads/')

Notes:

Now, if those local branches that you deleted had remote branches that should also be deleted, you'd want to do something like:

# Get the remote name of the main branch, probably just 'origin'
remote=$(git config "branch.${main_branch}.remote")

# Then inside the loop, you'd do something like this:
git push --delete "${remote}" "${branch}"

But it's a little more complicated because you'd want to first find out if this branch even has a remote set up, what the actual remote branch name is (could be different from the local), if its HEAD is at (or an ancestor of) the local branch, etc.

\$\endgroup\$
1
\$\begingroup\$

The command below deletes anything that has been merged into master.

git branch --merged | egrep -v "(^\*|master|dev)" | xargs git branch -d
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.