]>
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 | |
16 | import subprocess | |
17 | import sys | |
58742bc5 MF |
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}' | |
2ea871e7 MF |
22 | |
23 | ||
58742bc5 | 24 | def 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 | 33 | def 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 |
47 | def 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 | 97 | def 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 |
105 | def 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 | 120 | def 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 | 125 | def 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 | 148 | def 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 | ||
188 | if __name__ == '__main__': | |
189 | sys.exit(main(sys.argv[1:])) |