]> git.wh0rd.org - home.git/blame - .bin/git-rb-all
git-rb-all: handle empty trees & dirty trees better
[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
6import os
7from pathlib import Path
8import re
9import subprocess
10import sys
11
12
13PROG = os.path.basename(__file__)
14
15
16class Terminal:
17 """Terminal escape sequences."""
18
19 CSI_PREFIX = '\033['
20 SGR_SUFFIX = 'm'
21 NORMAL = ''
22 BOLD = '1'
23 _BLACK, _RED, _GREEN, _YELLOW, _BLUE, _MAGENTA, _CYAN, _WHITE = range(0, 8)
24 _FG = 30
25 _BG = 40
26 FG_BLACK = str(_FG + _BLACK)
27 FG_RED = str(_FG + _RED)
28 FG_GREEN = str(_FG + _GREEN)
29 FG_YELLOW = str(_FG + _YELLOW)
30 FG_BLUE = str(_FG + _BLUE)
31 FG_MAGENTA = str(_FG + _MAGENTA)
32 FG_CYAN = str(_FG + _CYAN)
33 FG_WHITE = str(_FG + _WHITE)
34
35
36class Color:
37 """Helper colors."""
38
39 _combine = lambda *args: Terminal.CSI_PREFIX + ';'.join(args) + Terminal.SGR_SUFFIX
40 NORMAL = _combine(Terminal.NORMAL)
41 GOOD = _combine(Terminal.FG_GREEN)
42 WARN = _combine(Terminal.FG_YELLOW)
43 BAD = _combine(Terminal.FG_RED)
44 HILITE = _combine(Terminal.BOLD, Terminal.FG_CYAN)
45 BRACKET = _combine(Terminal.BOLD, Terminal.FG_BLUE)
46
47
65a6ea84
MF
48def fatal(msg):
49 """Show an error |msg| then exit."""
50 print(f'{Color.BAD}{PROG}: error: {msg}{Color.NORMAL}', file=sys.stderr)
51 sys.exit(1)
52
53
2ea871e7
MF
54def git(args, **kwargs):
55 """Run git."""
56 kwargs.setdefault('check', True)
57 kwargs.setdefault('capture_output', True)
58 kwargs.setdefault('encoding', 'utf-8')
59 #print('+', 'git', *args)
60 return subprocess.run(['git'] + args, **kwargs)
61
62
63def rebase_inprogress():
64 """Determine whether a rebase is already in progress."""
65 output = git(['rev-parse', '--git-path', 'rebase-merge']).stdout.strip()
66 if Path(output).exists():
67 return True
68
69 output = git(['rev-parse', '--git-path', 'rebase-apply']).stdout.strip()
70 if Path(output).exists():
71 return True
72
73 return False
74
75
76class AppendOption(argparse.Action):
77 """Append the command line option (with no arguments) to dest.
78
79 parser.add_argument('-b', '--barg', dest='out', action='append_option')
80 options = parser.parse_args(['-b', '--barg'])
81 options.out == ['-b', '--barg']
82 """
83
84 def __init__(self, option_strings, dest, **kwargs):
85 if 'nargs' in kwargs:
86 raise ValueError('nargs is not supported for append_option action')
87 super().__init__(option_strings, dest, nargs=0, **kwargs)
88
89 def __call__(self, parser, namespace, values, option_string=None):
90 if getattr(namespace, self.dest, None) is None:
91 setattr(namespace, self.dest, [])
92 getattr(namespace, self.dest).append(option_string)
93
94
95def get_parser():
96 """Get CLI parser."""
97 parser = argparse.ArgumentParser(description=__doc__)
98 parser.add_argument(
99 '--catchup', action='store_true',
100 help='run git-rb-catchup when rebasing')
101 parser.add_argument(
102 '-q', '--quiet', dest='git_options', action=AppendOption, default=['-q'],
103 help='passthru to git rebase')
104 return parser
105
106
107def main(argv):
108 """The main entry point for scripts."""
109 parser = get_parser()
110 opts = parser.parse_args(argv)
111
112 # Skip if rebase is in progress.
113 if rebase_inprogress():
65a6ea84 114 fatal('skipping due to active rebase')
2ea871e7
MF
115
116 # Switch to the top dir in case the working dir doesn't exist in every branch.
117 topdir = git(['rev-parse', '--show-toplevel']).stdout.strip()
118 os.chdir(topdir)
119
120 # Example output:
121 # ||m|refs/remotes/origin/master|ahead 2, behind 203
122 # *||master|refs/remotes/origin/master|ahead 1
123 # |/usr/local/src/gnu/gdb/build/release|release||behind 10
124 # ||s-stash||
125 state = git(
126 ['for-each-ref', '--format=%(HEAD)|%(worktreepath)|%(refname:short)|%(upstream)|%(upstream:track,nobracket)',
127 'refs/heads/*']).stdout.splitlines()
128
129 curr_state = None
130 branch_width = 0
65a6ea84 131 local_count = 0
2ea871e7
MF
132 for line in state:
133 head, worktreepath, branch, tracking, ahead_behind = line.split('|')
134 branch_width = max(branch_width, len(branch))
135 if head == '*':
136 curr_state = branch
65a6ea84
MF
137 if not (worktreepath and worktreepath != topdir):
138 local_count += 1
139 # If there are no branches to rebase, go silently.
140 if not local_count:
141 return 0
2ea871e7 142 if not curr_state:
65a6ea84 143 fatal('unable to resolve current branch')
2ea871e7 144
65a6ea84 145 is_dirty = None
2ea871e7
MF
146 branches = {}
147 for line in state:
148 head, worktreepath, branch, tracking, ahead_behind = line.split('|')
149
150 # If it's a branch in another worktree, ignore it.
151 if worktreepath and worktreepath != topdir:
152 continue
153
154 print(f'{Color.BRACKET}### {Color.GOOD}{branch:{branch_width}}{Color.NORMAL} ',
155 end='', flush=True)
156 if not tracking:
157 print(f'{Color.WARN}skipping{Color.NORMAL} due to missing merge branch')
158 continue
159
160 m = re.match(r'ahead ([0-9]+)', ahead_behind)
161 ahead = int(m.group(1)) if m else 0
162 m = re.search(r'behind ([0-9]+)', ahead_behind)
163 behind = int(m.group(1)) if m else 0
164 if not behind:
165 print('Up-to-date!')
166 continue
167 elif not ahead:
168 git(['update-ref', f'refs/heads/{branch}', tracking])
169 print('fast forwarded')
170 continue
171
65a6ea84
MF
172 if is_dirty is None:
173 output = git(['status', '--porcelain']).stdout
174 is_dirty = any(x for x in output.splitlines() if '?' not in x[0:2])
175 if is_dirty:
176 print(f'{Color.BAD}unable to rebase: tree is dirty{Color.NORMAL}')
177 continue
178
2ea871e7
MF
179 print(f'rebasing [{ahead_behind}] ', end='', flush=True)
180 git(['checkout', '-q', branch])
181 if opts.catchup:
182 print()
183 result = git(['rb-catchup'], capture_output=False, check=False)
184 else:
185 result = git(['rebase'] + opts.git_options, check=False)
186 if result.returncode:
187 git(['rebase', '--abort'])
188 print(f'{Color.BAD}failed{Color.NORMAL}\n' + result.stdout.strip())
189 else:
190 print('OK!')
191
192 git(['checkout', '-q', curr_state])
193 return 0
194
195
196if __name__ == '__main__':
197 sys.exit(main(sys.argv[1:]))