]>
Commit | Line | Data |
---|---|---|
2ea871e7 MF |
1 | #!/usr/bin/env python3 |
2 | ||
3 | """Helper to rebase all local branches.""" | |
4 | ||
5 | import argparse | |
6 | import os | |
7 | from pathlib import Path | |
8 | import re | |
9 | import subprocess | |
10 | import sys | |
11 | ||
12 | ||
13 | PROG = os.path.basename(__file__) | |
14 | ||
15 | ||
16 | class 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 | ||
36 | class 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 |
48 | def 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 |
54 | def 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 | ||
63 | def 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 | ||
76 | class 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 | ||
95 | def 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 | ||
107 | def 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 | ||
196 | if __name__ == '__main__': | |
197 | sys.exit(main(sys.argv[1:])) |