2 # Distributed under the terms of the GNU General Public License v2 or later.
4 """Helper to automatically rebase onto latest commit possible.
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.
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.
17 from pathlib
import Path
20 from typing
import List
, Tuple
, Union
23 assert sys
.version_info
>= (3, 7), f
'Need Python 3.7+, not {sys.version_info}'
26 def git(args
: List
[str], **kwargs
) -> subprocess
.CompletedProcess
:
28 kwargs
.setdefault('check', True)
29 kwargs
.setdefault('capture_output', True)
30 kwargs
.setdefault('encoding', 'utf-8')
31 # pylint: disable=subprocess-run-check
32 return subprocess
.run(['git'] + args
, **kwargs
)
35 def rebase(target
: str) -> bool:
36 """Try to rebase onto |target|."""
38 git(['rebase', target
])
40 except KeyboardInterrupt:
41 git(['rebase', '--abort'])
45 git(['rebase', '--abort'])
49 def rebase_bisect(lbranch
: str,
52 leave_rebase
: bool = False,
53 force_checkout
: bool = False):
54 """Try to rebase branch as close to |rbranch| as possible."""
55 def attempt(pos
: int) -> bool:
56 target
= f
'{rbranch}~{pos}'
57 print(f
'Rebasing onto {target} ', end
='')
58 print('.', end
='', flush
=True)
59 # Checking out these branches directly helps clobber orphaned files,
60 # but is usually unnessary, and can slow down the overall process.
62 git(['checkout', '-f', target
])
63 print('.', end
='', flush
=True)
65 git(['checkout', '-f', lbranch
])
66 print('. ', end
='', flush
=True)
68 print('OK' if ret
else 'failed')
71 # "pmin" is the latest branch position while "pmax" is where we're now.
77 mid
= pmin
+ (pmax
- pmin
) // 2
78 if mid
== old_mid
or mid
< pmin
or mid
>= pmax
:
83 first_fail
= max(first_fail
, mid
)
88 last_target
= f
'{rbranch}~{first_fail}'
89 result
= git(['log', '-1', '--format=%s', last_target
])
90 subject
= result
.stdout
.strip()
92 print('Restarting', last_target
)
93 result
= git(['rebase', last_target
], check
=False)
94 print(result
.stdout
.strip())
95 print(f
'* Found first failure {last_target}: {subject}')
97 print('All caught up!')
100 def get_ahead_behind(lbranch
: str, rbranch
: str) -> Tuple
[int, int]:
101 """Return number of commits |lbranch| is ahead & behind relative to |rbranch|."""
103 ['rev-list', '--first-parent', '--left-right', '--count',
104 f
'{lbranch}...{rbranch}']).stdout
105 return [int(x
) for x
in output
.split()]
108 def get_tracking_branch(branch
: str) -> Union
[str, None]:
109 """Return branch that |branch| is tracking."""
110 merge
= git(['config', '--local', f
'branch.{branch}.merge']).stdout
.strip()
114 remote
= git(['config', '--local', f
'branch.{branch}.remote']).stdout
.strip()
116 if merge
.startswith('refs/heads/'):
118 return f
'{remote}/{merge}'
123 def get_local_branch() -> str:
124 """Return the name of the local checked out branch."""
125 return git(['branch', '--show-current']).stdout
.strip()
128 def get_parser() -> argparse
.ArgumentParser
:
129 """Get CLI parser."""
130 parser
= argparse
.ArgumentParser(
132 formatter_class
=argparse
.RawDescriptionHelpFormatter
)
134 '--skip-initial-rebase-latest', dest
='initial_rebase',
135 action
='store_false', default
=True,
136 help='skip initial rebase attempt onto the latest branch')
138 '--leave-at-last-failed-rebase', dest
='leave_rebase',
139 action
='store_true', default
=False,
140 help='leave tree state at last failing rebase')
142 '--checkout-before-rebase', dest
='force_checkout',
143 action
='store_true', default
=False,
144 help='force checkout before rebasing to target (to cleanup orphans)')
147 help='branch to rebase onto')
151 def main(argv
: List
[str]) -> int:
152 """The main entry point for scripts."""
153 parser
= get_parser()
154 opts
= parser
.parse_args(argv
)
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()}')
160 print(f
'Local branch resolved to "{lbranch}".')
162 print('Unable to resolve local branch', file=sys
.stderr
)
166 rbranch
= opts
.branch
168 rbranch
= get_tracking_branch(lbranch
)
169 print(f
'Tracking branch resolved to "{rbranch}".')
171 ahead
, behind
= get_ahead_behind(lbranch
, rbranch
)
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.')
178 print('Fast forwarding ...')
181 if opts
.initial_rebase
:
182 print(f
'Trying to rebase onto latest {rbranch} ... ',
187 print('failed; falling back to bisect')
188 rebase_bisect(lbranch
, rbranch
, behind
, leave_rebase
=opts
.leave_rebase
,
189 force_checkout
=opts
.force_checkout
)
194 if __name__
== '__main__':
195 sys
.exit(main(sys
.argv
[1:]))