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