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