-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathgit-cleanup.py
executable file
·159 lines (122 loc) · 4.87 KB
/
git-cleanup.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
#!/usr/bin/env python3
"""
Git branch reaper
When developing in a branch that is reviewed and frequently rebased, a nice
scheme to use is to -2, -3, … suffix success versions of the rebased branch so
you can always go back to a prior series. This is nice during review, but leaves
you with numerous irrelevant branches after finally landing the changes.
This script automates the process of (1) checking the final version of this
branch is really merged into the default branch and (2) deleting it and all its
predecessors.
"""
import argparse
import functools
import re
import shlex
import subprocess as sp
import sys
from typing import List
def run(args: List[str]):
print(f"+ {' '.join(shlex.quote(str(x)) for x in args)}")
sp.check_call(args)
def call(args: List[str]):
print(f"+ {' '.join(shlex.quote(str(x)) for x in args)}")
return sp.check_output(args, universal_newlines=True)
def force_delete(branch: str):
run(["git", "branch", "--delete", "--force", branch])
def delete(branch: str, remote: str, upstream: str):
# move to the branch to delete
run(["git", "checkout", "--detach", branch])
# rebase onto main
run(["git", "pull", "--rebase", remote, upstream])
# check where this moved our pointer to
us = call(["git", "rev-parse", "HEAD"])
them = call(["git", "rev-parse", f"{remote}/{upstream}"])
# move to main
run(["git", "checkout", f"{remote}/{upstream}"])
# if rebasing left no commits in addition to main, we can delete the target
if us == them:
force_delete(branch)
else:
raise RuntimeError(
f"{branch} still contained commits after rebasing {upstream}"
)
def branch_sorter(base: str, branch: str) -> int:
"""
sort key for incremented branches
"""
if base == branch:
return 0
assert branch.startswith(f"{base}-")
return int(branch[len(base) + 1 :])
def main(args: List[str]) -> int:
# parse command line options
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--dry-run",
"-N",
action="store_true",
help="do not perform destructive actions",
)
parser.add_argument("--debug", action="store_true", help="enable debugging output")
parser.add_argument("--remote", help="remote to compare against", default="origin")
parser.add_argument("--onto", help="branch that the target was merged into")
parser.add_argument("branch", help="branch to reap")
options = parser.parse_args(args[1:])
# check this is actually a Git repository
run(["git", "rev-parse", "HEAD"])
# is the working directory clean?
changes = call(["git", "status", "--short", "--ignore-submodules"])
if re.search(r"^.[^\?]", changes, flags=re.MULTILINE) is not None:
sys.stderr.write("changes in working directory; aborting\n")
return -1
# if the user did not give us a base, figure it out from upstream
if options.onto is None:
show = call(["git", "remote", "show", options.remote])
default = re.search(r"^\s*HEAD branch: (.*)$", show, flags=re.MULTILINE)
if default is None:
sys.stderr.write("could not figure out default branch name\n")
return -1
options.onto = default.group(1)
if options.debug:
sys.stderr.write(f"debug: figured out default branch is {options.onto}\n")
# if the branch contains ':', assume it is <fork>:<branch> as Github’s copy
# button gives you
options.branch = options.branch.split(":", maxsplit=1)[-1]
# find the branch(es) we are aiming to remove
victims: List[str] = []
branches = call(["git", "branch"])
for line in branches.split("\n"):
branch = line.lstrip(" *")
if branch.startswith(options.branch):
suffix = branch[len(options.branch) :]
if re.match(r"(-\d+)?$", suffix) is not None:
victims.append(branch)
elif options.debug:
sys.stderr.write(
f'debug: branch "{branch}"’s suffix "{suffix}" did not match\n'
)
elif options.debug:
sys.stderr.write(f"debug: skipping branch {branch}\n")
print(f"going to delete {victims}")
if options.dry_run:
print("exiting due to --dry-run")
return 0
if len(victims) == 0:
sys.stderr.write("no branches to delete\n")
return -1
# delete the branches is reverse order
for i, branch in enumerate(
sorted(
victims, key=functools.partial(branch_sorter, options.branch), reverse=True
)
):
# assume all non-final branches will not rebase cleanly and should be
# terminated with extreme prejudice
if i != 0:
force_delete(branch)
else:
delete(branch, options.remote, options.onto)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))