roll_dep_svn.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. #!/usr/bin/env python
  2. # Copyright (c) 2014 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. """Rolls a git-svn dependency.
  6. It takes the path to a dep and a git commit hash or svn revision, and updates
  7. the parent repo's DEPS file with the corresponding git commit hash.
  8. Sample invocation:
  9. [chromium/src]$ roll-dep-svn third_party/WebKit 12345
  10. After the script completes, the DEPS file will be dirty with the new revision.
  11. The user can then:
  12. $ git add DEPS
  13. $ git commit
  14. """
  15. from __future__ import print_function
  16. import ast
  17. import optparse
  18. import os
  19. import re
  20. import sys
  21. from itertools import izip
  22. from subprocess import check_output, Popen, PIPE
  23. from textwrap import dedent
  24. SHA1_RE = re.compile('^[a-fA-F0-9]{40}$')
  25. GIT_SVN_ID_RE = re.compile('^git-svn-id: .*@([0-9]+) .*$')
  26. ROLL_DESCRIPTION_STR = (
  27. '''Roll %(dep_path)s %(before_rev)s:%(after_rev)s%(svn_range)s
  28. Summary of changes available at:
  29. %(revlog_url)s
  30. ''')
  31. def shorten_dep_path(dep):
  32. """Shorten the given dep path if necessary."""
  33. while len(dep) > 31:
  34. dep = '.../' + dep.lstrip('./').partition('/')[2]
  35. return dep
  36. def posix_path(path):
  37. """Convert a possibly-Windows path to a posix-style path."""
  38. (_, path) = os.path.splitdrive(path)
  39. return path.replace(os.sep, '/')
  40. def platform_path(path):
  41. """Convert a path to the native path format of the host OS."""
  42. return path.replace('/', os.sep)
  43. def find_gclient_root():
  44. """Find the directory containing the .gclient file."""
  45. cwd = posix_path(os.getcwd())
  46. result = ''
  47. for _ in xrange(len(cwd.split('/'))):
  48. if os.path.exists(os.path.join(result, '.gclient')):
  49. return result
  50. result = os.path.join(result, os.pardir)
  51. assert False, 'Could not find root of your gclient checkout.'
  52. def get_solution(gclient_root, dep_path):
  53. """Find the solution in .gclient containing the dep being rolled."""
  54. dep_path = os.path.relpath(dep_path, gclient_root)
  55. cwd = os.getcwd().rstrip(os.sep) + os.sep
  56. gclient_root = os.path.realpath(gclient_root)
  57. gclient_path = os.path.join(gclient_root, '.gclient')
  58. gclient_locals = {}
  59. execfile(gclient_path, {}, gclient_locals)
  60. for soln in gclient_locals['solutions']:
  61. soln_relpath = platform_path(soln['name'].rstrip('/')) + os.sep
  62. if (dep_path.startswith(soln_relpath) or
  63. cwd.startswith(os.path.join(gclient_root, soln_relpath))):
  64. return soln
  65. assert False, 'Could not determine the parent project for %s' % dep_path
  66. def is_git_hash(revision):
  67. """Determines if a given revision is a git hash."""
  68. return SHA1_RE.match(revision)
  69. def verify_git_revision(dep_path, revision):
  70. """Verify that a git revision exists in a repository."""
  71. p = Popen(['git', 'rev-list', '-n', '1', revision],
  72. cwd=dep_path, stdout=PIPE, stderr=PIPE)
  73. result = p.communicate()[0].strip()
  74. if p.returncode != 0 or not is_git_hash(result):
  75. result = None
  76. return result
  77. def get_svn_revision(dep_path, git_revision):
  78. """Given a git revision, return the corresponding svn revision."""
  79. p = Popen(['git', 'log', '-n', '1', '--pretty=format:%B', git_revision],
  80. stdout=PIPE, cwd=dep_path)
  81. (log, _) = p.communicate()
  82. assert p.returncode == 0, 'git log %s failed.' % git_revision
  83. for line in reversed(log.splitlines()):
  84. m = GIT_SVN_ID_RE.match(line.strip())
  85. if m:
  86. return m.group(1)
  87. return None
  88. def convert_svn_revision(dep_path, revision):
  89. """Find the git revision corresponding to an svn revision."""
  90. err_msg = 'Unknown error'
  91. revision = int(revision)
  92. latest_svn_rev = None
  93. with open(os.devnull, 'w') as devnull:
  94. for ref in ('HEAD', 'origin/master'):
  95. try:
  96. log_p = Popen(['git', 'log', ref],
  97. cwd=dep_path, stdout=PIPE, stderr=devnull)
  98. grep_p = Popen(['grep', '-e', '^commit ', '-e', '^ *git-svn-id: '],
  99. stdin=log_p.stdout, stdout=PIPE, stderr=devnull)
  100. git_rev = None
  101. prev_svn_rev = None
  102. for line in grep_p.stdout:
  103. if line.startswith('commit '):
  104. git_rev = line.split()[1]
  105. continue
  106. try:
  107. svn_rev = int(line.split()[1].partition('@')[2])
  108. except (IndexError, ValueError):
  109. print('WARNING: Could not parse svn revision out of "%s"' % line,
  110. file=sys.stderr)
  111. continue
  112. if not latest_svn_rev or int(svn_rev) > int(latest_svn_rev):
  113. latest_svn_rev = svn_rev
  114. if svn_rev == revision:
  115. return git_rev
  116. if svn_rev > revision:
  117. prev_svn_rev = svn_rev
  118. continue
  119. if prev_svn_rev:
  120. err_msg = 'git history skips from revision %d to revision %d.' % (
  121. svn_rev, prev_svn_rev)
  122. else:
  123. err_msg = (
  124. 'latest available revision is %d; you may need to '
  125. '"git fetch origin" to get the latest commits.' %
  126. latest_svn_rev)
  127. finally:
  128. log_p.terminate()
  129. grep_p.terminate()
  130. raise RuntimeError('No match for revision %d; %s' % (revision, err_msg))
  131. def get_git_revision(dep_path, revision):
  132. """Convert the revision argument passed to the script to a git revision."""
  133. svn_revision = None
  134. if revision.startswith('r'):
  135. git_revision = convert_svn_revision(dep_path, revision[1:])
  136. svn_revision = revision[1:]
  137. elif re.search('[a-fA-F]', revision):
  138. git_revision = verify_git_revision(dep_path, revision)
  139. if not git_revision:
  140. raise RuntimeError('Please \'git fetch origin\' in %s' % dep_path)
  141. svn_revision = get_svn_revision(dep_path, git_revision)
  142. elif len(revision) > 6:
  143. git_revision = verify_git_revision(dep_path, revision)
  144. if git_revision:
  145. svn_revision = get_svn_revision(dep_path, git_revision)
  146. else:
  147. git_revision = convert_svn_revision(dep_path, revision)
  148. svn_revision = revision
  149. else:
  150. try:
  151. git_revision = convert_svn_revision(dep_path, revision)
  152. svn_revision = revision
  153. except RuntimeError:
  154. git_revision = verify_git_revision(dep_path, revision)
  155. if not git_revision:
  156. raise
  157. svn_revision = get_svn_revision(dep_path, git_revision)
  158. return git_revision, svn_revision
  159. def ast_err_msg(node):
  160. return 'ERROR: Undexpected DEPS file AST structure at line %d column %d' % (
  161. node.lineno, node.col_offset)
  162. def find_deps_section(deps_ast, section):
  163. """Find a top-level section of the DEPS file in the AST."""
  164. try:
  165. result = [n.value for n in deps_ast.body if
  166. n.__class__ is ast.Assign and
  167. n.targets[0].__class__ is ast.Name and
  168. n.targets[0].id == section][0]
  169. return result
  170. except IndexError:
  171. return None
  172. def find_dict_index(dict_node, key):
  173. """Given a key, find the index of the corresponding dict entry."""
  174. assert dict_node.__class__ is ast.Dict, ast_err_msg(dict_node)
  175. indices = [i for i, n in enumerate(dict_node.keys) if
  176. n.__class__ is ast.Str and n.s == key]
  177. assert len(indices) < 2, (
  178. 'Found redundant dict entries for key "%s"' % key)
  179. return indices[0] if indices else None
  180. def update_node(deps_lines, deps_ast, node, git_revision):
  181. """Update an AST node with the new git revision."""
  182. if node.__class__ is ast.Str:
  183. return update_string(deps_lines, node, git_revision)
  184. elif node.__class__ is ast.BinOp:
  185. return update_binop(deps_lines, deps_ast, node, git_revision)
  186. elif node.__class__ is ast.Call:
  187. return update_call(deps_lines, deps_ast, node, git_revision)
  188. elif node.__class__ is ast.Dict:
  189. return update_dict(deps_lines, deps_ast, node, git_revision)
  190. else:
  191. assert False, ast_err_msg(node)
  192. def update_string(deps_lines, string_node, git_revision):
  193. """Update a string node in the AST with the new git revision."""
  194. line_idx = string_node.lineno - 1
  195. start_idx = string_node.col_offset - 1
  196. line = deps_lines[line_idx]
  197. (prefix, sep, old_rev) = string_node.s.partition('@')
  198. if sep:
  199. start_idx = line.find(prefix + sep, start_idx) + len(prefix + sep)
  200. tail_idx = start_idx + len(old_rev)
  201. else:
  202. start_idx = line.find(prefix, start_idx)
  203. tail_idx = start_idx + len(prefix)
  204. old_rev = prefix
  205. deps_lines[line_idx] = line[:start_idx] + git_revision + line[tail_idx:]
  206. return line_idx
  207. def update_binop(deps_lines, deps_ast, binop_node, git_revision):
  208. """Update a binary operation node in the AST with the new git revision."""
  209. # Since the revision part is always last, assume that it's the right-hand
  210. # operand that needs to be updated.
  211. return update_node(deps_lines, deps_ast, binop_node.right, git_revision)
  212. def update_call(deps_lines, deps_ast, call_node, git_revision):
  213. """Update a function call node in the AST with the new git revision."""
  214. # The only call we know how to handle is Var()
  215. assert call_node.func.id == 'Var', ast_err_msg(call_node)
  216. assert call_node.args and call_node.args[0].__class__ is ast.Str, (
  217. ast_err_msg(call_node))
  218. return update_var(deps_lines, deps_ast, call_node.args[0].s, git_revision)
  219. def update_dict(deps_lines, deps_ast, dict_node, git_revision):
  220. """Update a dict node in the AST with the new git revision."""
  221. for key, value in zip(dict_node.keys, dict_node.values):
  222. if key.__class__ is ast.Str and key.s == 'url':
  223. return update_node(deps_lines, deps_ast, value, git_revision)
  224. def update_var(deps_lines, deps_ast, var_name, git_revision):
  225. """Update an entry in the vars section of the DEPS file with the new
  226. git revision."""
  227. vars_node = find_deps_section(deps_ast, 'vars')
  228. assert vars_node, 'Could not find "vars" section of DEPS file.'
  229. var_idx = find_dict_index(vars_node, var_name)
  230. assert var_idx is not None, (
  231. 'Could not find definition of "%s" var in DEPS file.' % var_name)
  232. val_node = vars_node.values[var_idx]
  233. return update_node(deps_lines, deps_ast, val_node, git_revision)
  234. def short_rev(rev, dep_path):
  235. return check_output(['git', 'rev-parse', '--short', rev],
  236. cwd=dep_path).rstrip()
  237. def generate_commit_message(deps_section, dep_path, dep_name, new_rev):
  238. dep_url = deps_section[dep_name]
  239. if isinstance(dep_url, dict):
  240. dep_url = dep_url['url']
  241. (url, _, old_rev) = dep_url.partition('@')
  242. if url.endswith('.git'):
  243. url = url[:-4]
  244. old_rev_short = short_rev(old_rev, dep_path)
  245. new_rev_short = short_rev(new_rev, dep_path)
  246. url += '/+log/%s..%s' % (old_rev_short, new_rev_short)
  247. try:
  248. old_svn_rev = get_svn_revision(dep_path, old_rev)
  249. new_svn_rev = get_svn_revision(dep_path, new_rev)
  250. except Exception:
  251. # Ignore failures that might arise from the repo not being checked out.
  252. old_svn_rev = new_svn_rev = None
  253. svn_range_str = ''
  254. if old_svn_rev and new_svn_rev:
  255. svn_range_str = ' (svn %s:%s)' % (old_svn_rev, new_svn_rev)
  256. return dedent(ROLL_DESCRIPTION_STR % {
  257. 'dep_path': shorten_dep_path(dep_name),
  258. 'before_rev': old_rev_short,
  259. 'after_rev': new_rev_short,
  260. 'svn_range': svn_range_str,
  261. 'revlog_url': url,
  262. })
  263. def update_deps_entry(deps_lines, deps_ast, value_node, new_rev, comment):
  264. line_idx = update_node(deps_lines, deps_ast, value_node, new_rev)
  265. (content, _, _) = deps_lines[line_idx].partition('#')
  266. if comment:
  267. deps_lines[line_idx] = '%s # %s' % (content.rstrip(), comment)
  268. else:
  269. deps_lines[line_idx] = content.rstrip()
  270. def update_deps(deps_file, dep_path, dep_name, new_rev, comment):
  271. """Update the DEPS file with the new git revision."""
  272. commit_msg = ''
  273. with open(deps_file) as fh:
  274. deps_content = fh.read()
  275. deps_locals = {}
  276. def _Var(key):
  277. return deps_locals['vars'][key]
  278. deps_locals['Var'] = _Var
  279. exec deps_content in {}, deps_locals
  280. deps_lines = deps_content.splitlines()
  281. deps_ast = ast.parse(deps_content, deps_file)
  282. deps_node = find_deps_section(deps_ast, 'deps')
  283. assert deps_node, 'Could not find "deps" section of DEPS file'
  284. dep_idx = find_dict_index(deps_node, dep_name)
  285. if dep_idx is not None:
  286. value_node = deps_node.values[dep_idx]
  287. update_deps_entry(deps_lines, deps_ast, value_node, new_rev, comment)
  288. commit_msg = generate_commit_message(deps_locals['deps'], dep_path,
  289. dep_name, new_rev)
  290. deps_os_node = find_deps_section(deps_ast, 'deps_os')
  291. if deps_os_node:
  292. for (os_name, os_node) in izip(deps_os_node.keys, deps_os_node.values):
  293. dep_idx = find_dict_index(os_node, dep_name)
  294. if dep_idx is not None:
  295. value_node = os_node.values[dep_idx]
  296. if value_node.__class__ is ast.Name and value_node.id == 'None':
  297. pass
  298. else:
  299. update_deps_entry(deps_lines, deps_ast, value_node, new_rev, comment)
  300. commit_msg = generate_commit_message(
  301. deps_locals['deps_os'][os_name.s], dep_path, dep_name, new_rev)
  302. if not commit_msg:
  303. print('Could not find an entry in %s to update.' % deps_file)
  304. return 1
  305. print('Pinning %s' % dep_name)
  306. print('to revision %s' % new_rev)
  307. print('in %s' % deps_file)
  308. with open(deps_file, 'w') as fh:
  309. for line in deps_lines:
  310. print(line, file=fh)
  311. deps_file_dir = os.path.normpath(os.path.dirname(deps_file))
  312. deps_file_root = Popen(
  313. ['git', 'rev-parse', '--show-toplevel'],
  314. cwd=deps_file_dir, stdout=PIPE).communicate()[0].strip()
  315. with open(os.path.join(deps_file_root, '.git', 'MERGE_MSG'), 'w') as fh:
  316. fh.write(commit_msg)
  317. return 0
  318. def main(argv):
  319. usage = 'Usage: roll-dep-svn [options] <dep path> <rev> [ <DEPS file> ]'
  320. parser = optparse.OptionParser(usage=usage, description=__doc__)
  321. parser.add_option('--no-verify-revision',
  322. help='Don\'t verify the revision passed in. This '
  323. 'also skips adding an svn revision comment '
  324. 'for git dependencies and requires the passed '
  325. 'revision to be a git hash.',
  326. default=False, action='store_true')
  327. options, args = parser.parse_args(argv)
  328. if len(args) not in (2, 3):
  329. parser.error('Expected either 2 or 3 positional parameters.')
  330. arg_dep_path, revision = args[:2]
  331. gclient_root = find_gclient_root()
  332. dep_path = platform_path(arg_dep_path)
  333. if not os.path.exists(dep_path):
  334. dep_path = os.path.join(gclient_root, dep_path)
  335. if not options.no_verify_revision:
  336. # Only require the path to exist if the revision should be verified. A path
  337. # to e.g. os deps might not be checked out.
  338. if not os.path.isdir(dep_path):
  339. print('No such directory: %s' % arg_dep_path, file=sys.stderr)
  340. return 1
  341. if len(args) > 2:
  342. deps_file = args[2]
  343. else:
  344. soln = get_solution(gclient_root, dep_path)
  345. soln_path = os.path.relpath(os.path.join(gclient_root, soln['name']))
  346. deps_file = os.path.join(soln_path, 'DEPS')
  347. dep_name = posix_path(os.path.relpath(dep_path, gclient_root))
  348. if options.no_verify_revision:
  349. if not is_git_hash(revision):
  350. print(
  351. 'The passed revision %s must be a git hash when skipping revision '
  352. 'verification.' % revision, file=sys.stderr)
  353. return 1
  354. git_rev = revision
  355. comment = None
  356. else:
  357. git_rev, svn_rev = get_git_revision(dep_path, revision)
  358. comment = ('from svn revision %s' % svn_rev) if svn_rev else None
  359. if not git_rev:
  360. print('Could not find git revision matching %s.' % revision,
  361. file=sys.stderr)
  362. return 1
  363. return update_deps(deps_file, dep_path, dep_name, git_rev, comment)
  364. if __name__ == '__main__':
  365. try:
  366. sys.exit(main(sys.argv[1:]))
  367. except KeyboardInterrupt:
  368. sys.stderr.write('interrupted\n')
  369. sys.exit(1)