3 """Helper to rebase all local branches."""
8 from pathlib import Path
14 # Not sure if newer features are used.
15 assert sys.version_info >= (3, 6), f'Python 3.6+ required but found {sys.version}'
18 PROG = os.path.basename(__file__)
23 """Terminal escape sequences."""
29 _BLACK, _RED, _GREEN, _YELLOW, _BLUE, _MAGENTA, _CYAN, _WHITE = range(0, 8)
32 FG_BLACK = str(_FG + _BLACK)
33 FG_RED = str(_FG + _RED)
34 FG_GREEN = str(_FG + _GREEN)
35 FG_YELLOW = str(_FG + _YELLOW)
36 FG_BLUE = str(_FG + _BLUE)
37 FG_MAGENTA = str(_FG + _MAGENTA)
38 FG_CYAN = str(_FG + _CYAN)
39 FG_WHITE = str(_FG + _WHITE)
45 _combine = lambda *args: Terminal.CSI_PREFIX + ';'.join(args) + Terminal.SGR_SUFFIX
46 NORMAL = _combine(Terminal.NORMAL)
47 GOOD = _combine(Terminal.FG_GREEN)
48 WARN = _combine(Terminal.FG_YELLOW)
49 BAD = _combine(Terminal.FG_RED)
50 HILITE = _combine(Terminal.BOLD, Terminal.FG_CYAN)
51 BRACKET = _combine(Terminal.BOLD, Terminal.FG_BLUE)
55 return cls.GOOD + msg + cls.NORMAL
59 return cls.BAD + msg + cls.NORMAL
63 """Print a debug |msg|."""
65 print(msg, file=sys.stderr)
69 """Show an error |msg| then exit."""
70 print(Color.bad(f'{PROG}: error: {msg}'), file=sys.stderr)
74 def git(args, **kwargs):
76 kwargs.setdefault('check', True)
77 kwargs.setdefault('stdout', subprocess.PIPE)
78 kwargs.setdefault('stderr', subprocess.STDOUT)
79 kwargs.setdefault('encoding', 'utf-8')
81 print('+', 'git', *args)
82 return subprocess.run(['git'] + args, **kwargs)
85 @functools.lru_cache(maxsize=None)
86 def checkout_is_dirty():
87 """Determine whether the checkout is dirty (e.g. modified files)."""
88 output = git(['status', '--porcelain']).stdout
89 return any(x for x in output.splitlines() if '?' not in x[0:2])
92 @functools.lru_cache(maxsize=None)
93 def rebase_inprogress():
94 """Determine whether a rebase is already in progress."""
95 output = git(['rev-parse', '--git-path', 'rebase-merge']).stdout.strip()
96 if Path(output).exists():
99 output = git(['rev-parse', '--git-path', 'rebase-apply']).stdout.strip()
100 if Path(output).exists():
106 @functools.lru_cache(maxsize=None)
107 def cherry_pick_inprogress():
108 """Determine whether a cherry-pick is in progress."""
109 output = git(['rev-parse', '--git-path', 'CHERRY_PICK_HEAD']).stdout.strip()
110 return Path(output).exists()
113 class AppendOption(argparse.Action):
114 """Append the command line option (with no arguments) to dest.
116 parser.add_argument('-b', '--barg', dest='out', action='append_option')
117 options = parser.parse_args(['-b', '--barg'])
118 options.out == ['-b', '--barg']
121 def __init__(self, option_strings, dest, **kwargs):
122 if 'nargs' in kwargs:
123 raise ValueError('nargs is not supported for append_option action')
124 super().__init__(option_strings, dest, nargs=0, **kwargs)
126 def __call__(self, parser, namespace, values, option_string=None):
127 if getattr(namespace, self.dest, None) is None:
128 setattr(namespace, self.dest, [])
129 getattr(namespace, self.dest).append(option_string)
133 """Get CLI parser."""
134 parser = argparse.ArgumentParser(description=__doc__)
136 '--catchup', action='store_true',
137 help='run git-rb-catchup when rebasing')
139 '-d', '--debug', action='store_true',
140 help='enable debug output')
142 '-q', '--quiet', dest='git_options', action=AppendOption, default=['-q'],
143 help='passthru to git rebase')
148 """The main entry point for scripts."""
149 parser = get_parser()
150 opts = parser.parse_args(argv)
155 # Switch to the top dir in case the working dir doesn't exist in every branch.
157 topdir = git(['rev-parse', '--show-toplevel'], stderr=subprocess.PIPE).stdout.strip()
158 except subprocess.CalledProcessError as e:
159 sys.exit(f'{os.path.basename(sys.argv[0])}: {Path.cwd()}:\n{e}\n{e.stderr.strip()}')
163 # ||m|refs/remotes/origin/master|ahead 2, behind 203
164 # *||master|refs/remotes/origin/master|ahead 1
165 # |/usr/local/src/gnu/gdb/build/release|release||behind 10
168 ['for-each-ref', '--format=%(HEAD)|%(worktreepath)|%(refname:short)|%(upstream)|%(upstream:track,nobracket)',
169 'HEAD', 'refs/heads/*']).stdout.splitlines()
175 head, worktreepath, branch, tracking, ahead_behind = line.split('|')
176 branch_width = max(branch_width, len(branch))
180 elif not (worktreepath and worktreepath != topdir):
183 dbg(f'{worktreepath}:{branch}: Skipping branch checked out in diff worktree')
184 # If there are no branches to rebase, go silently.
188 # Are we in a detached head state?
189 if not git(['symbolic-ref', '-q', 'HEAD'], check=False).returncode:
190 fatal('unable to resolve current branch')
191 curr_state = git(['rev-parse', 'HEAD']).stdout.strip()
193 switched_head = False
196 _, worktreepath, branch, tracking, ahead_behind = line.split('|')
198 # If it's a branch in another worktree, ignore it.
199 if worktreepath and worktreepath != topdir:
202 print(f'{Color.BRACKET}### {Color.GOOD}{branch:{branch_width}}{Color.NORMAL} ',
205 print(f'{Color.WARN}skipping{Color.NORMAL} due to missing merge branch')
208 m = re.match(r'ahead ([0-9]+)', ahead_behind)
209 ahead = int(m.group(1)) if m else 0
210 m = re.search(r'behind ([0-9]+)', ahead_behind)
211 behind = int(m.group(1)) if m else 0
213 print(Color.good('up-to-date'))
216 # If we haven't switched the checkout, update-ref on current HEAD
217 # will get us into a dirty checkout, so use merge to handle it.
218 if switched_head or curr_state != branch:
219 git(['update-ref', f'refs/heads/{branch}', tracking])
220 print('fast forwarded [updated ref]')
222 result = git(['merge', '-q', '--ff-only'], check=False)
223 if result.returncode:
224 print(Color.bad('unable to merge') + '\n' + result.stdout.strip())
226 print('fast forwarded [merged]')
229 # Skip this ref if tree is in a bad state.
230 if rebase_inprogress():
231 print(Color.bad('skipping due to active rebase'))
233 if cherry_pick_inprogress():
234 print(Color.bad('skipping due to active cherry-pick'))
236 if checkout_is_dirty():
237 print(Color.bad('unable to rebase: tree is dirty'))
240 print(f'rebasing [{ahead_behind}] ', end='', flush=True)
241 result = git(['checkout', '-q', branch], check=False)
242 if result.returncode:
243 print(Color.bad('unable to checkout') + '\n' + result.stdout.strip())
248 result = git(['rb-catchup'], capture_output=False, check=False)
250 result = git(['rebase'] + opts.git_options, check=False)
251 if result.returncode:
252 git(['rebase', '--abort'])
253 print(Color.bad('failed') + '\n' + result.stdout.strip())
255 print(Color.good('OK'))
258 git(['checkout', '-q', curr_state])
262 if __name__ == '__main__':
263 sys.exit(main(sys.argv[1:]))