git_squash_branch_tree.py 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
  1. #!/usr/bin/env python3
  2. # Copyright 2024 The Chromium Authors. All rights reserved.
  3. # Use of this source code is governed by a BSD-style license that can be
  4. # found in the LICENSE file.
  5. '''
  6. Tool to squash all branches and their downstream branches. Useful to avoid
  7. potential conflicts during a git rebase-update with multiple stacked CLs.
  8. '''
  9. import argparse
  10. import collections
  11. import git_common as git
  12. import sys
  13. # Returns the list of branches that have diverged from their respective upstream
  14. # branch.
  15. def get_diverged_branches(tree):
  16. diverged_branches = []
  17. for branch, upstream_branch in tree.items():
  18. # If the merge base of a branch and its upstream is not equal to the
  19. # upstream, then it means that both branch diverged.
  20. upstream_branch_hash = git.hash_one(upstream_branch)
  21. merge_base_hash = git.hash_one(git.get_or_create_merge_base(branch))
  22. if upstream_branch_hash != merge_base_hash:
  23. diverged_branches.append(branch)
  24. return diverged_branches
  25. # Returns a dictionary that contains the hash of every branch before the
  26. # squashing started.
  27. def get_initial_hashes(tree):
  28. initial_hashes = {}
  29. for branch, upstream_branch in tree.items():
  30. initial_hashes[branch] = git.hash_one(branch)
  31. initial_hashes[upstream_branch] = git.hash_one(upstream_branch)
  32. return initial_hashes
  33. # Returns a dictionary that contains the downstream branches of every branch.
  34. def get_downstream_branches(tree):
  35. downstream_branches = collections.defaultdict(list)
  36. for branch, upstream_branch in tree.items():
  37. downstream_branches[upstream_branch].append(branch)
  38. return downstream_branches
  39. # Squash a branch, taking care to rebase the branch on top of the new commit
  40. # position of its upstream branch.
  41. def squash_branch(branch, initial_hashes):
  42. print('Squashing branch %s.' % branch)
  43. assert initial_hashes[branch] == git.hash_one(branch)
  44. upstream_branch = git.upstream(branch)
  45. old_upstream_branch = initial_hashes[upstream_branch]
  46. # Because the branch's upstream has potentially changed from squashing it,
  47. # the current branch is rebased on top of the new upstream.
  48. git.run('rebase', '--onto', upstream_branch, old_upstream_branch, branch,
  49. '--update-refs')
  50. # Now do the squashing.
  51. git.run('checkout', branch)
  52. git.squash_current_branch()
  53. # Squashes all branches that are part of the subtree starting at `branch`.
  54. def squash_subtree(branch, initial_hashes, downstream_branches):
  55. # The upstream default never has to be squashed (e.g. origin/main).
  56. if branch != git.upstream_default():
  57. squash_branch(branch, initial_hashes)
  58. # Recurse on downstream branches, if any.
  59. for downstream_branch in downstream_branches[branch]:
  60. squash_subtree(downstream_branch, initial_hashes, downstream_branches)
  61. def main(args=None):
  62. parser = argparse.ArgumentParser()
  63. parser.add_argument('--ignore-no-upstream',
  64. action='store_true',
  65. help='Allows proceeding if any branch has no '
  66. 'upstreams.')
  67. parser.add_argument('--branch',
  68. '-b',
  69. type=str,
  70. default=git.current_branch(),
  71. help='The name of the branch who\'s subtree must be '
  72. 'squashed. Defaults to the current branch.')
  73. opts = parser.parse_args(args)
  74. if git.is_dirty_git_tree('squash-branch-tree'):
  75. return 1
  76. branches_without_upstream, tree = git.get_branch_tree()
  77. if not opts.ignore_no_upstream and branches_without_upstream:
  78. print('Cannot use `git squash-branch-tree` since the following\n'
  79. 'branches don\'t have an upstream:')
  80. for branch in branches_without_upstream:
  81. print(f' - {branch}')
  82. print('Use --ignore-no-upstream to ignore this check and proceed.')
  83. return 1
  84. diverged_branches = get_diverged_branches(tree)
  85. if diverged_branches:
  86. print('Cannot use `git squash-branch-tree` since the following\n'
  87. 'branches have diverged from their upstream and could cause\n'
  88. 'conflicts:')
  89. for diverged_branch in diverged_branches:
  90. print(f' - {diverged_branch}')
  91. return 1
  92. # Before doing the squashing, save the current branch checked out branch so
  93. # we can go back to it at the end.
  94. return_branch = git.current_branch()
  95. initial_hashes = get_initial_hashes(tree)
  96. downstream_branches = get_downstream_branches(tree)
  97. squash_subtree(opts.branch, initial_hashes, downstream_branches)
  98. git.run('checkout', return_branch)
  99. return 0
  100. if __name__ == '__main__': # pragma: no cover
  101. try:
  102. sys.exit(main(sys.argv[1:]))
  103. except KeyboardInterrupt:
  104. sys.stderr.write('interrupted\n')
  105. sys.exit(1)