Created
August 16, 2023 16:45
-
-
Save mccutchen/3e6bb5617636fb24175b6e8b3050cc3c to your computer and use it in GitHub Desktop.
This file contains 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 python3 | |
import subprocess | |
import sys | |
def run_cmd(cmd): | |
return subprocess.check_output(cmd).decode("utf8").strip() | |
def get_current_branch(): | |
return run_cmd(["git", "branch", "--show-current"]) | |
def delete_local_branch(name, force=False): | |
delete_flag = "-D" if force else "-d" | |
return run_cmd(["git", "branch", delete_flag, name]) | |
def force_delete_local_branch(name): | |
return delete_local_branch(name, force=True) | |
def delete_remote_branch(name): | |
remote, name = name.split("/", 1) | |
return run_cmd(["git", "push", remote, ":{}".format(name)]) | |
def list_branches_to_delete(remote=False): | |
""" | |
Return a list of branch names that should be safe to delete. The given opts | |
will be passed into `git branch` and should determine whether to list local | |
or remote branches. | |
Local branch output: | |
$ git branch --merged | |
INFRA-658-user-cleanup | |
* master | |
master-new | |
new-master | |
Remote branch output: | |
$ git branch -r --merged | |
origin/DISTR-609-scaffold-components-and-fetch-artifacts | |
origin/HEAD -> origin/master | |
origin/INFRA-658-user-cleanup | |
origin/FOO-136-API-GET-project-by-ID-precommit | |
origin/feeder-worker | |
origin/master | |
""" | |
cmd = ["git", "branch"] | |
if remote: | |
cmd.append("-r") | |
cmd.extend(["--merged"]) | |
lines = run_cmd(cmd).splitlines() | |
names = [parse_branch_name(line) for line in lines] | |
return [name for name in names if safe_to_delete(name, remote)] | |
def list_local_branches_with_deleted_remotes(): | |
""" | |
With a "squash-and-merge" GitHub development style, you'll end up with | |
local branches that have been merged, but `git branch --merged` can't tell | |
because the local commits were squashed on GitHub's end before merging. | |
To handle that case, we fall back to finding local branches whose remote | |
tracking branches have been deleted, which boils down to simulating this | |
shell pipeline: | |
$ git branch -vv | grep ': gone]' | awk '{print $1}' | |
The important part is here: | |
$ git branch -vv | grep ': gone]' | |
cdk-deploy-test 65572dd [origin/cdk-deploy-test: gone] look up existing task execution role | |
cdk-eks a42a911 [origin/cdk-eks: gone] infra: add proof-of-concept rodeo EKS cluster | |
""" # noqa | |
cmd = ["git", "branch", "-vv"] | |
lines = run_cmd(cmd).splitlines() | |
return [line.split()[0] for line in lines if ": gone]" in line] | |
def parse_branch_name(line): | |
return line.lstrip(" *").split()[0] | |
def safe_to_delete(name, is_remote): | |
remote_name = None | |
if is_remote: | |
remote_name, branch = name.split("/", 1) | |
else: | |
branch = name | |
is_protected = branch in ("main", "master", "develop", "HEAD") | |
is_mine = remote_name == "origin" or not is_remote | |
return is_mine and not is_protected | |
def pluralize(xs, suffix="s"): | |
return suffix if len(xs) > 0 else "" | |
def get_answer(base_prompt): | |
answer_map = {"y": True, "yes": True, "n": False, "no": False, "q": False} | |
prompt = f"{base_prompt} [Y/n]: " | |
while True: | |
answer = input(prompt).strip().lower() or "y" | |
if answer in answer_map: | |
return answer_map[answer] | |
def main(): | |
current_branch = get_current_branch() | |
if current_branch not in ("master", "main"): | |
print( | |
f"Error: git-cleanup must be run from the `main` or `master` " | |
f"branch (currently: `{current_branch}`)" | |
) | |
return 1 | |
local_branches = set(list_branches_to_delete(remote=False)) | |
local_branches_with_deleted_remotes = ( | |
set(list_local_branches_with_deleted_remotes()) - local_branches | |
) | |
remote_branches = list_branches_to_delete(remote=True) | |
work = [ | |
("fully merged local branches", local_branches, delete_local_branch), | |
( | |
"local branches w/ deleted remotes", | |
local_branches_with_deleted_remotes, | |
force_delete_local_branch, | |
), | |
("fully merged remote branches", remote_branches, delete_remote_branch), | |
] | |
for kind, branches, delete in work: | |
if not branches: | |
continue | |
count = len(branches) | |
print(f"Found {count} {kind}:") | |
for branch in sorted(branches): | |
print(f" - {branch}") | |
do_delete = get_answer(f"Delete {count} {kind}?") | |
if do_delete: | |
for branch in branches: | |
delete(branch) | |
print() | |
if __name__ == "__main__": | |
sys.exit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment