Skip to content

Instantly share code, notes, and snippets.

@pmolodo
Last active September 23, 2025 19:22
Show Gist options
  • Save pmolodo/8f2c4d097a64a18caaee067c7362aec6 to your computer and use it in GitHub Desktop.
Save pmolodo/8f2c4d097a64a18caaee067c7362aec6 to your computer and use it in GitHub Desktop.
Script to prune branches that are identical to a remote
#!/usr/bin/env python
# prune_git_branches_on_remote by Paul Molodowitch is marked CC0 1.0.
# to view a copy of this mark, visit https://creativecommons.org/publicdomain/zero/1.0/
"""CLI interface to prune git branches from a target repo that match the reference repo."""
import argparse
import subprocess
import sys
import traceback
from enum import Enum
###############################################################################
# Utilities
###############################################################################
def is_ipython():
try:
__IPYTHON__ # type: ignore
except NameError:
return False
return True
def run_git_command(args):
result = subprocess.run(["git"] + args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True, text=True)
return result.stdout.strip()
def parse_refs(ref_output):
refs = {}
for line in ref_output.splitlines():
parts = line.strip().split()
if len(parts) == 2:
refs[parts[1].replace("refs/heads/", "")] = parts[0]
return refs
def get_remote_branches(remote):
return parse_refs(run_git_command(["ls-remote", "--branches", remote]))
def get_local_branches():
return parse_refs(run_git_command(["show-ref", "--branches"]))
###############################################################################
# Enums
###############################################################################
class PruneMode(Enum):
PROMPT = "prompt"
FORCE = "force"
DRY_RUN = "dry-run"
###############################################################################
# Core functions
###############################################################################
def prune_branches(
reference="origin",
target=None,
mode=PruneMode.PROMPT,
show_diff=True,
show_locals=False,
):
reference_refs = get_remote_branches(reference)
if target:
target_refs = get_remote_branches(target)
scope = f"remote '{target}'"
else:
target_refs = get_local_branches()
scope = "local"
to_delete = [
name for name, hash_ in target_refs.items() if name in reference_refs and reference_refs[name] == hash_
]
if to_delete:
print(f"The following {scope} branches match {reference} exactly and will be pruned:")
for name in to_delete:
print(f" {name}")
if mode == PruneMode.PROMPT:
confirm = input("Proceed with pruning? (y/N): ").strip().lower()
if confirm != "y":
print("Aborting.")
return
if mode != PruneMode.DRY_RUN:
for name in to_delete:
if target:
run_git_command(["push", target, "--delete", name])
else:
run_git_command(["branch", "-D", name])
print(f"Pruned {scope} branch: {name}")
else:
print("No branches to prune.")
if show_diff:
to_warn = [name for name in target_refs if name in reference_refs and target_refs[name] != reference_refs[name]]
if to_warn:
print(f"Branches with differing hashes in {scope} and {reference}:")
for name in to_warn:
print(f" {name}: {scope}({target_refs[name]}), reference({reference_refs[name]})")
else:
print(f"No differing branches found in {scope} compared to {reference}.")
if show_locals:
only_target = [name for name in target_refs if name not in reference_refs]
if only_target:
print(f"Branches only in {scope}:")
for name in only_target:
print(f" {name}")
else:
print(f"No branches exist only in {scope}.")
###############################################################################
# CLI
###############################################################################
def get_parser():
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument("--reference", default="origin", help="The reference remote to compare against")
parser.add_argument("--target", help="The target remote to prune (omit to prune local branches)")
parser.add_argument(
"--mode",
choices=[mode.value for mode in PruneMode],
default=PruneMode.PROMPT.value,
help="Prune mode: prompt, force, or dry-run",
)
parser.add_argument("--no-diff", dest="show_diff", action="store_false", help="Do not show differing hashes")
parser.add_argument("--show-locals", action="store_true", help="Show branches only in target scope")
return parser
def main(argv=None):
if argv is None:
argv = sys.argv[1:]
parser = get_parser()
args = parser.parse_args(argv)
try:
prune_branches(
reference=args.reference,
target=args.target,
mode=PruneMode(args.mode),
show_diff=args.show_diff,
show_locals=args.show_locals,
)
except Exception:
traceback.print_exc()
return 1
return 0
if __name__ == "__main__" and not is_ipython():
sys.exit(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment