]> git.wh0rd.org - home.git/blame - .bin/git-rb-all
cros-board: update
[home.git] / .bin / git-rb-all
CommitLineData
2ea871e7
MF
1#!/usr/bin/env python3
2
3"""Helper to rebase all local branches."""
4
5import argparse
eb86c710 6import functools
2ea871e7
MF
7import os
8from pathlib import Path
9import re
8c438e9f 10import shlex
2ea871e7
MF
11import subprocess
12import sys
13
14
eb86c710
MF
15# Not sure if newer features are used.
16assert sys.version_info >= (3, 6), f'Python 3.6+ required but found {sys.version}'
17
18
2ea871e7 19PROG = os.path.basename(__file__)
669b9886 20DEBUG = False
2ea871e7
MF
21
22
23class Terminal:
24 """Terminal escape sequences."""
25
26 CSI_PREFIX = '\033['
27 SGR_SUFFIX = 'm'
28 NORMAL = ''
29 BOLD = '1'
30 _BLACK, _RED, _GREEN, _YELLOW, _BLUE, _MAGENTA, _CYAN, _WHITE = range(0, 8)
31 _FG = 30
32 _BG = 40
33 FG_BLACK = str(_FG + _BLACK)
34 FG_RED = str(_FG + _RED)
35 FG_GREEN = str(_FG + _GREEN)
36 FG_YELLOW = str(_FG + _YELLOW)
37 FG_BLUE = str(_FG + _BLUE)
38 FG_MAGENTA = str(_FG + _MAGENTA)
39 FG_CYAN = str(_FG + _CYAN)
40 FG_WHITE = str(_FG + _WHITE)
41
42
43class Color:
44 """Helper colors."""
45
46 _combine = lambda *args: Terminal.CSI_PREFIX + ';'.join(args) + Terminal.SGR_SUFFIX
47 NORMAL = _combine(Terminal.NORMAL)
48 GOOD = _combine(Terminal.FG_GREEN)
49 WARN = _combine(Terminal.FG_YELLOW)
50 BAD = _combine(Terminal.FG_RED)
51 HILITE = _combine(Terminal.BOLD, Terminal.FG_CYAN)
52 BRACKET = _combine(Terminal.BOLD, Terminal.FG_BLUE)
53
fb5d2cb6
MF
54 @classmethod
55 def good(cls, msg):
56 return cls.GOOD + msg + cls.NORMAL
57
58 @classmethod
59 def bad(cls, msg):
60 return cls.BAD + msg + cls.NORMAL
61
2ea871e7 62
8c438e9f 63def dbg(*args, **kwargs):
669b9886
MF
64 """Print a debug |msg|."""
65 if DEBUG:
8c438e9f 66 print(*args, file=sys.stderr, **kwargs)
669b9886
MF
67
68
65a6ea84
MF
69def fatal(msg):
70 """Show an error |msg| then exit."""
fb5d2cb6 71 print(Color.bad(f'{PROG}: error: {msg}'), file=sys.stderr)
65a6ea84
MF
72 sys.exit(1)
73
74
2ea871e7
MF
75def git(args, **kwargs):
76 """Run git."""
77 kwargs.setdefault('check', True)
eb86c710
MF
78 kwargs.setdefault('stdout', subprocess.PIPE)
79 kwargs.setdefault('stderr', subprocess.STDOUT)
2ea871e7 80 kwargs.setdefault('encoding', 'utf-8')
669b9886 81 if DEBUG:
8c438e9f
MF
82 dbg('+', 'git', shlex.join(args))
83 ret = subprocess.run(['git'] + args, **kwargs)
84 if DEBUG:
85 if ret.stdout:
86 dbg(ret.stdout.rstrip())
87 if ret.stderr:
88 dbg('stderr =', ret.stderr)
89 if ret.returncode:
90 dbg('++ exit', ret.returncode)
91 return ret
2ea871e7
MF
92
93
eb86c710
MF
94@functools.lru_cache(maxsize=None)
95def checkout_is_dirty():
96 """Determine whether the checkout is dirty (e.g. modified files)."""
97 output = git(['status', '--porcelain']).stdout
98 return any(x for x in output.splitlines() if '?' not in x[0:2])
99
100
101@functools.lru_cache(maxsize=None)
2ea871e7
MF
102def rebase_inprogress():
103 """Determine whether a rebase is already in progress."""
104 output = git(['rev-parse', '--git-path', 'rebase-merge']).stdout.strip()
105 if Path(output).exists():
106 return True
107
108 output = git(['rev-parse', '--git-path', 'rebase-apply']).stdout.strip()
109 if Path(output).exists():
110 return True
111
112 return False
113
114
fb5d2cb6
MF
115@functools.lru_cache(maxsize=None)
116def cherry_pick_inprogress():
117 """Determine whether a cherry-pick is in progress."""
118 output = git(['rev-parse', '--git-path', 'CHERRY_PICK_HEAD']).stdout.strip()
119 return Path(output).exists()
120
121
8c438e9f
MF
122@functools.lru_cache(maxsize=None)
123def top_dir() -> Path:
124 """Find the top dir of the git checkout."""
125 output = git(['rev-parse', '--show-toplevel'], stderr=subprocess.PIPE).stdout.strip()
126 return Path(output).resolve()
127
128
129@functools.lru_cache(maxsize=None)
130def git_dir() -> Path:
131 """Find the internal git dir for this project."""
132 output = git(['rev-parse', '--git-dir']).stdout.strip()
133 return Path(output).resolve()
134
135
136@functools.lru_cache(maxsize=None)
137def worktree_is_local(worktree: str) -> bool:
138 """See whether |worktree| is the cwd git repo."""
139 if not worktree:
140 return True
141
6efb1ad0
MF
142 # If .git is a symlink, worktree result might be the target.
143 if worktree == str(git_dir().resolve()):
144 return True
145
8c438e9f
MF
146 # NB: worktree path is supposed to be absolute from for-each-ref, but it's
147 # not always, so we have to resolve it. https://crbug.com/git/88
148 worktree = (git_dir() / worktree).resolve()
149 return worktree == top_dir()
150
151
2ea871e7
MF
152class AppendOption(argparse.Action):
153 """Append the command line option (with no arguments) to dest.
154
155 parser.add_argument('-b', '--barg', dest='out', action='append_option')
156 options = parser.parse_args(['-b', '--barg'])
157 options.out == ['-b', '--barg']
158 """
159
160 def __init__(self, option_strings, dest, **kwargs):
161 if 'nargs' in kwargs:
162 raise ValueError('nargs is not supported for append_option action')
163 super().__init__(option_strings, dest, nargs=0, **kwargs)
164
165 def __call__(self, parser, namespace, values, option_string=None):
166 if getattr(namespace, self.dest, None) is None:
167 setattr(namespace, self.dest, [])
168 getattr(namespace, self.dest).append(option_string)
169
170
171def get_parser():
172 """Get CLI parser."""
173 parser = argparse.ArgumentParser(description=__doc__)
174 parser.add_argument(
175 '--catchup', action='store_true',
176 help='run git-rb-catchup when rebasing')
669b9886
MF
177 parser.add_argument(
178 '-d', '--debug', action='store_true',
179 help='enable debug output')
2ea871e7
MF
180 parser.add_argument(
181 '-q', '--quiet', dest='git_options', action=AppendOption, default=['-q'],
182 help='passthru to git rebase')
183 return parser
184
185
186def main(argv):
187 """The main entry point for scripts."""
188 parser = get_parser()
189 opts = parser.parse_args(argv)
190
669b9886
MF
191 global DEBUG
192 DEBUG = opts.debug
193
2ea871e7 194 # Switch to the top dir in case the working dir doesn't exist in every branch.
669b9886 195 try:
8c438e9f 196 topdir = top_dir()
669b9886
MF
197 except subprocess.CalledProcessError as e:
198 sys.exit(f'{os.path.basename(sys.argv[0])}: {Path.cwd()}:\n{e}\n{e.stderr.strip()}')
2ea871e7
MF
199 os.chdir(topdir)
200
201 # Example output:
202 # ||m|refs/remotes/origin/master|ahead 2, behind 203
203 # *||master|refs/remotes/origin/master|ahead 1
204 # |/usr/local/src/gnu/gdb/build/release|release||behind 10
205 # ||s-stash||
206 state = git(
207 ['for-each-ref', '--format=%(HEAD)|%(worktreepath)|%(refname:short)|%(upstream)|%(upstream:track,nobracket)',
fb5d2cb6 208 'HEAD', 'refs/heads/*']).stdout.splitlines()
2ea871e7
MF
209
210 curr_state = None
211 branch_width = 0
65a6ea84 212 local_count = 0
2ea871e7
MF
213 for line in state:
214 head, worktreepath, branch, tracking, ahead_behind = line.split('|')
215 branch_width = max(branch_width, len(branch))
216 if head == '*':
217 curr_state = branch
65a6ea84 218 local_count += 1
8c438e9f 219 elif worktree_is_local(worktreepath):
669b9886
MF
220 local_count += 1
221 else:
222 dbg(f'{worktreepath}:{branch}: Skipping branch checked out in diff worktree')
65a6ea84
MF
223 # If there are no branches to rebase, go silently.
224 if not local_count:
225 return 0
2ea871e7 226 if not curr_state:
fb5d2cb6
MF
227 # Are we in a detached head state?
228 if not git(['symbolic-ref', '-q', 'HEAD'], check=False).returncode:
229 fatal('unable to resolve current branch')
230 curr_state = git(['rev-parse', 'HEAD']).stdout.strip()
2ea871e7 231
eb86c710 232 switched_head = False
2ea871e7
MF
233 branches = {}
234 for line in state:
eb86c710 235 _, worktreepath, branch, tracking, ahead_behind = line.split('|')
2ea871e7
MF
236
237 # If it's a branch in another worktree, ignore it.
8c438e9f
MF
238 if not worktree_is_local(worktreepath):
239 dbg(f'{worktreepath}:{branch}: Skipping branch checked out in diff worktree')
2ea871e7
MF
240 continue
241
242 print(f'{Color.BRACKET}### {Color.GOOD}{branch:{branch_width}}{Color.NORMAL} ',
243 end='', flush=True)
244 if not tracking:
245 print(f'{Color.WARN}skipping{Color.NORMAL} due to missing merge branch')
246 continue
247
248 m = re.match(r'ahead ([0-9]+)', ahead_behind)
249 ahead = int(m.group(1)) if m else 0
250 m = re.search(r'behind ([0-9]+)', ahead_behind)
251 behind = int(m.group(1)) if m else 0
252 if not behind:
fb5d2cb6 253 print(Color.good('up-to-date'))
2ea871e7
MF
254 continue
255 elif not ahead:
eb86c710
MF
256 # If we haven't switched the checkout, update-ref on current HEAD
257 # will get us into a dirty checkout, so use merge to handle it.
258 if switched_head or curr_state != branch:
259 git(['update-ref', f'refs/heads/{branch}', tracking])
260 print('fast forwarded [updated ref]')
261 else:
262 result = git(['merge', '-q', '--ff-only'], check=False)
263 if result.returncode:
fb5d2cb6 264 print(Color.bad('unable to merge') + '\n' + result.stdout.strip())
eb86c710
MF
265 else:
266 print('fast forwarded [merged]')
2ea871e7
MF
267 continue
268
fb5d2cb6
MF
269 # Skip this ref if tree is in a bad state.
270 if rebase_inprogress():
271 print(Color.bad('skipping due to active rebase'))
272 continue
273 if cherry_pick_inprogress():
274 print(Color.bad('skipping due to active cherry-pick'))
275 continue
eb86c710 276 if checkout_is_dirty():
fb5d2cb6 277 print(Color.bad('unable to rebase: tree is dirty'))
65a6ea84
MF
278 continue
279
2ea871e7 280 print(f'rebasing [{ahead_behind}] ', end='', flush=True)
fb5d2cb6
MF
281 result = git(['checkout', '-q', branch], check=False)
282 if result.returncode:
283 print(Color.bad('unable to checkout') + '\n' + result.stdout.strip())
284 continue
eb86c710 285 switched_head = True
2ea871e7
MF
286 if opts.catchup:
287 print()
288 result = git(['rb-catchup'], capture_output=False, check=False)
289 else:
290 result = git(['rebase'] + opts.git_options, check=False)
291 if result.returncode:
8c438e9f 292 git(['rebase', '--abort'], check=False)
fb5d2cb6 293 print(Color.bad('failed') + '\n' + result.stdout.strip())
2ea871e7 294 else:
fb5d2cb6 295 print(Color.good('OK'))
2ea871e7 296
eb86c710
MF
297 if switched_head:
298 git(['checkout', '-q', curr_state])
2ea871e7
MF
299 return 0
300
301
302if __name__ == '__main__':
303 sys.exit(main(sys.argv[1:]))