]> git.wh0rd.org Git - home.git/blob - .bin/git-rb-all
git-rb-all: fix update-ref usage with current HEAD, and make nops faster
[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
20
21 class 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
41 class 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
53 def 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
59 def git(args, **kwargs):
60     """Run git."""
61     kwargs.setdefault('check', True)
62     kwargs.setdefault('stdout', subprocess.PIPE)
63     kwargs.setdefault('stderr', subprocess.STDOUT)
64     kwargs.setdefault('encoding', 'utf-8')
65     #print('+', 'git', *args)
66     return subprocess.run(['git'] + args, **kwargs)
67
68
69 @functools.lru_cache(maxsize=None)
70 def 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)
77 def 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
90 class 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
109 def 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
121 def 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():
128         fatal('skipping due to active rebase')
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
145     local_count = 0
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
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
156     if not curr_state:
157         fatal('unable to resolve current branch')
158
159     switched_head = False
160     branches = {}
161     for line in state:
162         _, worktreepath, branch, tracking, ahead_behind = line.split('|')
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:
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]')
193             continue
194
195         if checkout_is_dirty():
196             print(f'{Color.BAD}unable to rebase: tree is dirty{Color.NORMAL}')
197             continue
198
199         print(f'rebasing [{ahead_behind}] ', end='', flush=True)
200         git(['checkout', '-q', branch])
201         switched_head = True
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
213     if switched_head:
214         git(['checkout', '-q', curr_state])
215     return 0
216
217
218 if __name__ == '__main__':
219     sys.exit(main(sys.argv[1:]))