Curious (Clojure) Programmer Simplicity matters

Menu

  • Home
  • Archives
  • Tags
  • About
  • My Talks
  • Clojure Tip of the Day Screencast
  • (Open) Source
  • Weekly Bits & Pieces
  • RSS
September 15, 2023

Git - How to Delete Merged Branches Older than X Days?

Table of Contents
  • Ok, can you tell me how to actually do it?
    • TL;DR: The final script
    • How it works
  • GitHub Actions job
    • Full job definition
  • References

At CodeScene, we used to not delete merged git branches, at all. The practice came from Branch Analysis. To get that data, we didn’t want to remove a merged branch immediately. However, we ended up, never deleting them which also causes a problem: over time you accumulate thousands of branches. That creates a mess and makes it hard (or impossible) to select proper branch in CodeScene itself (the max number of branches we load and display is 1000).

Recently, we decided that it’s time to start removing older merged branches We still want to keep recent merged branches for ocassional review (via CodeScene’s branch analyses) but we do not need anything older than 1 month.

The question arises, how to do it: There’s setting in GitHub to do this automatically (see Managing the automatic deletion of branches). But that’s pretty much instantaneous - it deletes the merged branch immediately, which isn’t what we want.

So we have to resort to a more manual approach - we decided to write a script that’s periodically run via GitHub Actions.

Ok, can you tell me how to actually do it?

TL;DR: The final script

It’s quite simple - I ended up writing this "one-liner"[1]:

MAIN_BRANCH=master
MAX_AGE_DAYS=30
git branch -r --merged origin/${MAIN_BRANCH} --no-contains ${MAIN_BRANCH} --format='%(committerdate:raw)%09%(refname:short)' | awk -v max_age=$(( $MAX_AGE_DAYS * 86400 )) -v now=$(date +%s) '{ diff = now - $1; if (diff > max_age) print $3 }' | sed 's/origin\///' | xargs git push --delete origin

How it works

It works like this:

  1. Find all the branches merged into the main branch (excluding the main)

    git branch -r --merged origin/${MAIN_BRANCH} --no-contains ${MAIN_BRANCH} ...
  2. …​ while adding committerdate at the same time - using the raw format to get Unix Epoch seconds:

    ... --format='%(committerdate:raw)%09%(refname:short)'
  3. Use awk to calculate the difference between current time and print branch names only for refs with commiterdate older than the threshold (30 days)

    awk -v max_age=$(( $MAX_AGE_DAYS * 86400 )) -v now=$(date +%s) '{ diff = now - $1; if (diff > max_age) print $3 }'

    Notice how I use awk -v to define awk variables max_age and now because I cannot reference shell variables easily inside the awk script

  4. Remove the 'origin/' prefix

    sed 's/origin\///'
  5. Finally, delete all the matching branches

    xargs git push --delete origin

The output of the first two steps may look like this:

1683723780 +0200        origin/branch-a
1683190390 +0200        origin/branch-b

GitHub Actions job

Running this via GH Actions is relatively easy, but there’s one critical piece you need to be aware of - the checkout step has be configured with fetch-depth: 0 [2]:

...
      - uses: actions/checkout@v3
        with:
          # Fetch all the branches to be able to list them later
          fetch-depth: 0

Otherwise, the script won’t be able to find anything and fails with a rather cryptic error: "fatal: --delete doesn’t make sense without any refs"

Full job definition

name: Delete old merged git branches
on:
  schedule:
    - cron: "0 6 * * 1-5"
  workflow_dispatch:
    inputs:
      max-age-days:
        description: "Branches older than 'age' (days) will be deleted. Default: 30"
        required: true
        default: 30
        type: number
      main-branch:
        description: "The main branch that shouldn't be deleted. Default: 'master'"
        required: true
        default: master
        type: string

env:
  MAX_AGE_DAYS: ${{ github.event.inputs.max-age-days }}
  MAIN_BRANCH: ${{ github.event.inputs.main-branch }}

jobs:
  build:
    timeout-minutes: 5
    runs-on: ubuntu-latest

    steps:
      - name: "Print info"
        run: echo "The script will remove all git branches older than ${{ env.MAX_AGE_DAYS }} days and merged into the ${{ env.MAIN_BRANCH }} branch."
      - uses: actions/checkout@v3
        with:
          # Fetch all the branches to be able to list them later
          fetch-depth: 0
      - name: "Delete old merged branches"
      # The best way to understand this is to actually run it piece-by-piece, perhaps skipping the last command (actual delete)
      # ----
      # 1. The "git branch" command shows all the merged branches with the date (unix epoch seconds) of the latest commit, e.g.
      #        1636550813 +0100        origin/1095-improve-trello-provider
      # 2. The "awk" command filter out those that are older then the threshold, printing only the branch names ($3)
      # 3. Finally, we delete the branches one by one via `xargs git push --delete ...`
      # ----
      # NOTE:  the 'origin/' prefix for the branch name is needed later, otherwise it fails on GH actions with this error: "fatal: --delete doesn't make sense without any refs"
        run: git branch -r --merged origin/${{ env.MAIN_BRANCH }} --no-contains origin/${{ env.MAIN_BRANCH }} --format='%(committerdate:raw)%09%(refname:short)' | awk -v max_age=$(( ${{ env.MAX_AGE_DAYS }} * 86400 )) -v now=$(date +%s) '{ diff = now - $1; if (diff > max_age) print $3 }' | sed 's/origin\///' | xargs git push --delete origin

References

  • CodeScene’s Branch Analysis

  • GitHub - Managing the automatic deletion of branches

  • use awk -v to define an awk variable


1. I know, it’s more than one line but that’s just definition of a couple of environment variables
2. Thanks, Kalle!

Tags: bash git command-line awk

« Starting Clojure REPL with plain java Feature flags, Middlewares, and Cloudfront caching. »

Copyright © 2025 Juraj Martinka

Powered by Cryogen | Free Website Template by Download Website Templates