blob: ef32613f580111e1e5c2d79e0a778c1c11b207c1 [file] [log] [blame]
Kuang-che Wu6e4beca2018-06-27 17:45:02 +08001# -*- coding: utf-8 -*-
Kuang-che Wu3eb6b502018-06-06 16:15:18 +08002# Copyright 2018 The Chromium OS 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"""Gclient utility."""
6
7from __future__ import print_function
Kuang-che Wu8a28a9d2018-09-11 17:43:36 +08008import collections
9import itertools
Kuang-che Wu3eb6b502018-06-06 16:15:18 +080010import logging
Kuang-che Wu8a28a9d2018-09-11 17:43:36 +080011import operator
Kuang-che Wu3eb6b502018-06-06 16:15:18 +080012import os
Kuang-che Wu6948ecc2018-09-11 17:43:49 +080013import pprint
Kuang-che Wub17b3b92018-09-04 18:12:11 +080014import sys
Kuang-che Wu3eb6b502018-06-06 16:15:18 +080015import urlparse
16
17from bisect_kit import codechange
Kuang-che Wu8a28a9d2018-09-11 17:43:36 +080018from bisect_kit import git_util
Kuang-che Wu3eb6b502018-06-06 16:15:18 +080019from bisect_kit import util
20
21logger = logging.getLogger(__name__)
22
23
Kuang-che Wu8a28a9d2018-09-11 17:43:36 +080024def config(gclient_dir, url, cache_dir=None, deps_file=None, custom_var=None):
Kuang-che Wu3eb6b502018-06-06 16:15:18 +080025 """Simply wrapper of `gclient config`.
26
27 Args:
28 gclient_dir: root directory of gclient project
29 url: URL of gclient configuration files
30 cache_dir: gclient's git cache folder
31 deps_file: override the default DEPS file name
Kuang-che Wu8a28a9d2018-09-11 17:43:36 +080032 custom_var: custom variables
Kuang-che Wu3eb6b502018-06-06 16:15:18 +080033 """
34 cmd = ['gclient', 'config']
35 if deps_file:
36 cmd += ['--deps-file', deps_file]
37 if cache_dir:
38 cmd += ['--cache-dir', cache_dir]
Kuang-che Wu8a28a9d2018-09-11 17:43:36 +080039 if custom_var:
40 cmd += ['--custom-var', custom_var]
Kuang-che Wu3eb6b502018-06-06 16:15:18 +080041 cmd.append(url)
42
43 util.check_call(*cmd, cwd=gclient_dir)
44
45
46def sync(gclient_dir, with_branch_heads=False, with_tags=False, jobs=8):
47 """Simply wrapper of `gclient sync`.
48
49 Args:
50 gclient_dir: root directory of gclient project
51 with_branch_heads: whether to clone git `branch_heads` refspecs
52 with_tags: whether to clone git tags
53 jobs: how many workers running in parallel
54 """
55 cmd = ['gclient', 'sync', '--jobs', str(jobs)]
56 if with_branch_heads:
57 cmd.append('--with_branch_heads')
58 if with_tags:
59 cmd.append('--with_tags')
60 util.check_call(*cmd, cwd=gclient_dir)
61
62
Kuang-che Wub17b3b92018-09-04 18:12:11 +080063# Copied from depot_tools' gclient.py
64_PLATFORM_MAPPING = {
65 'cygwin': 'win',
66 'darwin': 'mac',
67 'linux2': 'linux',
68 'win32': 'win',
69 'aix6': 'aix',
70}
71
72
73def _detect_host_os():
74 return _PLATFORM_MAPPING[sys.platform]
75
76
Kuang-che Wu8a28a9d2018-09-11 17:43:36 +080077class Dep(object):
78 """Represent one entry of DEPS's deps.
Kuang-che Wu3eb6b502018-06-06 16:15:18 +080079
Kuang-che Wu8a28a9d2018-09-11 17:43:36 +080080 One Dep object means one subproject inside DEPS file. It recorded what to
81 checkout (like git or cipd) content of each subproject.
Kuang-che Wu3eb6b502018-06-06 16:15:18 +080082
Kuang-che Wu8a28a9d2018-09-11 17:43:36 +080083 Attributes:
84 path: subproject path, relative to project root
85 variables: the variables of the containing DEPS file; these variables will
86 be applied to fields of this object (like 'url' and 'condition') and
87 children projects.
88 condition: whether to checkout this subproject
89 dep_type: 'git' or 'cipd'
90 url: if dep_type='git', the url of remote repo and associated branch/commit
91 packages: if dep_type='cipd', cipd package version and location
Kuang-che Wu3eb6b502018-06-06 16:15:18 +080092 """
93
Kuang-che Wu8a28a9d2018-09-11 17:43:36 +080094 def __init__(self, path, variables, entry):
95 self.path = path
96 self.variables = variables
Kuang-che Wu3eb6b502018-06-06 16:15:18 +080097
Kuang-che Wu8a28a9d2018-09-11 17:43:36 +080098 self.url = None # only valid for dep_type='git'
99 self.packages = None # only valid for dep_type='cipd'
Kuang-che Wu3eb6b502018-06-06 16:15:18 +0800100
Kuang-che Wu8a28a9d2018-09-11 17:43:36 +0800101 if isinstance(entry, str):
102 self.dep_type = 'git'
103 self.url = entry
104 self.condition = None
105 else:
106 self.dep_type = entry.get('dep_type', 'git')
107 self.condition = entry.get('condition')
108 if self.dep_type == 'git':
109 self.url = entry['url']
Kuang-che Wu3eb6b502018-06-06 16:15:18 +0800110 else:
Kuang-che Wu8a28a9d2018-09-11 17:43:36 +0800111 assert self.dep_type == 'cipd'
112 self.packages = entry['packages']
Kuang-che Wu3eb6b502018-06-06 16:15:18 +0800113
Kuang-che Wu8a28a9d2018-09-11 17:43:36 +0800114 if self.dep_type == 'git':
115 self.url = self.url.format(**self.variables)
Kuang-che Wu3eb6b502018-06-06 16:15:18 +0800116
Kuang-che Wu8a28a9d2018-09-11 17:43:36 +0800117 def __eq__(self, rhs):
118 return vars(self) == vars(rhs)
Kuang-che Wu3eb6b502018-06-06 16:15:18 +0800119
Kuang-che Wu8a28a9d2018-09-11 17:43:36 +0800120 def __ne__(self, rhs):
121 return not self.__eq__(rhs)
122
123 def as_path_spec(self):
124 assert self.dep_type == 'git'
125
126 if '@' in self.url:
127 repo_url, at = self.url.split('@')
128 else:
129 # If the dependency is not pinned, the default is master branch.
130 repo_url, at = self.url, 'master'
131 return codechange.PathSpec(self.path, repo_url, at)
132
133 def eval_condition(self):
134 """Evaluate condition for DEPS parsing.
135
136 Returns:
137 eval result
138 """
139 if not self.condition:
140 return True
141
142 vars_dict = {
143 # default os: linux
144 'checkout_android': False,
145 'checkout_chromeos': False,
146 'checkout_fuchsia': False,
147 'checkout_ios': False,
148 'checkout_linux': True,
149 'checkout_mac': False,
150 'checkout_win': False,
151 # default cpu: x64
152 'checkout_arm64': False,
153 'checkout_arm': False,
154 'checkout_mips': False,
155 'checkout_ppc': False,
156 'checkout_s390': False,
157 'checkout_x64': True,
158 'checkout_x86': False,
159 'host_os': _detect_host_os(),
160 'False': False,
161 'None': None,
162 'True': True,
163 }
164 vars_dict.update(self.variables)
165 # pylint: disable=eval-used
166 return eval(self.condition, vars_dict)
167
168
169class Deps(object):
170 """DEPS parsed result.
171
172 Attributes:
173 variables: 'vars' dict in DEPS file; these variables will be applied
174 recursively to children.
175 entries: dict of Dep objects
176 recursedeps: list of recursive projects
177 """
178
179 def __init__(self):
180 self.variables = {}
181 self.entries = {}
182 self.recursedeps = []
183
184
185class TimeSeriesTree(object):
186 """Data structure for generating snapshots of historical dependency tree.
187
188 This is a tree structure with time information. Each tree node represents not
189 only typical tree data and tree children information, but also historical
190 value of those tree data and tree children.
191
192 To be more specific in terms of DEPS parsing, one TimeSeriesTree object
193 represent a DEPS file. The caller will add_snapshot() to add parsed result of
194 historical DEPS instances. After that, the tree root of this class can
195 reconstruct the every historical moment of the project dependency state.
196
197 This class is slight abstraction of git_util.get_history_recursively() to
198 support more than single git repo and be version control system independent.
199 """
200
201 # TODO(kcwu): refactor git_util.get_history_recursively() to reuse this class.
202
203 def __init__(self, parent_deps, name, start_time, end_time):
204 """TimeSeriesTree constructor.
205
206 Args:
207 parent_deps: parent DEPS of the given period. None if this is tree root.
208 name: project name
209 start_time: start time
210 end_time: end time
211 """
212 self.parent_deps = parent_deps
213 self.name = name
214 self.snapshots = {}
215 self.start_time = start_time
216 self.end_time = end_time
217
218 # Intermediate dict to keep track alive children for the time being.
219 # Maintained by add_snapshot() and no_more_snapshot().
220 self.alive_children = {}
221
222 # All historical children (TimeSeriesTree object) between start_time and
223 # end_time. It's possible that children with the same name appear more than
224 # once in this list because they are removed and added back to the DEPS
225 # file.
226 self.subtrees = []
227
228 def subtree_eq(self, deps_a, deps_b, child_name):
229 """Compares subtree of two Deps.
230
231 Args:
232 deps_a: Deps object
233 deps_b: Deps object
234 child_name: the subtree to compare
235
236 Returns:
237 True if the said subtree of these two Deps equal
238 """
239 # Need to compare variables because they may influence subtree parsing
240 # behavior
241 return (deps_a.entries[child_name] == deps_b.entries[child_name] and
242 deps_a.variables == deps_b.variables)
243
244 def add_snapshot(self, timestamp, deps, children_names):
245 """Adds parsed DEPS result and children.
246
247 For example, if a given DEPS file has N revisions between start_time and
248 end_time, the caller should call this method N times to feed all parsed
249 results in order (timestamp increasing).
250
251 Args:
252 timestamp: timestamp of `deps`
253 deps: Deps object
254 children_names: list of names of deps' children
255 """
256 assert timestamp not in self.snapshots
257 self.snapshots[timestamp] = deps
258
259 for child_name in set(self.alive_children.keys() + children_names):
260 # `child_name` is added at `timestamp`
261 if child_name not in self.alive_children:
262 self.alive_children[child_name] = timestamp, deps
263
264 # `child_name` is removed at `timestamp`
265 elif child_name not in children_names:
266 self.subtrees.append(
267 TimeSeriesTree(self.alive_children[child_name][1], child_name,
268 self.alive_children[child_name][0], timestamp))
269 del self.alive_children[child_name]
270
271 # `child_name` is alive before and after `timestamp`
272 else:
273 last_deps = self.alive_children[child_name][1]
274 if not self.subtree_eq(last_deps, deps, child_name):
275 self.subtrees.append(
276 TimeSeriesTree(last_deps, child_name,
277 self.alive_children[child_name][0], timestamp))
278 self.alive_children[child_name] = timestamp, deps
279
280 def no_more_snapshot(self, deps):
281 """Indicates all snapshots are added.
282
283 add_snapshot() should not be invoked after no_more_snapshot().
284 """
285 for child_name, (timestamp, deps) in self.alive_children.items():
286 if timestamp == self.end_time:
287 continue
288 self.subtrees.append(
289 TimeSeriesTree(deps, child_name, timestamp, self.end_time))
290 self.alive_children = None
291
292 def events(self):
293 """Gets children added/removed events of this subtree.
294
295 Returns:
296 list of (timestamp, deps_name, deps, end_flag):
297 timestamp: timestamp of event
298 deps_name: name of this subtree
299 deps: Deps object of given project
300 end_flag: True indicates this is the last event of this deps tree
301 """
302 assert self.snapshots
303 assert self.alive_children is None, ('events() is valid only after '
304 'no_more_snapshot() is invoked')
305
306 result = []
307
308 last_deps = None
309 for timestamp, deps in self.snapshots.items():
310 result.append((timestamp, self.name, deps, False))
311 last_deps = deps
312
313 assert last_deps
314 result.append((self.end_time, self.name, last_deps, True))
315
316 for subtree in self.subtrees:
317 for event in subtree.events():
318 result.append(event)
319
320 result.sort()
321
322 return result
323
324 def iter_path_specs(self):
325 """Iterates snapshots of project dependency state.
326
327 Yields:
328 (timestamp, path_specs):
329 timestamp: time of snapshot
330 path_specs: dict of path_spec entries
331 """
332 forest = {}
333 # Group by timestamp
334 for timestamp, events in itertools.groupby(self.events(),
335 operator.itemgetter(0)):
336 # It's possible that one deps is removed and added at the same timestamp,
337 # i.e. modification, so use counter to track.
338 end_counter = collections.Counter()
339
340 for timestamp, name, deps, end in events:
341 forest[name] = deps
342 if end:
343 end_counter[name] += 1
344 else:
345 end_counter[name] -= 1
346
347 # Merge Deps at time `timestamp` into single path_specs.
348 path_specs = {}
349 for deps in forest.values():
350 for path, dep in deps.entries.items():
351 path_specs[path] = dep.as_path_spec()
352
353 yield timestamp, path_specs
354
355 # Remove deps which are removed at this timestamp.
356 for name, count in end_counter.items():
357 assert -1 <= count <= 1, (timestamp, name)
358 if count == 1:
359 del forest[name]
360
361
362class DepsParser(object):
363 """Gclient DEPS file parser."""
364
365 def __init__(self, project_root, code_storage):
366 self.project_root = project_root
367 self.code_storage = code_storage
368
369 def parse_single_deps(self, content, parent_vars=None, parent_path=''):
370 """Parses DEPS file without recursion.
371
372 Args:
373 content: file content of DEPS file
374 parent_vars: variables inherent from parent DEPS
375 parent_path: project path of parent DEPS file
376
377 Returns:
378 Deps object
379 """
380
381 def var_function(name):
382 return '{%s}' % name
383
384 global_scope = dict(Var=var_function)
385 local_scope = {}
386 try:
387 exec (content, global_scope, local_scope) # pylint: disable=exec-used
388 except SyntaxError:
389 raise
390
391 deps = Deps()
392 local_scope.setdefault('vars', {})
393 if parent_vars:
394 local_scope['vars'].update(parent_vars)
395 deps.variables = local_scope['vars']
396
397 # Warnings for old usages which we don't support.
398 for name in deps.variables:
399 if name.startswith('RECURSEDEPS_') or name.endswith('_DEPS_file'):
400 logger.warning('%s is deprecated and not supported recursion syntax',
401 name)
402 if 'deps_os' in local_scope:
403 logger.warning('deps_os is no longer supported')
404
405 for path, dep_entry in local_scope['deps'].items():
406 if local_scope.get('use_relative_paths', False):
407 path = os.path.join(parent_path, path)
408 path = path.format(**deps.variables)
409 dep = Dep(path, deps.variables, dep_entry)
410 if not dep.eval_condition():
411 continue
412
413 # TODO(kcwu): support dep_type=cipd http://crbug.com/846564
414 if dep.dep_type != 'git':
415 logger.warning('dep_type=%s is not supported yet: %s', dep.dep_type,
416 path)
417 continue
418
419 deps.entries[path] = dep
420
421 recursedeps = []
422 for path in local_scope.get('recursedeps', []):
423 assert isinstance(path, str)
424 if local_scope.get('use_relative_paths', False):
425 path = os.path.join(parent_path, path)
426 path = path.format(**deps.variables)
427 if path in deps.entries:
428 recursedeps.append(path)
429 deps.recursedeps = recursedeps
430
431 return deps
432
433 def construct_deps_tree(self,
434 tstree,
435 repo_url,
436 at,
437 after,
438 before,
439 parent_vars=None,
440 parent_path='',
441 deps_file='DEPS'):
442 """Processes DEPS recursively of given time period.
443
444 This method parses all commits of DEPS between time `after` and `before`,
445 segments recursive dependencies into subtrees if they are changed, and
446 processes subtrees recursively.
447
448 The parsed results (multiple revisions of DEPS file) are stored in `tstree`.
449
450 Args:
451 tstree: TimeSeriesTree object
452 repo_url: remote repo url
453 at: branch or git commit id
454 after: begin of period
455 before: end of period
456 parent_vars: DEPS variables inherit from parent DEPS (including
457 custom_vars)
458 parent_path: the path of parent project of current DEPS file
459 deps_file: filename of DEPS file, relative to the git repo, repo_rul
460 """
461 if '://' in repo_url:
462 git_repo = self.code_storage.cached_git_root(repo_url)
463 else:
464 git_repo = repo_url
465
466 if git_util.is_git_rev(at):
467 history = [
468 (after, at),
469 (before, at),
470 ]
471 else:
472 history = git_util.get_history(
473 git_repo,
474 deps_file,
475 branch=at,
476 after=after,
477 before=before,
478 padding=True)
479 assert history
480
481 # If not equal, it means the file was deleted but is still referenced by
482 # its parent.
483 assert history[-1][0] == before
484
485 # TODO(kcwu): optimization: history[-1] is unused
486 for timestamp, git_rev in history[:-1]:
487 content = git_util.get_file_from_revision(git_repo, git_rev, deps_file)
488
489 deps = self.parse_single_deps(
490 content, parent_vars=parent_vars, parent_path=parent_path)
491 tstree.add_snapshot(timestamp, deps, deps.recursedeps)
492
493 tstree.no_more_snapshot(deps)
494
495 for subtree in tstree.subtrees:
496 path = subtree.name
497 path_spec = subtree.parent_deps.entries[path].as_path_spec()
498 self.construct_deps_tree(
499 subtree,
500 path_spec.repo_url,
501 path_spec.at,
502 subtree.start_time,
503 subtree.end_time,
504 parent_vars=subtree.parent_deps.variables,
505 parent_path=path)
506
507 def enumerate_path_specs(self, start_time, end_time, path):
508 tstree = TimeSeriesTree(None, path, start_time, end_time)
509 self.construct_deps_tree(tstree, path, 'master', start_time, end_time)
510 return tstree.iter_path_specs()
Kuang-che Wu3eb6b502018-06-06 16:15:18 +0800511
512
513class GclientCache(codechange.CodeStorage):
514 """Gclient git cache."""
515
516 def __init__(self, cache_dir):
517 self.cache_dir = cache_dir
518
519 def _url_to_cache_dir(self, url):
520 # ref: depot_tools' git_cache.Mirror.UrlToCacheDir
521 parsed = urlparse.urlparse(url)
522 norm_url = parsed.netloc + parsed.path
523 if norm_url.endswith('.git'):
524 norm_url = norm_url[:-len('.git')]
525 return norm_url.replace('-', '--').replace('/', '-').lower()
526
527 def cached_git_root(self, repo_url):
528 cache_path = self._url_to_cache_dir(repo_url)
529 return os.path.join(self.cache_dir, cache_path)
Kuang-che Wu6948ecc2018-09-11 17:43:49 +0800530
531 def _load_project_list(self, project_root):
532 repo_project_list = os.path.join(project_root, '.gclient_entries')
533 scope = {}
534 exec open(repo_project_list) in scope # pylint: disable=exec-used
535 return scope.get('entries', {})
536
537 def _save_project_list(self, project_root, projects):
538 repo_project_list = os.path.join(project_root, '.gclient_entries')
539 content = 'entries = {\n'
540 for item in sorted(projects.items()):
541 content += ' %s: %s,\n' % map(pprint.pformat, item)
542 content += '}\n'
543 with open(repo_project_list, 'w') as f:
544 f.write(content)
545
546 def add_to_project_list(self, project_root, path, repo_url):
547 projects = self._load_project_list(project_root)
548
549 projects[path] = repo_url
550
551 self._save_project_list(project_root, projects)
552
553 def remove_from_project_list(self, project_root, path):
554 projects = self._load_project_list(project_root)
555
556 if path in projects:
557 del projects[path]
558
559 self._save_project_list(project_root, projects)