]>
Commit | Line | Data |
---|---|---|
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 | ||
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 | """ | |
2ea871e7 MF |
14 | |
15 | import argparse | |
77ac61ce MF |
16 | import os |
17 | from pathlib import Path | |
2ea871e7 MF |
18 | import subprocess |
19 | import sys | |
58742bc5 MF |
20 | from typing import List, Tuple, Union |
21 | ||
22 | ||
23 | assert sys.version_info >= (3, 7), f'Need Python 3.7+, not {sys.version_info}' | |
2ea871e7 MF |
24 | |
25 | ||
58742bc5 | 26 | def 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 | 35 | def 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 |
49 | def 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 | 100 | def 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 |
108 | def 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 | 123 | def 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 | 128 | def 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 | 151 | def 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 | ||
194 | if __name__ == '__main__': | |
195 | sys.exit(main(sys.argv[1:])) |