| #!/usr/bin/env python |
| # Copyright 2014 The Chromium Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """ |
| Tool to update all branches to have the latest changes from their upstreams. |
| """ |
| |
| from __future__ import print_function |
| |
| import argparse |
| import collections |
| import logging |
| import sys |
| import textwrap |
| import os |
| |
| from fnmatch import fnmatch |
| from pprint import pformat |
| |
| import git_common as git |
| |
| |
| STARTING_BRANCH_KEY = 'depot-tools.rebase-update.starting-branch' |
| STARTING_WORKDIR_KEY = 'depot-tools.rebase-update.starting-workdir' |
| |
| |
| def find_return_branch_workdir(): |
| """Finds the branch and working directory which we should return to after |
| rebase-update completes. |
| |
| These values may persist across multiple invocations of rebase-update, if |
| rebase-update runs into a conflict mid-way. |
| """ |
| return_branch = git.get_config(STARTING_BRANCH_KEY) |
| workdir = git.get_config(STARTING_WORKDIR_KEY) |
| if not return_branch: |
| workdir = os.getcwd() |
| git.set_config(STARTING_WORKDIR_KEY, workdir) |
| return_branch = git.current_branch() |
| if return_branch != 'HEAD': |
| git.set_config(STARTING_BRANCH_KEY, return_branch) |
| |
| return return_branch, workdir |
| |
| |
| def fetch_remotes(branch_tree): |
| """Fetches all remotes which are needed to update |branch_tree|.""" |
| fetch_tags = False |
| remotes = set() |
| tag_set = git.tags() |
| fetchspec_map = {} |
| all_fetchspec_configs = git.get_config_regexp(r'^remote\..*\.fetch') |
| for fetchspec_config in all_fetchspec_configs: |
| key, _, fetchspec = fetchspec_config.partition(' ') |
| dest_spec = fetchspec.partition(':')[2] |
| remote_name = key.split('.')[1] |
| fetchspec_map[dest_spec] = remote_name |
| for parent in branch_tree.values(): |
| if parent in tag_set: |
| fetch_tags = True |
| else: |
| full_ref = git.run('rev-parse', '--symbolic-full-name', parent) |
| for dest_spec, remote_name in fetchspec_map.items(): |
| if fnmatch(full_ref, dest_spec): |
| remotes.add(remote_name) |
| break |
| |
| fetch_args = [] |
| if fetch_tags: |
| # Need to fetch all because we don't know what remote the tag comes from :( |
| # TODO(iannucci): assert that the tags are in the remote fetch refspec |
| fetch_args = ['--all'] |
| else: |
| fetch_args.append('--multiple') |
| fetch_args.extend(remotes) |
| # TODO(iannucci): Should we fetch git-svn? |
| |
| if not fetch_args: # pragma: no cover |
| print('Nothing to fetch.') |
| else: |
| git.run_with_stderr('fetch', *fetch_args, stdout=sys.stdout, |
| stderr=sys.stderr) |
| |
| |
| def remove_empty_branches(branch_tree): |
| tag_set = git.tags() |
| ensure_root_checkout = git.once(lambda: git.run('checkout', git.root())) |
| |
| deletions = {} |
| reparents = {} |
| downstreams = collections.defaultdict(list) |
| for branch, parent in git.topo_iter(branch_tree, top_down=False): |
| if git.is_dormant(branch): |
| continue |
| |
| downstreams[parent].append(branch) |
| |
| # If branch and parent have the same tree, then branch has to be marked |
| # for deletion and its children and grand-children reparented to parent. |
| if git.hash_one(branch+":") == git.hash_one(parent+":"): |
| ensure_root_checkout() |
| |
| logging.debug('branch %s merged to %s', branch, parent) |
| |
| # Mark branch for deletion while remembering the ordering, then add all |
| # its children as grand-children of its parent and record reparenting |
| # information if necessary. |
| deletions[branch] = len(deletions) |
| |
| for down in downstreams[branch]: |
| if down in deletions: |
| continue |
| |
| # Record the new and old parent for down, or update such a record |
| # if it already exists. Keep track of the ordering so that reparenting |
| # happen in topological order. |
| downstreams[parent].append(down) |
| if down not in reparents: |
| reparents[down] = (len(reparents), parent, branch) |
| else: |
| order, _, old_parent = reparents[down] |
| reparents[down] = (order, parent, old_parent) |
| |
| # Apply all reparenting recorded, in order. |
| for branch, value in sorted(reparents.items(), key=lambda x:x[1][0]): |
| _, parent, old_parent = value |
| if parent in tag_set: |
| git.set_branch_config(branch, 'remote', '.') |
| git.set_branch_config(branch, 'merge', 'refs/tags/%s' % parent) |
| print('Reparented %s to track %s [tag] (was tracking %s)' % |
| (branch, parent, old_parent)) |
| else: |
| git.run('branch', '--set-upstream-to', parent, branch) |
| print('Reparented %s to track %s (was tracking %s)' % (branch, parent, |
| old_parent)) |
| |
| # Apply all deletions recorded, in order. |
| for branch, _ in sorted(deletions.items(), key=lambda x: x[1]): |
| print(git.run('branch', '-d', branch)) |
| |
| |
| def rebase_branch(branch, parent, start_hash): |
| logging.debug('considering %s(%s) -> %s(%s) : %s', |
| branch, git.hash_one(branch), parent, git.hash_one(parent), |
| start_hash) |
| |
| # If parent has FROZEN commits, don't base branch on top of them. Instead, |
| # base branch on top of whatever commit is before them. |
| back_ups = 0 |
| orig_parent = parent |
| while git.run('log', '-n1', '--format=%s', |
| parent, '--').startswith(git.FREEZE): |
| back_ups += 1 |
| parent = git.run('rev-parse', parent+'~') |
| |
| if back_ups: |
| logging.debug('Backed parent up by %d from %s to %s', |
| back_ups, orig_parent, parent) |
| |
| if git.hash_one(parent) != start_hash: |
| # Try a plain rebase first |
| print('Rebasing:', branch) |
| rebase_ret = git.rebase(parent, start_hash, branch, abort=True) |
| if not rebase_ret.success: |
| # TODO(iannucci): Find collapsible branches in a smarter way? |
| print("Failed! Attempting to squash", branch, "...", end=' ') |
| sys.stdout.flush() |
| squash_branch = branch+"_squash_attempt" |
| git.run('checkout', '-b', squash_branch) |
| git.squash_current_branch(merge_base=start_hash) |
| |
| # Try to rebase the branch_squash_attempt branch to see if it's empty. |
| squash_ret = git.rebase(parent, start_hash, squash_branch, abort=True) |
| empty_rebase = git.hash_one(squash_branch) == git.hash_one(parent) |
| git.run('checkout', branch) |
| git.run('branch', '-D', squash_branch) |
| if squash_ret.success and empty_rebase: |
| print('Success!') |
| git.squash_current_branch(merge_base=start_hash) |
| git.rebase(parent, start_hash, branch) |
| else: |
| print("Failed!") |
| print() |
| |
| # rebase and leave in mid-rebase state. |
| # This second rebase attempt should always fail in the same |
| # way that the first one does. If it magically succeeds then |
| # something very strange has happened. |
| second_rebase_ret = git.rebase(parent, start_hash, branch) |
| if second_rebase_ret.success: # pragma: no cover |
| print("Second rebase succeeded unexpectedly!") |
| print("Please see: http://crbug.com/425696") |
| print("First rebased failed with:") |
| print(rebase_ret.stderr) |
| else: |
| print("Here's what git-rebase (squashed) had to say:") |
| print() |
| print(squash_ret.stdout) |
| print(squash_ret.stderr) |
| print(textwrap.dedent("""\ |
| Squashing failed. You probably have a real merge conflict. |
| |
| Your working copy is in mid-rebase. Either: |
| * completely resolve like a normal git-rebase; OR |
| * abort the rebase and mark this branch as dormant: |
| git config branch.%s.dormant true |
| |
| And then run `git rebase-update` again to resume. |
| """ % branch)) |
| return False |
| else: |
| print('%s up-to-date' % branch) |
| |
| git.remove_merge_base(branch) |
| git.get_or_create_merge_base(branch) |
| |
| return True |
| |
| |
| def main(args=None): |
| parser = argparse.ArgumentParser() |
| parser.add_argument('--verbose', '-v', action='store_true') |
| parser.add_argument('--keep-going', '-k', action='store_true', |
| help='Keep processing past failed rebases.') |
| parser.add_argument('--no_fetch', '--no-fetch', '-n', |
| action='store_true', |
| help='Skip fetching remotes.') |
| parser.add_argument( |
| '--current', action='store_true', help='Only rebase the current branch.') |
| parser.add_argument('branches', nargs='*', |
| help='Branches to be rebased. All branches are assumed ' |
| 'if none specified.') |
| parser.add_argument('--keep-empty', '-e', action='store_true', |
| help='Do not automatically delete empty branches.') |
| opts = parser.parse_args(args) |
| |
| if opts.verbose: # pragma: no cover |
| logging.getLogger().setLevel(logging.DEBUG) |
| |
| # TODO(iannucci): snapshot all branches somehow, so we can implement |
| # `git rebase-update --undo`. |
| # * Perhaps just copy packed-refs + refs/ + logs/ to the side? |
| # * commit them to a secret ref? |
| # * Then we could view a summary of each run as a |
| # `diff --stat` on that secret ref. |
| |
| if git.in_rebase(): |
| # TODO(iannucci): Be able to resume rebase with flags like --continue, |
| # etc. |
| print('Rebase in progress. Please complete the rebase before running ' |
| '`git rebase-update`.') |
| return 1 |
| |
| return_branch, return_workdir = find_return_branch_workdir() |
| os.chdir(git.run('rev-parse', '--show-toplevel')) |
| |
| if git.current_branch() == 'HEAD': |
| if git.run('status', '--porcelain'): |
| print('Cannot rebase-update with detached head + uncommitted changes.') |
| return 1 |
| else: |
| git.freeze() # just in case there are any local changes. |
| |
| branches_to_rebase = set(opts.branches) |
| if opts.current: |
| branches_to_rebase.add(git.current_branch()) |
| |
| skipped, branch_tree = git.get_branch_tree() |
| if branches_to_rebase: |
| skipped = set(skipped).intersection(branches_to_rebase) |
| for branch in skipped: |
| print('Skipping %s: No upstream specified' % branch) |
| |
| if not opts.no_fetch: |
| fetch_remotes(branch_tree) |
| |
| merge_base = {} |
| for branch, parent in branch_tree.items(): |
| merge_base[branch] = git.get_or_create_merge_base(branch, parent) |
| |
| logging.debug('branch_tree: %s' % pformat(branch_tree)) |
| logging.debug('merge_base: %s' % pformat(merge_base)) |
| |
| retcode = 0 |
| unrebased_branches = [] |
| # Rebase each branch starting with the root-most branches and working |
| # towards the leaves. |
| for branch, parent in git.topo_iter(branch_tree): |
| # Only rebase specified branches, unless none specified. |
| if branches_to_rebase and branch not in branches_to_rebase: |
| continue |
| if git.is_dormant(branch): |
| print('Skipping dormant branch', branch) |
| else: |
| ret = rebase_branch(branch, parent, merge_base[branch]) |
| if not ret: |
| retcode = 1 |
| |
| if opts.keep_going: |
| print('--keep-going set, continuing with next branch.') |
| unrebased_branches.append(branch) |
| if git.in_rebase(): |
| git.run_with_retcode('rebase', '--abort') |
| if git.in_rebase(): # pragma: no cover |
| print('Failed to abort rebase. Something is really wrong.') |
| break |
| else: |
| break |
| |
| if unrebased_branches: |
| print() |
| print('The following branches could not be cleanly rebased:') |
| for branch in unrebased_branches: |
| print(' %s' % branch) |
| |
| if not retcode: |
| if not opts.keep_empty: |
| remove_empty_branches(branch_tree) |
| |
| # return_branch may not be there any more. |
| if return_branch in git.branches(): |
| git.run('checkout', return_branch) |
| git.thaw() |
| else: |
| root_branch = git.root() |
| if return_branch != 'HEAD': |
| print("%s was merged with its parent, checking out %s instead." % |
| (git.unicode_repr(return_branch), git.unicode_repr(root_branch))) |
| git.run('checkout', root_branch) |
| |
| # return_workdir may also not be there any more. |
| if return_workdir: |
| try: |
| os.chdir(return_workdir) |
| except OSError as e: |
| print( |
| "Unable to return to original workdir %r: %s" % (return_workdir, e)) |
| git.set_config(STARTING_BRANCH_KEY, '') |
| git.set_config(STARTING_WORKDIR_KEY, '') |
| |
| return retcode |
| |
| |
| if __name__ == '__main__': # pragma: no cover |
| try: |
| sys.exit(main()) |
| except KeyboardInterrupt: |
| sys.stderr.write('interrupted\n') |
| sys.exit(1) |