Last active
September 23, 2025 19:22
-
-
Save pmolodo/8f2c4d097a64a18caaee067c7362aec6 to your computer and use it in GitHub Desktop.
Script to prune branches that are identical to a remote
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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