šŸ Automate Git Branch Cleanup with Python: Say Goodbye to Manual Tidying

Tom Smykowski
11 min read3 days ago

--

If youā€™re like me, branches sometimes sit in your local repository long after theyā€™re merged or stale. Tidying them up one by one isnā€™t exactly fun. So, I thought ā€” why not automate it? Today, Iā€™m walking you through a Python script that makes branch cleanup simple, efficient, and best of all, quick to execute.

Ok. It seems like you liked my previous post about a 5-Minute Python script that allows live reload of script generated images and PDF. So I have something new for you today. This article will be free too, as I wrote earlier I donā€™t have a motivation to write, since my earnings from Medium dropped to almost zero.

I donā€™t know about you, but I donā€™t clear branches right away. Eventually these are merged on the remote repository, and local branchesā€¦ they live happily for weeks. Until thereā€™s so much of them I just tidy them up.

But hereā€™s the catch. I donā€™t like doing it, because you have to go through them, see if they were merged, pushed to remote repository at all and so on.

So I was wondering if itā€™s possible to actually do it automatically. I usually use Bash for such things, but today Iā€™ll use Python just to show you how awesome it is to replace bash. First letā€™s start by creating a command we could call from command line.

Making Python Script Callbable From Anywhere

So I create a small script.py file:

With this:

# script.py
print("Hello, World!")

When you call:

python script.py

It will execute it. But immediately two problem occur. First, I donā€™t want to call python script.py every time, secondly I donā€™t want to have it in my repository. I want to have it outside and use it in any repository.

To achieve that we have to do this:

chmod +x script.py

And we will create an alias, to do it, we have to open .bashrc:

cd ~

And open the file, because Iā€™m using terminal in VSCode, Iā€™ll just open it in VSCode:

code .bashrc

And add this line:

alias s="python /path/to/your/script.py"

When doing it on Windows notice the proper slashes.

After finishing, you have to close current terminal and open new. When you type ā€œsā€, youā€™ll see:

Meaning our script was actually executed. Now whereever youā€™ll be you can call the script. Nice right?

Using Rich UI

Rich library is ā€¦ rich

Letā€™s move forward. What I like to have, is a menu in my script, to add various options to it, so letā€™s make a menu. To do it, weā€™ll install Rich package:

pip install rich

Rich package is a terminal prettifier. You can format text, tables, syntax highlight and stuff. There are also ready to use components like tables and progress bars

Hereā€™s an example of displaying a table:

from rich.console import Console
from rich.table import Table

# Initialize the console for rich output
console = Console()

# Create a new table with a title and styled header
table = Table(title="Programming Languages", show_header=True, header_style="bold magenta")

# Define the columns with styles
table.add_column("Language", style="cyan", justify="left")
table.add_column("Developer", style="green", justify="left")
table.add_column("First Appeared", style="yellow", justify="right")
table.add_column("Typing Discipline", style="blue", justify="left")

# Add rows of data
table.add_row("Python", "Guido van Rossum", "1991", "Dynamic")
table.add_row("JavaScript", "Brendan Eich", "1995", "Dynamic")
table.add_row("Java", "James Gosling", "1995", "Static")
table.add_row("C++", "Bjarne Stroustrup", "1985", "Static")
table.add_row("Go", "Robert Griesemer, Rob Pike, Ken Thompson", "2009", "Static")

# Print the table to the console
console.print(table)

Result:

Using terminal doesnā€™t mean we canā€™t use fancy UI :)

Creating A Menu

Ok, now weā€™ll change our script to:

from rich.console import Console
import keyboard

console = Console()

def delete_unused_branches():
console.print("Deleting unused branches...", style="bold red")

def something_else():
console.print("Doing something else...", style="bold green")

def show_menu():
console.clear()
console.print("[1] Delete unused branches (Press 1 or D)", style="cyan")
console.print("[2] Something else (Press 2 or S)", style="cyan")
console.print("[q] Quit (Press Q)", style="cyan")

show_menu()

while True:
if keyboard.is_pressed("1") or keyboard.is_pressed("d"):
delete_unused_branches()
show_menu()
elif keyboard.is_pressed("2") or keyboard.is_pressed("s"):
something_else()
show_menu()
elif keyboard.is_pressed("q"):
console.print("Exiting...", style="bold yellow")
break

Ah, my dear, if youā€™ve ever tried managing scripts in bash, youā€™ve probably noticed how much smoother it feels to use Python. Itā€™s like stepping into a streamlined, organized world where every command falls neatly into place.

For instance, in my scripts, I skip the usual numbered choices and go with lettered options instead. It feels more natural, and to add a bit more elegance, my Python scripts execute commands instantly ā€” no need for that pesky extra confirmation with the enter key!

