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