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