One of the Sci-Fi Summon The JSON Python deck cards

By the way, if youā€™re as passionate about Python as I am, you might love my printable flashcard deck designed especially for Python enthusiasts. Itā€™s crafted in three distinct styles: fantasy, where every function has a touch of magic; sci-fi, which feels like coding in a neon-lit futuristic universe; and neutral.

Whether youā€™re diving into function basics or mastering complex concepts, these decks are perfect for learning and adding a dash of personality to your coding practice!

The drawback of not confirming with enter is that when you do it with Python, it will catch keys pressed outside terminal. So weā€™ll use enter confirmation.

This is how rich looks:

Nice right?

With that structure you can add as many commands as you like. Weā€™ll focus on the one to delete unused branches.

So what are unused branches really? It depends on a case. But I usually have to delete such branches:

  • test branches (were never pushed to remote in a month)
  • other branches that were stale for a month (were pushed to remote, but nothing happened to them)
  • merged branches (ones that were merged to main branch on remote repository)

Because itā€™s slippery, Iā€™ll show you how to confirm every deletion.

So letā€™s begin. weā€™ll take care first of branches that werenā€™t pushed to remote, and are month old.

Deleting Local Unpushed, Stale Branches

Now our script will look like this:

from rich.console import Console
from rich.table import Table
from datetime import datetime, timedelta
import subprocess

console = Console()

# Configuration
DELETE_UNPUSHED_BRANCHES = True # Set to False to skip deletion of unpushed branches
CHECK_LAST_MODIFIED_TIME = True # Set to False to ignore the last modified time when checking branches
LAST_MODIFIED_CUTOFF = datetime.now() - timedelta(days=30)
DEFAULT_BRANCH = "main" # Branch to switch to before deletion if on the branch to be deleted

def get_local_branches():
result = subprocess.run(
["git", "for-each-ref", "--sort=-committerdate", "--format='%(refname:short) %(committerdate:iso8601)'", "refs/heads/"],
capture_output=True,
text=True
)
branches = result.stdout.strip().splitlines()

branch_info = []
for line in branches:
name, date_str = line.strip("'").split(maxsplit=1)
date = datetime.fromisoformat(date_str)
branch_info.append((name, date))

return branch_info

def get_current_branch():
result = subprocess.run(
["git", "branch", "--show-current"],
capture_output=True,
text=True
)
return result.stdout.strip()

def switch_to_default_branch():
subprocess.run(["git", "checkout", DEFAULT_BRANCH], check=True)
console.print(f"[bold green]Switched to '{DEFAULT_BRANCH}' branch.[/]")

def delete_unused_branches():
console.print("\n[bold red]Searching for unused branches...[/]\n")
branch_info = get_local_branches()
unused_branches_found = False

for branch, last_commit_date in branch_info:
last_commit_date = last_commit_date.replace(tzinfo=None)

# Bypass the date check if CHECK_LAST_MODIFIED_TIME is False
if not CHECK_LAST_MODIFIED_TIME or (CHECK_LAST_MODIFIED_TIME and last_commit_date < LAST_MODIFIED_CUTOFF):
is_pushed = subprocess.run(
["git", "rev-parse", "--verify", f"origin/{branch}"],
capture_output=True,
text=True
).returncode == 0

if not is_pushed:
unused_branches_found = True
show_branch_info(branch, last_commit_date)

if DELETE_UNPUSHED_BRANCHES:
console.print("[bold cyan]Delete this branch locally? (y/n/q to cancel): ", end="")
choice = input().strip().lower()
if choice == "y":
current_branch = get_current_branch()
if current_branch == branch:
switch_to_default_branch()
try:
subprocess.run(["git", "branch", "-D", branch], check=True)
console.print(f"\n[bold green]Branch '{branch}' deleted.[/]")
except subprocess.CalledProcessError:
console.print(f"\n[bold red]Failed to delete branch '{branch}'.[/]")
elif choice == "n":
console.print(f"\n[bold yellow]Branch '{branch}' skipped.[/]")
elif choice == "q":
console.print("\n[bold yellow]Operation canceled, returning to menu...[/]")
return

if not unused_branches_found:
console.print("[bold yellow]No unused branches found that meet the criteria.[/]")

def show_branch_info(branch, last_commit_date):
table = Table(title="Branch Information", show_header=True, header_style="bold magenta")
table.add_column("Branch", style="cyan", justify="left")
table.add_column("Last Edited", style="yellow", justify="right")
table.add_row(branch, last_commit_date.strftime('%Y-%m-%d'))
console.print(table)

def something_else():
console.print("[bold green]Doing something else...[/]")

