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}'
90 print('Restarting', last_target
)
91 result
= git(['rebase', last_target
], check
=False)
92 print(result
.stdout
.strip())
94 print('Found first failure', last_target
)
96 print('All caught up!')
99 def get_ahead_behind(lbranch
: str, rbranch
: str) -> Tuple
[int, int]:
100 """Return number of commits |lbranch| is ahead & behind relative to |rbranch|."""
102 ['rev-list', '--first-parent', '--left-right', '--count',
103 f
'{lbranch}...{rbranch}']).stdout
104 return [int(x
) for x
in output
.split()]
107 def get_tracking_branch(branch
: str) -> Union
[str, None]:
108 """Return branch that |branch| is tracking."""
109 merge
= git(['config', '--local', f
'branch.{branch}.merge']).stdout
.strip()
113 remote
= git(['config', '--local', f
'branch.{branch}.remote']).stdout
.strip()
115 if merge
.startswith('refs/heads/'):
117 return f
'{remote}/{merge}'
122 def get_local_branch() -> str:
123 """Return the name of the local checked out branch."""
124 return git(['branch', '--show-current']).stdout
.strip()
127 def get_parser() -> argparse
.ArgumentParser
:
128 """Get CLI parser."""
129 parser
= argparse
.ArgumentParser(
131 formatter_class
=argparse
.RawDescriptionHelpFormatter
)
133 '--skip-initial-rebase-latest', dest
='initial_rebase',
134 action
='store_false', default
=True,
135 help='skip initial rebase attempt onto the latest branch')
137 '--leave-at-last-failed-rebase', dest
='leave_rebase',
138 action
='store_true', default
=False,
139 help='leave tree state at last failing rebase')
141 '--checkout-before-rebase', dest
='force_checkout',
142 action
='store_true', default
=False,
143 help='force checkout before rebasing to target (to cleanup orphans)')
146 help='branch to rebase onto')
150 def main(argv
: List
[str]) -> int:
151 """The main entry point for scripts."""
152 parser
= get_parser()
153 opts
= parser
.parse_args(argv
)
156 lbranch
= get_local_branch()
157 except subprocess
.CalledProcessError
as e
:
158 sys
.exit(f
'{os.path.basename(sys.argv[0])}: {Path.cwd()}:\n{e}\n{e.stderr.strip()}')
159 print(f
'Local branch resolved to "{lbranch}".')
161 print('Unable to resolve local branch', file=sys
.stderr
)
165 rbranch
= opts
.branch
167 rbranch
= get_tracking_branch(lbranch
)
168 print(f
'Tracking branch resolved to "{rbranch}".')
170 ahead
, behind
= get_ahead_behind(lbranch
, rbranch
)
171 print(f
'Branch is {ahead} commits ahead and {behind} commits behind.')
172 print('NB: Counts for the first parent in merge history, not all commits.')
177 print('Fast forwarding ...')
180 if opts
.initial_rebase
:
181 print(f
'Trying to rebase onto latest {rbranch} ... ',
186 print('failed; falling back to bisect')
187 rebase_bisect(lbranch
, rbranch
, behind
, leave_rebase
=opts
.leave_rebase
,
188 force_checkout
=opts
.force_checkout
)
193 if __name__
== '__main__':
194 sys
.exit(main(sys
.argv
[1:]))