]> git.wh0rd.org - home.git/blame - .bin/git-rb-catchup
git-rb-catchup: fix merge counts
[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
16import subprocess
17import sys
58742bc5
MF
18from typing import List, Tuple, Union
19
20
21assert sys.version_info >= (3, 7), f'Need Python 3.7+, not {sys.version_info}'
2ea871e7
MF
22
23
58742bc5 24def git(args: List[str], **kwargs) -> subprocess.CompletedProcess:
2ea871e7
MF
25 """Run git."""
26 kwargs.setdefault('check', True)
27 kwargs.setdefault('capture_output', True)
28 kwargs.setdefault('encoding', 'utf-8')
58742bc5 29 # pylint: disable=subprocess-run-check
2ea871e7
MF
30 return subprocess.run(['git'] + args, **kwargs)
31
32
58742bc5 33def rebase(target: str) -> bool:
2ea871e7
MF
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
58742bc5
MF
47def rebase_bisect(lbranch: str,
48 rbranch: str,
49 behind: int,
50 leave_rebase: bool = False,
51 force_checkout: bool = False):
2ea871e7 52 """Try to rebase branch as close to |rbranch| as possible."""
58742bc5 53 def attempt(pos: int) -> bool:
2ea871e7 54 target = f'{rbranch}~{pos}'
58742bc5 55 print(f'Rebasing onto {target} ', end='')
2ea871e7 56 print('.', end='', flush=True)
58742bc5
MF
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])
2ea871e7 61 print('.', end='', flush=True)
58742bc5
MF
62 if force_checkout:
63 git(['checkout', '-f', lbranch])
2ea871e7
MF
64 print('. ', end='', flush=True)
65 ret = rebase(target)
66 print('OK' if ret else 'failed')
67 return ret
68
7732cedd
MF
69 # "pmin" is the latest branch position while "pmax" is where we're now.
70 pmin = 0
71 pmax = behind
2ea871e7 72 old_mid = None
7732cedd 73 first_fail = 0
2ea871e7 74 while True:
7732cedd
MF
75 mid = pmin + (pmax - pmin) // 2
76 if mid == old_mid or mid < pmin or mid >= pmax:
2ea871e7
MF
77 break
78 if attempt(mid):
7732cedd 79 pmax = mid
2ea871e7 80 else:
7732cedd
MF
81 first_fail = max(first_fail, mid)
82 pmin = mid
2ea871e7 83 old_mid = mid
7732cedd
MF
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!')
2ea871e7
MF
95
96
58742bc5 97def get_ahead_behind(lbranch: str, rbranch: str) -> Tuple[int, int]:
2ea871e7 98 """Return number of commits |lbranch| is ahead & behind relative to |rbranch|."""
58742bc5 99 output = git(
b72cc58d
MF
100 ['rev-list', '--first-parent', '--left-right', '--count',
101 f'{lbranch}...{rbranch}']).stdout
2ea871e7
MF
102 return [int(x) for x in output.split()]
103
104
58742bc5
MF
105def get_tracking_branch(branch: str) -> Union[str, None]:
106 """Return branch that |branch| is tracking."""
2ea871e7
MF
107 merge = git(['config', '--local', f'branch.{branch}.merge']).stdout.strip()
108 if not merge:
109 return None
110
111 remote = git(['config', '--local', f'branch.{branch}.remote']).stdout.strip()
112 if remote:
113 if merge.startswith('refs/heads/'):
114 merge = merge[11:]
115 return f'{remote}/{merge}'
116 else:
117 return merge
118
119
58742bc5 120def get_local_branch() -> str:
2ea871e7
MF
121 """Return the name of the local checked out branch."""
122 return git(['branch', '--show-current']).stdout.strip()
123
124
58742bc5 125def get_parser() -> argparse.ArgumentParser:
2ea871e7 126 """Get CLI parser."""
58742bc5
MF
127 parser = argparse.ArgumentParser(
128 description=__doc__,
129 formatter_class=argparse.RawDescriptionHelpFormatter)
2ea871e7
MF
130 parser.add_argument(
131 '--skip-initial-rebase-latest', dest='initial_rebase',
132 action='store_false', default=True,
133 help='skip initial rebase attempt onto the latest branch')
7732cedd
MF
134 parser.add_argument(
135 '--leave-at-last-failed-rebase', dest='leave_rebase',
136 action='store_true', default=False,
137 help='leave tree state at last failing rebase')
58742bc5
MF
138 parser.add_argument(
139 '--checkout-before-rebase', dest='force_checkout',
140 action='store_true', default=False,
141 help='force checkout before rebasing to target (to cleanup orphans)')
2ea871e7
MF
142 parser.add_argument(
143 'branch', nargs='?',
144 help='branch to rebase onto')
145 return parser
146
147
58742bc5 148def main(argv: List[str]) -> int:
2ea871e7
MF
149 """The main entry point for scripts."""
150 parser = get_parser()
151 opts = parser.parse_args(argv)
152
153 lbranch = get_local_branch()
b72cc58d 154 print(f'Local branch resolved to "{lbranch}".')
2ea871e7
MF
155 if not lbranch:
156 print('Unable to resolve local branch', file=sys.stderr)
157 return 1
158
159 if opts.branch:
160 rbranch = opts.branch
161 else:
162 rbranch = get_tracking_branch(lbranch)
b72cc58d 163 print(f'Tracking branch resolved to "{rbranch}".')
2ea871e7
MF
164
165 ahead, behind = get_ahead_behind(lbranch, rbranch)
b72cc58d
MF
166 print(f'Branch is {ahead} commits ahead and {behind} commits behind.')
167 print('NB: Counts for the first parent in merge history, not all commits.')
2ea871e7
MF
168
169 if not behind:
170 print('Up-to-date!')
171 elif not ahead:
172 print('Fast forwarding ...')
173 git(['merge'])
174 else:
175 if opts.initial_rebase:
58742bc5
MF
176 print(f'Trying to rebase onto latest {rbranch} ... ',
177 end='', flush=True)
2ea871e7
MF
178 if rebase(rbranch):
179 print('OK!')
180 return 0
181 print('failed; falling back to bisect')
58742bc5
MF
182 rebase_bisect(lbranch, rbranch, behind, leave_rebase=opts.leave_rebase,
183 force_checkout=opts.force_checkout)
184
185 return 0
2ea871e7
MF
186
187
188if __name__ == '__main__':
189 sys.exit(main(sys.argv[1:]))