def show_menu():
console.print("\n[D] [cyan]Delete unused branches[/]")
console.print("[S] [cyan]Something else[/]")
console.print("[Q] [cyan]Quit[/]")

def main():
while True:
show_menu()
console.print("\n[bold magenta]Select an option (D/S/Q): ", end="")
choice = input().strip().lower()
if choice == "d":
delete_unused_branches()
elif choice == "s":
something_else()
elif choice == "q":
console.print("[bold yellow]Exiting...[/]")
break

if __name__ == "__main__":
main()

You may noticed I use git rev-parse ā€” verify. Iā€™m executing it by combining origin (remote branch) with the local branch name. It returns an information if a remote branch under that name exists

You can configure if you want to delete unpushed branches at all, if you want to check last modification date and what is the cutoff time. Letā€™s see how it works. Iā€™ve created a branch that is unpushed, and disabled time checking (you can also set days to 0) to show how it works:

Perfect, I see the branch name, last edit and can decide if it should be removed, or I can quit the process to go to the main menu. Iā€™ve decided to remove the branch and it got removed.

Hereā€™s an interesting part, if youā€™re currently on this branch, you have to switch to other before you delete it, the script switches you automatically to main branch. You can configure it to other branch if you use other naming like ā€œmasterā€

Deleting Branches That Were Merged

Now letā€™s move to the tricky part. Deleting local branches that were merged on remote repository. To do this, we have to understand where the information is available.

First we fetch info from remote repo:

git fetch --all

Then we display what branches were merged:

git branch -r --merged origin/main

But it wonā€™t give us proper answer:

So we remove HEAD and main from the list:

git branch -r --merged origin/main | grep -vE 'origin/HEAD|origin/main'

Result:

Voila. Itā€™s the remote branch I merged to main, as you can see here:

So now, we need to remove these branches with our script:

from rich.console import Console
from rich.table import Table
from datetime import datetime, timedelta
import subprocess

console = Console()

# Configuration
DELETE_UNPUSHED_BRANCHES = True # Set to False to skip deletion of unpushed branches
CHECK_LAST_MODIFIED_TIME = True # Set to False to ignore the last modified time when checking branches
DELETE_MERGED_BRANCHES = True # Set to False to skip deletion of merged branches
LAST_MODIFIED_CUTOFF = datetime.now() - timedelta(days=0)
DEFAULT_BRANCH = "main" # Branch to switch to before deletion if on the branch to be deleted

def get_local_branches():
result = subprocess.run(
["git", "for-each-ref", "--sort=-committerdate", "--format='%(refname:short) %(committerdate:iso8601)'", "refs/heads/"],
capture_output=True,
text=True
)
branches = result.stdout.strip().splitlines()

branch_info = []
for line in branches:
name, date_str = line.strip("'").split(maxsplit=1)
date = datetime.fromisoformat(date_str)
branch_info.append((name, date))

return branch_info

def get_current_branch():
result = subprocess.run(
["git", "branch", "--show-current"],
capture_output=True,
text=True
)
return result.stdout.strip()

def switch_to_default_branch():
subprocess.run(["git", "checkout", DEFAULT_BRANCH], check=True)
console.print(f"[bold green]Switched to '{DEFAULT_BRANCH}' branch.[/]")

def delete_unused_branches():
console.print("\n[bold red]Searching for unused branches...[/]\n")
branch_info = get_local_branches()
unused_branches_found = False

for branch, last_commit_date in branch_info:
last_commit_date = last_commit_date.replace(tzinfo=None)

# Check if the branch is pushed to remote, ignoring date if CHECK_LAST_MODIFIED_TIME is False
is_pushed = subprocess.run(
["git", "rev-parse", "--verify", f"origin/{branch}"],
capture_output=True,
text=True
).returncode == 0

# Include the branch if it's unpushed, and if CHECK_LAST_MODIFIED_TIME is True, check the date
if not is_pushed and (not CHECK_LAST_MODIFIED_TIME or last_commit_date < LAST_MODIFIED_CUTOFF):
unused_branches_found = True
show_branch_info(branch, last_commit_date)

if DELETE_UNPUSHED_BRANCHES:
console.print("[bold cyan]Delete this branch locally? (y/n/q to cancel): ", end="")
choice = input().strip().lower()
if choice == "y":
current_branch = get_current_branch()
if current_branch == branch:
switch_to_default_branch()
try:
subprocess.run(["git", "branch", "-D", branch], check=True)
console.print(f"\n[bold green]Branch '{branch}' deleted.[/]")
except subprocess.CalledProcessError:
console.print(f"\n[bold red]Failed to delete branch '{branch}'.[/]")
elif choice == "n":
console.print(f"\n[bold yellow]Branch '{branch}' skipped.[/]")
elif choice == "q":
console.print("\n[bold yellow]Operation canceled, returning to menu...[/]")
return # Only return if 'q' is selected to cancel

