š Automate Git Branch Cleanup with Python: Say Goodbye to Manual Tidying
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
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!
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!