]> git.wh0rd.org - home.git/blame - .bin/git-rb-catchup
cros-board: update
[home.git] / .bin / git-rb-catchup
CommitLineData
2ea871e7 1#!/usr/bin/env python3
58742bc5 2# Distributed under the terms of the GNU General Public License v2 or later.
2ea871e7 3
58742bc5
MF
4"""Helper to automatically rebase onto latest commit possible.
5
6Helpful when you have a branch tracking an old commit, and a lot of conflicting
7changes have landed in the latest branch, but you still want to update.
8
9A single rebase to the latest commit will require addressing all the different
10changes at once which can be difficult, overwhelming, and error-prone. Instead,
11if you rebased onto each intermediate conflicting point, you'd break up the work
12into smaller pieces, and be able to run tests to make sure things were still OK.
13"""
2ea871e7
MF
14
15import argparse
77ac61ce
MF
16import os
17from pathlib import Path
2ea871e7
MF
18import subprocess
19import sys
58742bc5
MF
20from typing import List, Tuple, Union
21
22
23assert sys.version_info >= (3, 7), f'Need Python 3.7+, not {sys.version_info}'
2ea871e7
MF
24
25
58742bc5 26def git(args: List[str], **kwargs) -> subprocess.CompletedProcess:
2ea871e7
MF
27 """Run git."""
28 kwargs.setdefault('check', True)
29 kwargs.setdefault('capture_output', True)
30 kwargs.setdefault('encoding', 'utf-8')
58742bc5 31 # pylint: disable=subprocess-run-check
2ea871e7
MF
32 return subprocess.run(['git'] + args, **kwargs)
33
34
58742bc5 35def rebase(target: str) -> bool:
2ea871e7
MF
36 """Try to rebase onto |target|."""
37 try:
38 git(['rebase', target])
39 return True
40 except KeyboardInterrupt:
41 git(['rebase', '--abort'])
42 print('aborted')
43 sys.exit(1)
44 except:
45 git(['rebase', '--abort'])
46 return False
47
48
58742bc5
MF
49def rebase_bisect(lbranch: str,
50 rbranch: str,
51 behind: int,
52 leave_rebase: bool = False,
53 force_checkout: bool = False):
2ea871e7 54 """Try to rebase branch as close to |rbranch| as possible."""
58742bc5 55 def attempt(pos: int) -> bool:
2ea871e7 56 target = f'{rbranch}~{pos}'
58742bc5 57 print(f'Rebasing onto {target} ', end='')
2ea871e7 58 print('.', end='', flush=True)
58742bc5
MF
59 # Checking out these branches directly helps clobber orphaned files,
60 # but is usually unnessary, and can slow down the overall process.
61 if force_checkout:
62 git(['checkout', '-f', target])
2ea871e7 63 print('.', end='', flush=True)
58742bc5
MF
64 if force_checkout:
65 git(['checkout', '-f', lbranch])
2ea871e7
MF
66 print('. ', end='', flush=True)
67 ret = rebase(target)
68 print('OK' if ret else 'failed')
69 return ret
70
7732cedd
MF
71 # "pmin" is the latest branch position while "pmax" is where we're now.
72 pmin = 0
73 pmax = behind
2ea871e7 74 old_mid = None
7732cedd 75 first_fail = 0
2ea871e7 76 while True:
7732cedd
MF
77 mid = pmin + (pmax - pmin) // 2
78 if mid == old_mid or mid < pmin or mid >= pmax:
2ea871e7
MF
79 break
80 if attempt(mid):
7732cedd 81 pmax = mid
2ea871e7 82 else:
7732cedd
MF
83 first_fail = max(first_fail, mid)
84 pmin = mid
2ea871e7 85 old_mid = mid
7732cedd
MF
86
87 if pmin or pmax:
88 last_target = f'{rbranch}~{first_fail}'
f985e3fb
MF
89 result = git(['log', '-1', '--format=%s', last_target])
90 subject = result.stdout.strip()
7732cedd
MF
91 if leave_rebase:
92 print('Restarting', last_target)
93 result = git(['rebase', last_target], check=False)
94 print(result.stdout.strip())
f985e3fb 95 print(f'* Found first failure {last_target}: {subject}')
7732cedd
MF
96 else:
97 print('All caught up!')
2ea871e7
MF
98
99
58742bc5 100def get_ahead_behind(lbranch: str, rbranch: str) -> Tuple[int, int]:
2ea871e7 101 """Return number of commits |lbranch| is ahead & behind relative to |rbranch|."""
58742bc5 102 output = git(
b72cc58d
MF
103 ['rev-list', '--first-parent', '--left-right', '--count',
104 f'{lbranch}...{rbranch}']).stdout
2ea871e7
MF
105 return [int(x) for x in output.split()]
106
107
58742bc5
MF
108def get_tracking_branch(branch: str) -> Union[str, None]:
109 """Return branch that |branch| is tracking."""
2ea871e7
MF
110 merge = git(['config', '--local', f'branch.{branch}.merge']).stdout.strip()
111 if not merge:
112 return None
113
114 remote = git(['config', '--local', f'branch.{branch}.remote']).stdout.strip()
115 if remote:
116 if merge.startswith('refs/heads/'):
117 merge = merge[11:]
118 return f'{remote}/{merge}'
119 else:
120 return merge
121
122
58742bc5 123def get_local_branch() -> str:
2ea871e7
MF
124 """Return the name of the local checked out branch."""
125 return git(['branch', '--show-current']).stdout.strip()
126
127
58742bc5 128def get_parser() -> argparse.ArgumentParser:
2ea871e7 129 """Get CLI parser."""
58742bc5
MF
130 parser = argparse.ArgumentParser(
131 description=__doc__,
132 formatter_class=argparse.RawDescriptionHelpFormatter)
2ea871e7
MF
133 parser.add_argument(
134 '--skip-initial-rebase-latest', dest='initial_rebase',
135 action='store_false', default=True,
136 help='skip initial rebase attempt onto the latest branch')
7732cedd
MF
137 parser.add_argument(
138 '--leave-at-last-failed-rebase', dest='leave_rebase',
139 action='store_true', default=False,
140 help='leave tree state at last failing rebase')
58742bc5
MF
141 parser.add_argument(
142 '--checkout-before-rebase', dest='force_checkout',
143 action='store_true', default=False,
144 help='force checkout before rebasing to target (to cleanup orphans)')
2ea871e7
MF
145 parser.add_argument(
146 'branch', nargs='?',
147 help='branch to rebase onto')
148 return parser
149
150
58742bc5 151def main(argv: List[str]) -> int:
2ea871e7
MF
152 """The main entry point for scripts."""
153 parser = get_parser()
154 opts = parser.parse_args(argv)
155
77ac61ce
MF
156 try:
157 lbranch = get_local_branch()
158 except subprocess.CalledProcessError as e:
159 sys.exit(f'{os.path.basename(sys.argv[0])}: {Path.cwd()}:\n{e}\n{e.stderr.strip()}')
b72cc58d 160 print(f'Local branch resolved to "{lbranch}".')
2ea871e7
MF
161 if not lbranch:
162 print('Unable to resolve local branch', file=sys.stderr)
163 return 1
164
165 if opts.branch:
166 rbranch = opts.branch
167 else:
168 rbranch = get_tracking_branch(lbranch)
b72cc58d 169 print(f'Tracking branch resolved to "{rbranch}".')
2ea871e7
MF
170
171 ahead, behind = get_ahead_behind(lbranch, rbranch)
b72cc58d
MF
172 print(f'Branch is {ahead} commits ahead and {behind} commits behind.')
173 print('NB: Counts for the first parent in merge history, not all commits.')
2ea871e7
MF
174
175 if not behind:
176 print('Up-to-date!')
177 elif not ahead:
178 print('Fast forwarding ...')
179 git(['merge'])
180 else:
181 if opts.initial_rebase:
58742bc5
MF
182 print(f'Trying to rebase onto latest {rbranch} ... ',
183 end='', flush=True)
2ea871e7
MF
184 if rebase(rbranch):
185 print('OK!')
186 return 0
187 print('failed; falling back to bisect')
58742bc5
MF
188 rebase_bisect(lbranch, rbranch, behind, leave_rebase=opts.leave_rebase,
189 force_checkout=opts.force_checkout)
190
191 return 0
2ea871e7
MF
192
193
194if __name__ == '__main__':
195 sys.exit(main(sys.argv[1:]))