if not unused_branches_found:
console.print("[bold yellow]No unused branches found that meet the criteria.[/]")

def delete_merged_branches():
if not DELETE_MERGED_BRANCHES:
return

console.print("\n[bold red]Searching for merged branches...[/]\n")
subprocess.run(["git", "fetch", "--all"], check=True)

merged_branches = subprocess.run(
["git", "branch", "-r", "--merged", f"origin/{DEFAULT_BRANCH}"],
capture_output=True,
text=True
).stdout.splitlines()

# Filter out HEAD and main branches
merged_branches = [branch.strip() for branch in merged_branches if not branch.endswith("/HEAD") and not branch.endswith(f"/{DEFAULT_BRANCH}")]

if not merged_branches:
console.print("[bold yellow]No merged branches found.[/]")
return

for branch in merged_branches:
# Extract branch name and retrieve the merge time
local_branch_name = branch.replace("origin/", "")
merge_time = subprocess.run(
["git", "log", "-1", "--format=%ci", f"{DEFAULT_BRANCH}..{branch}"],
capture_output=True,
text=True
).stdout.strip()

# Display branch info and prompt for deletion
table = Table(show_header=True, header_style="bold magenta")
table.add_column("Branch", style="cyan")
table.add_column("Merge Time", style="yellow")
table.add_row(local_branch_name, merge_time if merge_time else "Unknown")
console.print(table)

console.print("[bold cyan]Delete this merged branch locally? (y/n/q to cancel): ", end="")
choice = input().strip().lower()
if choice == "y":
try:
subprocess.run(["git", "branch", "-d", local_branch_name], check=True)
console.print(f"\n[bold green]Branch '{local_branch_name}' deleted.[/]")
except subprocess.CalledProcessError:
console.print(f"\n[bold red]Failed to delete branch '{local_branch_name}'.[/]")
elif choice == "n":
console.print(f"\n[bold yellow]Branch '{local_branch_name}' skipped.[/]")
elif choice == "q":
console.print("\n[bold yellow]Operation canceled, returning to menu...[/]")
return # Only return if 'q' is selected to cancel

def show_branch_info(branch, last_commit_date):
table = Table(title="Branch Information", show_header=True, header_style="bold magenta")
table.add_column("Branch", style="cyan", justify="left")
table.add_column("Last Edited", style="yellow", justify="right")
table.add_row(branch, last_commit_date.strftime('%Y-%m-%d'))
console.print(table)

def something_else():
console.print("[bold green]Doing something else...[/]")

def show_menu():
console.print("\n[D] [cyan]Delete unused and merged branches[/]")
console.print("[S] [cyan]Something else[/]")
console.print("[Q] [cyan]Quit[/]")

def main():
while True:
show_menu()
console.print("\n[bold magenta]Select an option (D/S/Q): ", end="")
choice = input().strip().lower()
if choice == "d":
delete_unused_branches()
delete_merged_branches()
elif choice == "s":
something_else()
elif choice == "q":
console.print("[bold yellow]Exiting...[/]")
break

if __name__ == "__main__":
main()

And now weā€™ll see this:

So thatā€™s the final script script, and hereā€™s the final output when I delete two unpushed branches and one merged:

Iā€™ve been using a script like this for ages, and itā€™s genuinely a time-saver when it comes to cleaning up branches and keeping things tidy. But hereā€™s the real magic ā€” if youā€™re eager to save time like I do, why not grab a set of my Python flashcard decks too? Theyā€™re designed to make learning not just efficient but fun, whether youā€™re a fan of the fantasy, sci-fi, or clean-cut neutral style.

If youā€™d love to take it a step further, contribute, fork, or download my Git Branch Cleaner script (with everything you need in one neat GitHub repository). And as you power through, think of these cards as your perfect companions in mastering Python. Get yourself a deck, level up your coding, and letā€™s make your workflow smooth, organized, and a bit more magical!

With this Python script, you can skip the hassle of manually checking each branch for its status. Instead, let the script handle unpushed, stale, and merged branches, all from the command line. Grab the script from GitHub, configure it to your liking, and enjoy a cleaner, more organized Git experience!

If this article gave you some new tricks for managing branches or inspired you to try a more organized workflow, go ahead and give it a clap (or two, or ten!). Share it with fellow coders, and drop a commentā€Šā€”ā€ŠIā€™d love to know, whatā€™s the one feature you wish every coding tool had? Letā€™s get the conversation going!

--

--

Tom Smykowski

Software Engineer & Tech Editor. Top 2% on StackOverflow, 3mil views on Quora. Won Shattered Pixel Dungeon.