blob: 2e38b04d1aa64c530a7b4c944c31ae80d8dfaaf2 [file] [log] [blame]
szager@chromium.org03fd85b2014-06-09 23:43:33 +00001#!/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
sbc@chromium.org98201122015-04-22 20:21:34 +00006"""This scripts takes the path to a dep and a git or svn revision, and updates
7the parent repo's DEPS file with the corresponding git revision. Sample
8invocation:
szager@chromium.org03fd85b2014-06-09 23:43:33 +00009
10[chromium/src]$ roll-dep third_party/WebKit 12345
11
12After the script completes, the DEPS file will be dirty with the new revision.
13The user can then:
14
15$ git add DEPS
16$ git commit
17"""
18
19import ast
machenbach@chromium.orge0c89732014-12-18 15:42:43 +000020import optparse
szager@chromium.org03fd85b2014-06-09 23:43:33 +000021import os
22import re
23import sys
24
25from itertools import izip
borenet@google.com2674ae02014-09-15 21:00:23 +000026from subprocess import check_output, Popen, PIPE
szager@chromium.org03fd85b2014-06-09 23:43:33 +000027from textwrap import dedent
28
29
szager@chromium.org9e43b8b2014-08-01 18:53:45 +000030SHA1_RE = re.compile('^[a-fA-F0-9]{40}$')
31GIT_SVN_ID_RE = re.compile('^git-svn-id: .*@([0-9]+) .*$')
borenet@google.com2674ae02014-09-15 21:00:23 +000032ROLL_DESCRIPTION_STR = (
33'''Roll %(dep_path)s %(before_rev)s:%(after_rev)s%(svn_range)s
borenet@google.com8eec89c2014-08-29 22:03:07 +000034
35Summary of changes available at:
borenet@google.com2674ae02014-09-15 21:00:23 +000036%(revlog_url)s
37''')
borenet@google.com8eec89c2014-08-29 22:03:07 +000038
39
40def shorten_dep_path(dep):
41 """Shorten the given dep path if necessary."""
42 while len(dep) > 31:
43 dep = '.../' + dep.lstrip('./').partition('/')[2]
44 return dep
szager@chromium.org9e43b8b2014-08-01 18:53:45 +000045
46
szager@chromium.org03fd85b2014-06-09 23:43:33 +000047def posix_path(path):
48 """Convert a possibly-Windows path to a posix-style path."""
szager@chromium.org9e43b8b2014-08-01 18:53:45 +000049 (_, path) = os.path.splitdrive(path)
50 return path.replace(os.sep, '/')
szager@chromium.org03fd85b2014-06-09 23:43:33 +000051
52
53def platform_path(path):
54 """Convert a path to the native path format of the host OS."""
55 return path.replace('/', os.sep)
56
57
58def find_gclient_root():
59 """Find the directory containing the .gclient file."""
60 cwd = posix_path(os.getcwd())
61 result = ''
62 for _ in xrange(len(cwd.split('/'))):
63 if os.path.exists(os.path.join(result, '.gclient')):
64 return result
65 result = os.path.join(result, os.pardir)
66 assert False, 'Could not find root of your gclient checkout.'
67
68
69def get_solution(gclient_root, dep_path):
70 """Find the solution in .gclient containing the dep being rolled."""
71 dep_path = os.path.relpath(dep_path, gclient_root)
72 cwd = os.getcwd().rstrip(os.sep) + os.sep
73 gclient_root = os.path.realpath(gclient_root)
74 gclient_path = os.path.join(gclient_root, '.gclient')
75 gclient_locals = {}
76 execfile(gclient_path, {}, gclient_locals)
77 for soln in gclient_locals['solutions']:
78 soln_relpath = platform_path(soln['name'].rstrip('/')) + os.sep
79 if (dep_path.startswith(soln_relpath) or
80 cwd.startswith(os.path.join(gclient_root, soln_relpath))):
81 return soln
82 assert False, 'Could not determine the parent project for %s' % dep_path
83
84
machenbach@chromium.orge0c89732014-12-18 15:42:43 +000085def is_git_hash(revision):
86 """Determines if a given revision is a git hash."""
87 return SHA1_RE.match(revision)
88
89
szager@chromium.org03fd85b2014-06-09 23:43:33 +000090def verify_git_revision(dep_path, revision):
91 """Verify that a git revision exists in a repository."""
92 p = Popen(['git', 'rev-list', '-n', '1', revision],
93 cwd=dep_path, stdout=PIPE, stderr=PIPE)
94 result = p.communicate()[0].strip()
machenbach@chromium.orge0c89732014-12-18 15:42:43 +000095 if p.returncode != 0 or not is_git_hash(result):
szager@chromium.org03fd85b2014-06-09 23:43:33 +000096 result = None
97 return result
98
99
szager@chromium.org9e43b8b2014-08-01 18:53:45 +0000100def get_svn_revision(dep_path, git_revision):
101 """Given a git revision, return the corresponding svn revision."""
102 p = Popen(['git', 'log', '-n', '1', '--pretty=format:%B', git_revision],
103 stdout=PIPE, cwd=dep_path)
104 (log, _) = p.communicate()
105 assert p.returncode == 0, 'git log %s failed.' % git_revision
106 for line in reversed(log.splitlines()):
107 m = GIT_SVN_ID_RE.match(line.strip())
108 if m:
109 return m.group(1)
110 return None
111
112
szager@chromium.org03fd85b2014-06-09 23:43:33 +0000113def convert_svn_revision(dep_path, revision):
114 """Find the git revision corresponding to an svn revision."""
115 err_msg = 'Unknown error'
116 revision = int(revision)
thestig@chromium.orgbf13f082014-09-24 23:48:33 +0000117 latest_svn_rev = None
szager@chromium.org03fd85b2014-06-09 23:43:33 +0000118 with open(os.devnull, 'w') as devnull:
119 for ref in ('HEAD', 'origin/master'):
120 try:
121 log_p = Popen(['git', 'log', ref],
122 cwd=dep_path, stdout=PIPE, stderr=devnull)
123 grep_p = Popen(['grep', '-e', '^commit ', '-e', '^ *git-svn-id: '],
124 stdin=log_p.stdout, stdout=PIPE, stderr=devnull)
125 git_rev = None
126 prev_svn_rev = None
127 for line in grep_p.stdout:
128 if line.startswith('commit '):
129 git_rev = line.split()[1]
130 continue
131 try:
132 svn_rev = int(line.split()[1].partition('@')[2])
133 except (IndexError, ValueError):
134 print >> sys.stderr, (
135 'WARNING: Could not parse svn revision out of "%s"' % line)
136 continue
thestig@chromium.orgbf13f082014-09-24 23:48:33 +0000137 if not latest_svn_rev or int(svn_rev) > int(latest_svn_rev):
138 latest_svn_rev = svn_rev
szager@chromium.org03fd85b2014-06-09 23:43:33 +0000139 if svn_rev == revision:
140 return git_rev
141 if svn_rev > revision:
142 prev_svn_rev = svn_rev
143 continue
144 if prev_svn_rev:
145 err_msg = 'git history skips from revision %d to revision %d.' % (
146 svn_rev, prev_svn_rev)
147 else:
148 err_msg = (
149 'latest available revision is %d; you may need to '
thestig@chromium.orgbf13f082014-09-24 23:48:33 +0000150 '"git fetch origin" to get the latest commits.' %
151 latest_svn_rev)
szager@chromium.org03fd85b2014-06-09 23:43:33 +0000152 finally:
153 log_p.terminate()
154 grep_p.terminate()
155 raise RuntimeError('No match for revision %d; %s' % (revision, err_msg))
156
157
158def get_git_revision(dep_path, revision):
159 """Convert the revision argument passed to the script to a git revision."""
szager@chromium.org9e43b8b2014-08-01 18:53:45 +0000160 svn_revision = None
szager@chromium.org03fd85b2014-06-09 23:43:33 +0000161 if revision.startswith('r'):
szager@chromium.org9e43b8b2014-08-01 18:53:45 +0000162 git_revision = convert_svn_revision(dep_path, revision[1:])
163 svn_revision = revision[1:]
szager@chromium.org03fd85b2014-06-09 23:43:33 +0000164 elif re.search('[a-fA-F]', revision):
szager@chromium.org9e43b8b2014-08-01 18:53:45 +0000165 git_revision = verify_git_revision(dep_path, revision)
machenbach@chromium.orge0c89732014-12-18 15:42:43 +0000166 if not git_revision:
167 raise RuntimeError('Please \'git fetch origin\' in %s' % dep_path)
szager@chromium.org9e43b8b2014-08-01 18:53:45 +0000168 svn_revision = get_svn_revision(dep_path, git_revision)
szager@chromium.org03fd85b2014-06-09 23:43:33 +0000169 elif len(revision) > 6:
szager@chromium.org9e43b8b2014-08-01 18:53:45 +0000170 git_revision = verify_git_revision(dep_path, revision)
171 if git_revision:
172 svn_revision = get_svn_revision(dep_path, git_revision)
173 else:
174 git_revision = convert_svn_revision(dep_path, revision)
175 svn_revision = revision
szager@chromium.org03fd85b2014-06-09 23:43:33 +0000176 else:
177 try:
szager@chromium.org9e43b8b2014-08-01 18:53:45 +0000178 git_revision = convert_svn_revision(dep_path, revision)
179 svn_revision = revision
szager@chromium.org03fd85b2014-06-09 23:43:33 +0000180 except RuntimeError:
szager@chromium.org9e43b8b2014-08-01 18:53:45 +0000181 git_revision = verify_git_revision(dep_path, revision)
182 if not git_revision:
szager@chromium.org03fd85b2014-06-09 23:43:33 +0000183 raise
szager@chromium.org9e43b8b2014-08-01 18:53:45 +0000184 svn_revision = get_svn_revision(dep_path, git_revision)
185 return git_revision, svn_revision
szager@chromium.org03fd85b2014-06-09 23:43:33 +0000186
187
188def ast_err_msg(node):
189 return 'ERROR: Undexpected DEPS file AST structure at line %d column %d' % (
190 node.lineno, node.col_offset)
191
192
193def find_deps_section(deps_ast, section):
194 """Find a top-level section of the DEPS file in the AST."""
195 try:
196 result = [n.value for n in deps_ast.body if
197 n.__class__ is ast.Assign and
198 n.targets[0].__class__ is ast.Name and
199 n.targets[0].id == section][0]
200 return result
201 except IndexError:
202 return None
203
204
205def find_dict_index(dict_node, key):
206 """Given a key, find the index of the corresponding dict entry."""
207 assert dict_node.__class__ is ast.Dict, ast_err_msg(dict_node)
208 indices = [i for i, n in enumerate(dict_node.keys) if
209 n.__class__ is ast.Str and n.s == key]
210 assert len(indices) < 2, (
211 'Found redundant dict entries for key "%s"' % key)
212 return indices[0] if indices else None
213
214
215def update_node(deps_lines, deps_ast, node, git_revision):
216 """Update an AST node with the new git revision."""
217 if node.__class__ is ast.Str:
218 return update_string(deps_lines, node, git_revision)
219 elif node.__class__ is ast.BinOp:
220 return update_binop(deps_lines, deps_ast, node, git_revision)
221 elif node.__class__ is ast.Call:
222 return update_call(deps_lines, deps_ast, node, git_revision)
223 else:
224 assert False, ast_err_msg(node)
225
226
227def update_string(deps_lines, string_node, git_revision):
228 """Update a string node in the AST with the new git revision."""
229 line_idx = string_node.lineno - 1
230 start_idx = string_node.col_offset - 1
231 line = deps_lines[line_idx]
232 (prefix, sep, old_rev) = string_node.s.partition('@')
233 if sep:
234 start_idx = line.find(prefix + sep, start_idx) + len(prefix + sep)
235 tail_idx = start_idx + len(old_rev)
236 else:
237 start_idx = line.find(prefix, start_idx)
238 tail_idx = start_idx + len(prefix)
239 old_rev = prefix
240 deps_lines[line_idx] = line[:start_idx] + git_revision + line[tail_idx:]
szager@chromium.org9e43b8b2014-08-01 18:53:45 +0000241 return line_idx
szager@chromium.org03fd85b2014-06-09 23:43:33 +0000242
243
244def update_binop(deps_lines, deps_ast, binop_node, git_revision):
245 """Update a binary operation node in the AST with the new git revision."""
246 # Since the revision part is always last, assume that it's the right-hand
247 # operand that needs to be updated.
248 return update_node(deps_lines, deps_ast, binop_node.right, git_revision)
249
250
251def update_call(deps_lines, deps_ast, call_node, git_revision):
252 """Update a function call node in the AST with the new git revision."""
253 # The only call we know how to handle is Var()
254 assert call_node.func.id == 'Var', ast_err_msg(call_node)
255 assert call_node.args and call_node.args[0].__class__ is ast.Str, (
256 ast_err_msg(call_node))
257 return update_var(deps_lines, deps_ast, call_node.args[0].s, git_revision)
258
259
260def update_var(deps_lines, deps_ast, var_name, git_revision):
261 """Update an entry in the vars section of the DEPS file with the new
262 git revision."""
263 vars_node = find_deps_section(deps_ast, 'vars')
264 assert vars_node, 'Could not find "vars" section of DEPS file.'
265 var_idx = find_dict_index(vars_node, var_name)
266 assert var_idx is not None, (
267 'Could not find definition of "%s" var in DEPS file.' % var_name)
268 val_node = vars_node.values[var_idx]
269 return update_node(deps_lines, deps_ast, val_node, git_revision)
270
271
borenet@google.com2674ae02014-09-15 21:00:23 +0000272def short_rev(rev, dep_path):
273 return check_output(['git', 'rev-parse', '--short', rev],
274 cwd=dep_path).rstrip()
275
276
277def generate_commit_message(deps_section, dep_path, dep_name, new_rev):
szager@chromium.org03fd85b2014-06-09 23:43:33 +0000278 (url, _, old_rev) = deps_section[dep_name].partition('@')
279 if url.endswith('.git'):
280 url = url[:-4]
borenet@google.com2674ae02014-09-15 21:00:23 +0000281 old_rev_short = short_rev(old_rev, dep_path)
282 new_rev_short = short_rev(new_rev, dep_path)
283 url += '/+log/%s..%s' % (old_rev_short, new_rev_short)
machenbach@chromium.orge0c89732014-12-18 15:42:43 +0000284 try:
285 old_svn_rev = get_svn_revision(dep_path, old_rev)
286 new_svn_rev = get_svn_revision(dep_path, new_rev)
287 except Exception:
288 # Ignore failures that might arise from the repo not being checked out.
289 old_svn_rev = new_svn_rev = None
borenet@google.com2674ae02014-09-15 21:00:23 +0000290 svn_range_str = ''
291 if old_svn_rev and new_svn_rev:
292 svn_range_str = ' (svn %s:%s)' % (old_svn_rev, new_svn_rev)
293 return dedent(ROLL_DESCRIPTION_STR % {
294 'dep_path': shorten_dep_path(dep_name),
295 'before_rev': old_rev_short,
296 'after_rev': new_rev_short,
297 'svn_range': svn_range_str,
298 'revlog_url': url,
299 })
szager@chromium.org03fd85b2014-06-09 23:43:33 +0000300
sbc@chromium.org98201122015-04-22 20:21:34 +0000301
szager@chromium.org9e43b8b2014-08-01 18:53:45 +0000302def update_deps_entry(deps_lines, deps_ast, value_node, new_rev, comment):
303 line_idx = update_node(deps_lines, deps_ast, value_node, new_rev)
304 (content, _, _) = deps_lines[line_idx].partition('#')
305 if comment:
306 deps_lines[line_idx] = '%s # %s' % (content.rstrip(), comment)
307 else:
308 deps_lines[line_idx] = content.rstrip()
309
sbc@chromium.org98201122015-04-22 20:21:34 +0000310
szager@chromium.org00253802014-10-21 19:00:06 +0000311def update_deps(deps_file, dep_path, dep_name, new_rev, comment):
szager@chromium.org03fd85b2014-06-09 23:43:33 +0000312 """Update the DEPS file with the new git revision."""
313 commit_msg = ''
szager@chromium.org03fd85b2014-06-09 23:43:33 +0000314 with open(deps_file) as fh:
315 deps_content = fh.read()
316 deps_locals = {}
317 def _Var(key):
318 return deps_locals['vars'][key]
319 deps_locals['Var'] = _Var
320 exec deps_content in {}, deps_locals
321 deps_lines = deps_content.splitlines()
322 deps_ast = ast.parse(deps_content, deps_file)
323 deps_node = find_deps_section(deps_ast, 'deps')
324 assert deps_node, 'Could not find "deps" section of DEPS file'
325 dep_idx = find_dict_index(deps_node, dep_name)
326 if dep_idx is not None:
327 value_node = deps_node.values[dep_idx]
szager@chromium.org9e43b8b2014-08-01 18:53:45 +0000328 update_deps_entry(deps_lines, deps_ast, value_node, new_rev, comment)
borenet@google.com2674ae02014-09-15 21:00:23 +0000329 commit_msg = generate_commit_message(deps_locals['deps'], dep_path,
330 dep_name, new_rev)
szager@chromium.org03fd85b2014-06-09 23:43:33 +0000331 deps_os_node = find_deps_section(deps_ast, 'deps_os')
332 if deps_os_node:
333 for (os_name, os_node) in izip(deps_os_node.keys, deps_os_node.values):
334 dep_idx = find_dict_index(os_node, dep_name)
335 if dep_idx is not None:
336 value_node = os_node.values[dep_idx]
337 if value_node.__class__ is ast.Name and value_node.id == 'None':
338 pass
339 else:
szager@chromium.org9e43b8b2014-08-01 18:53:45 +0000340 update_deps_entry(deps_lines, deps_ast, value_node, new_rev, comment)
szager@chromium.org03fd85b2014-06-09 23:43:33 +0000341 commit_msg = generate_commit_message(
szager@chromium.org42937872014-10-29 22:19:26 +0000342 deps_locals['deps_os'][os_name.s], dep_path, dep_name, new_rev)
sbc@chromium.org98201122015-04-22 20:21:34 +0000343 if not commit_msg:
szager@chromium.org03fd85b2014-06-09 23:43:33 +0000344 print 'Could not find an entry in %s to update.' % deps_file
sbc@chromium.org98201122015-04-22 20:21:34 +0000345 return 1
346
347 print 'Pinning %s' % dep_name
348 print 'to revision %s' % new_rev
349 print 'in %s' % deps_file
350 with open(deps_file, 'w') as fh:
351 for line in deps_lines:
352 print >> fh, line
353 deps_file_dir = os.path.normpath(os.path.dirname(deps_file))
354 deps_file_root = Popen(
355 ['git', 'rev-parse', '--show-toplevel'],
356 cwd=deps_file_dir, stdout=PIPE).communicate()[0].strip()
357 with open(os.path.join(deps_file_root, '.git', 'MERGE_MSG'), 'w') as fh:
358 fh.write(commit_msg)
359 return 0
szager@chromium.org03fd85b2014-06-09 23:43:33 +0000360
361
362def main(argv):
sbc@chromium.org98201122015-04-22 20:21:34 +0000363 usage = 'Usage: roll_dep.py [options] <dep path> <rev> [ <DEPS file> ]'
364 parser = optparse.OptionParser(usage=usage, description=__doc__)
machenbach@chromium.orge0c89732014-12-18 15:42:43 +0000365 parser.add_option('--no-verify-revision',
366 help='Don\'t verify the revision passed in. This '
367 'also skips adding an svn revision comment '
368 'for git dependencies and requires the passed '
369 'revision to be a git hash.',
370 default=False, action='store_true')
sbc@chromium.org98201122015-04-22 20:21:34 +0000371 options, args = parser.parse_args(argv)
372 if len(args) not in (2, 3):
373 parser.error('Expected either 2 or 3 positional parameters.')
374 arg_dep_path, revision = args[:2]
szager@chromium.org03fd85b2014-06-09 23:43:33 +0000375 gclient_root = find_gclient_root()
szager@chromium.org00253802014-10-21 19:00:06 +0000376 dep_path = platform_path(arg_dep_path)
377 if not os.path.exists(dep_path):
378 dep_path = os.path.join(gclient_root, dep_path)
machenbach@chromium.orge0c89732014-12-18 15:42:43 +0000379 if not options.no_verify_revision:
380 # Only require the path to exist if the revision should be verified. A path
381 # to e.g. os deps might not be checked out.
sbc@chromium.org98201122015-04-22 20:21:34 +0000382 if not os.path.isdir(dep_path):
383 print >> sys.stderr, 'No such directory: %s' % arg_dep_path
384 return 1
385 if len(args) > 2:
386 deps_file = args[2]
szager@chromium.org00253802014-10-21 19:00:06 +0000387 else:
388 soln = get_solution(gclient_root, dep_path)
389 soln_path = os.path.relpath(os.path.join(gclient_root, soln['name']))
390 deps_file = os.path.join(soln_path, 'DEPS')
szager@chromium.org03fd85b2014-06-09 23:43:33 +0000391 dep_name = posix_path(os.path.relpath(dep_path, gclient_root))
machenbach@chromium.orge0c89732014-12-18 15:42:43 +0000392 if options.no_verify_revision:
sbc@chromium.org98201122015-04-22 20:21:34 +0000393 if not is_git_hash(revision):
394 print >> sys.stderr, (
395 'The passed revision %s must be a git hash when skipping revision '
396 'verification.' % revision)
397 return 1
machenbach@chromium.orge0c89732014-12-18 15:42:43 +0000398 git_rev = revision
399 comment = None
400 else:
sbc@chromium.org98201122015-04-22 20:21:34 +0000401 git_rev, svn_rev = get_git_revision(dep_path, revision)
machenbach@chromium.orge0c89732014-12-18 15:42:43 +0000402 comment = ('from svn revision %s' % svn_rev) if svn_rev else None
sbc@chromium.org98201122015-04-22 20:21:34 +0000403 if not git_rev:
404 print >> sys.stderr, 'Could not find git revision matching %s.' % revision
405 return 1
szager@chromium.org00253802014-10-21 19:00:06 +0000406 return update_deps(deps_file, dep_path, dep_name, git_rev, comment)
szager@chromium.org03fd85b2014-06-09 23:43:33 +0000407
sbc@chromium.org98201122015-04-22 20:21:34 +0000408
szager@chromium.org03fd85b2014-06-09 23:43:33 +0000409if __name__ == '__main__':
sbc@chromium.org013731e2015-02-26 18:28:43 +0000410 try:
411 sys.exit(main(sys.argv[1:]))
412 except KeyboardInterrupt:
413 sys.stderr.write('interrupted\n')
414 sys.exit(1)