]>
Commit | Line | Data |
---|---|---|
2ea871e7 MF |
1 | #!/usr/bin/env python3 |
2 | ||
3 | """Helper to rebase all local branches.""" | |
4 | ||
5 | import argparse | |
eb86c710 | 6 | import functools |
2ea871e7 MF |
7 | import os |
8 | from pathlib import Path | |
9 | import re | |
10 | import subprocess | |
11 | import sys | |
12 | ||
13 | ||
eb86c710 MF |
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 | ||
2ea871e7 MF |
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 | ||
65a6ea84 MF |
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 | ||
2ea871e7 MF |
59 | def 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) |
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) | |
2ea871e7 MF |
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(): | |
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 | ||
218 | if __name__ == '__main__': | |
219 | sys.exit(main(sys.argv[1:])) |