update.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. #!/usr/bin/env python3
  2. # coding: utf-8
  3. # Copyright 2015 The Chromium Authors. All rights reserved.
  4. # Use of this source code is governed by a BSD-style license that can be
  5. # found in the LICENSE file.
  6. from __future__ import print_function
  7. import argparse
  8. import os
  9. import pipes
  10. import re
  11. import subprocess
  12. import sys
  13. import tempfile
  14. import textwrap
  15. if sys.version_info[0] < 3:
  16. input = raw_input
  17. IS_WINDOWS = sys.platform.startswith('win')
  18. def SubprocessCheckCall0Or1(args):
  19. """Like subprocss.check_call(), but allows a return code of 1.
  20. Returns True if the subprocess exits with code 0, False if it exits with
  21. code 1, and re-raises the subprocess.check_call() exception otherwise.
  22. """
  23. try:
  24. subprocess.check_call(args, shell=IS_WINDOWS)
  25. except subprocess.CalledProcessError as e:
  26. if e.returncode != 1:
  27. raise
  28. return False
  29. return True
  30. def GitMergeBaseIsAncestor(ancestor, descendant):
  31. """Determines whether |ancestor| is an ancestor of |descendant|.
  32. """
  33. return SubprocessCheckCall0Or1(
  34. ['git', 'merge-base', '--is-ancestor', ancestor, descendant])
  35. def main(args):
  36. parser = argparse.ArgumentParser(
  37. description='Update the in-tree copy of an imported project')
  38. parser.add_argument(
  39. '--repository',
  40. default='https://chromium.googlesource.com/crashpad/crashpad',
  41. help='The imported project\'s remote fetch URL',
  42. metavar='URL')
  43. parser.add_argument(
  44. '--subtree',
  45. default='third_party/crashpad/crashpad',
  46. help='The imported project\'s location in this project\'s tree',
  47. metavar='PATH')
  48. parser.add_argument(
  49. '--update-to',
  50. default='FETCH_HEAD',
  51. help='What to update the imported project to',
  52. metavar='COMMITISH')
  53. parser.add_argument(
  54. '--fetch-ref',
  55. default='HEAD',
  56. help='The remote ref to fetch',
  57. metavar='REF')
  58. parser.add_argument(
  59. '--readme',
  60. help='The README.chromium file describing the imported project',
  61. metavar='FILE',
  62. dest='readme_path')
  63. parser.add_argument(
  64. '--exclude',
  65. default=['codereview.settings', 'infra'],
  66. action='append',
  67. help='Files to exclude from the imported copy',
  68. metavar='PATH')
  69. parsed = parser.parse_args(args)
  70. original_head = (
  71. subprocess.check_output(['git', 'rev-parse', 'HEAD'],
  72. shell=IS_WINDOWS).rstrip())
  73. original_head = original_head.decode('utf-8')
  74. # Read the README, because that’s what it’s for. Extract some things from
  75. # it, and save it to be able to update it later.
  76. readme_path = (parsed.readme_path or
  77. os.path.join(os.path.dirname(__file__ or '.'),
  78. 'README.chromium'))
  79. readme_content_old = open(readme_path, 'rb').read().decode('utf-8')
  80. project_name_match = re.search(
  81. r'^Name:\s+(.*)$', readme_content_old, re.MULTILINE)
  82. project_name = project_name_match.group(1)
  83. # Extract the original commit hash from the README.
  84. revision_match = re.search(r'^Revision:\s+([0-9a-fA-F]{40})($|\s)',
  85. readme_content_old,
  86. re.MULTILINE)
  87. revision_old = revision_match.group(1)
  88. subprocess.check_call(['git', 'fetch', parsed.repository, parsed.fetch_ref],
  89. shell=IS_WINDOWS)
  90. # Make sure that parsed.update_to is an ancestor of FETCH_HEAD, and
  91. # revision_old is an ancestor of parsed.update_to. This prevents the use of
  92. # hashes that are known to git but that don’t make sense in the context of
  93. # the update operation.
  94. if not GitMergeBaseIsAncestor(parsed.update_to, 'FETCH_HEAD'):
  95. raise Exception('update_to is not an ancestor of FETCH_HEAD',
  96. parsed.update_to,
  97. 'FETCH_HEAD')
  98. if not GitMergeBaseIsAncestor(revision_old, parsed.update_to):
  99. raise Exception('revision_old is not an ancestor of update_to',
  100. revision_old,
  101. parsed.update_to)
  102. # git-filter-branch needs a ref to update. It’s not enough to just tell it
  103. # to operate on a range of commits ending at parsed.update_to, because
  104. # parsed.update_to is a commit hash that can’t be updated to point to
  105. # anything else.
  106. subprocess.check_call(['git', 'update-ref', 'UPDATE_TO', parsed.update_to],
  107. shell=IS_WINDOWS)
  108. # Filter the range being updated over to exclude files that ought to be
  109. # missing. This points UPDATE_TO to the rewritten (filtered) version.
  110. # git-filter-branch insists on running from the top level of the working
  111. # tree.
  112. toplevel = subprocess.check_output(['git', 'rev-parse', '--show-toplevel'],
  113. shell=IS_WINDOWS).rstrip()
  114. subprocess.check_call(
  115. ['git',
  116. 'filter-branch',
  117. '--force',
  118. '--index-filter',
  119. 'git rm -r --cached --ignore-unmatch ' +
  120. ' '.join(pipes.quote(path) for path in parsed.exclude),
  121. revision_old + '..UPDATE_TO'],
  122. cwd=toplevel,
  123. shell=IS_WINDOWS)
  124. # git-filter-branch saved a copy of the original UPDATE_TO at
  125. # original/UPDATE_TO, but this isn’t useful because it refers to the same
  126. # thing as parsed.update_to, which is already known.
  127. subprocess.check_call(
  128. ['git', 'update-ref', '-d', 'refs/original/UPDATE_TO'],
  129. shell=IS_WINDOWS)
  130. filtered_update_range = revision_old + '..UPDATE_TO'
  131. unfiltered_update_range = revision_old + '..' + parsed.update_to
  132. # This cherry-picks each change in the window from the filtered view of the
  133. # upstream project into the current branch.
  134. assisted_cherry_pick = False
  135. try:
  136. if not SubprocessCheckCall0Or1(['git',
  137. 'cherry-pick',
  138. '--keep-redundant-commits',
  139. '--strategy=subtree',
  140. '-Xsubtree=' + parsed.subtree,
  141. '-x',
  142. filtered_update_range]):
  143. assisted_cherry_pick = True
  144. print("""
  145. Please fix the errors above and run "git cherry-pick --continue".
  146. Press Enter when "git cherry-pick" completes.
  147. You may use a new shell for this, or ^Z if job control is available.
  148. Press ^C to abort.
  149. """, file=sys.stderr)
  150. input()
  151. except:
  152. # ^C, signal, or something else.
  153. print('Aborting...', file=sys.stderr)
  154. subprocess.call(['git', 'cherry-pick', '--abort'], shell=IS_WINDOWS)
  155. raise
  156. # Get an abbreviated hash and subject line for each commit in the window,
  157. # sorted in chronological order. Use the unfiltered view so that the commit
  158. # hashes are recognizable.
  159. log_lines = subprocess.check_output(
  160. ['git',
  161. '-c',
  162. 'core.abbrev=12',
  163. 'log',
  164. '--abbrev-commit',
  165. '--pretty=oneline',
  166. '--reverse',
  167. unfiltered_update_range],
  168. shell=IS_WINDOWS).decode('utf-8').splitlines(False)
  169. if assisted_cherry_pick:
  170. # If the user had to help, count the number of cherry-picked commits,
  171. # expecting it to match.
  172. cherry_picked_commits = int(subprocess.check_output(
  173. ['git', 'rev-list', '--count', original_head + '..HEAD'],
  174. shell=IS_WINDOWS))
  175. if cherry_picked_commits != len(log_lines):
  176. print('Something smells fishy, aborting anyway...', file=sys.stderr)
  177. subprocess.call(['git', 'cherry-pick', '--abort'], shell=IS_WINDOWS)
  178. raise Exception('not all commits were cherry-picked',
  179. len(log_lines),
  180. cherry_picked_commits)
  181. # Make a nice commit message. Start with the full commit hash.
  182. revision_new = subprocess.check_output(
  183. ['git', 'rev-parse', parsed.update_to],
  184. shell=IS_WINDOWS).decode('utf-8').rstrip()
  185. new_message = u'Update ' + project_name + ' to ' + revision_new + '\n\n'
  186. # Wrap everything to 72 characters, with a hanging indent.
  187. wrapper = textwrap.TextWrapper(width=72, subsequent_indent = ' ' * 13)
  188. for line in log_lines:
  189. # Strip trailing periods from subjects.
  190. if line.endswith('.'):
  191. line = line[:-1]
  192. # If any subjects have what look like commit hashes in them, truncate
  193. # them to 12 characters.
  194. line = re.sub(r'(\s)([0-9a-fA-F]{12})([0-9a-fA-F]{28})($|\s)',
  195. r'\1\2\4',
  196. line)
  197. new_message += '\n'.join(wrapper.wrap(line)) + '\n'
  198. # Update the README with the new hash.
  199. readme_content_new = re.sub(
  200. r'^(Revision:\s+)([0-9a-fA-F]{40})($|\s.*?$)',
  201. r'\g<1>' + revision_new,
  202. readme_content_old,
  203. 1,
  204. re.MULTILINE)
  205. # If the in-tree copy has no changes relative to the upstream, clear the
  206. # “Local Modifications” section of the README.
  207. has_local_modifications = True
  208. if SubprocessCheckCall0Or1(['git',
  209. 'diff-tree',
  210. '--quiet',
  211. 'UPDATE_TO',
  212. 'HEAD:' + parsed.subtree]):
  213. has_local_modifications = False
  214. if not parsed.exclude:
  215. modifications = 'None.\n'
  216. elif len(parsed.exclude) == 1:
  217. modifications = (
  218. ' - %s has been excluded.\n' % parsed.exclude[0])
  219. else:
  220. modifications = (
  221. ' - The following files have been excluded:\n')
  222. for excluded in sorted(parsed.exclude):
  223. modifications += ' - ' + excluded + '\n'
  224. readme_content_new = re.sub(r'\nLocal Modifications:\n.*$',
  225. '\nLocal Modifications:\n' + modifications,
  226. readme_content_new,
  227. 1,
  228. re.DOTALL)
  229. # The UPDATE_TO ref is no longer useful.
  230. subprocess.check_call(['git', 'update-ref', '-d', 'UPDATE_TO'],
  231. shell=IS_WINDOWS)
  232. # This soft-reset causes all of the cherry-picks to show up as staged, which
  233. # will have the effect of squashing them along with the README update when
  234. # committed below.
  235. subprocess.check_call(['git', 'reset', '--soft', original_head],
  236. shell=IS_WINDOWS)
  237. # Write the new README.
  238. open(readme_path, 'wb').write(readme_content_new.encode('utf-8'))
  239. # Commit everything.
  240. subprocess.check_call(['git', 'add', readme_path], shell=IS_WINDOWS)
  241. try:
  242. commit_message_name = None
  243. with tempfile.NamedTemporaryFile(mode='wb',
  244. delete=False) as commit_message_f:
  245. commit_message_name = commit_message_f.name
  246. commit_message_f.write(new_message.encode('utf-8'))
  247. subprocess.check_call(['git',
  248. 'commit', '--file=' + commit_message_name],
  249. shell=IS_WINDOWS)
  250. finally:
  251. if commit_message_name:
  252. os.unlink(commit_message_name)
  253. if has_local_modifications:
  254. print('Remember to check the Local Modifications section in ' +
  255. readme_path, file=sys.stderr)
  256. return 0
  257. if __name__ == '__main__':
  258. sys.exit(main(sys.argv[1:]))