api.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452
  1. # Copyright 2013 The Chromium Authors. All rights reserved.
  2. # Use of this source code is governed by a BSD-style license that can be
  3. # found in the LICENSE file.
  4. import itertools
  5. import re
  6. from recipe_engine import recipe_api
  7. from recipe_engine import util as recipe_util
  8. class GitApi(recipe_api.RecipeApi):
  9. _GIT_HASH_RE = re.compile('[0-9a-f]{40}', re.IGNORECASE)
  10. def __call__(self, *args, **kwargs):
  11. """Returns a git command step."""
  12. name = kwargs.pop('name', 'git ' + args[0])
  13. infra_step = kwargs.pop('infra_step', True)
  14. git_cmd = ['git']
  15. options = kwargs.pop('git_config_options', {})
  16. for k, v in sorted(options.items()):
  17. git_cmd.extend(['-c', '%s=%s' % (k, v)])
  18. with self.m.context(cwd=(self.m.context.cwd or self.m.path['checkout'])):
  19. return self.m.step(name, git_cmd + list(args), infra_step=infra_step,
  20. **kwargs)
  21. def fetch_tags(self, remote_name=None, **kwargs):
  22. """Fetches all tags from the remote."""
  23. kwargs.setdefault('name', 'git fetch tags')
  24. remote_name = remote_name or 'origin'
  25. return self('fetch', remote_name, '--tags', **kwargs)
  26. def cat_file_at_commit(self, file_path, commit_hash, remote_name=None,
  27. **kwargs):
  28. """Outputs the contents of a file at a given revision."""
  29. self.fetch_tags(remote_name=remote_name, **kwargs)
  30. kwargs.setdefault('name', 'git cat-file %s:%s' % (commit_hash, file_path))
  31. return self('cat-file', 'blob', '%s:%s' % (commit_hash, file_path),
  32. **kwargs)
  33. def count_objects(self, previous_result=None, raise_on_failure=False, **kwargs):
  34. """Returns `git count-objects` result as a dict.
  35. Args:
  36. * previous_result (dict): the result of previous count_objects call.
  37. If passed, delta is reported in the log and step text.
  38. * raise_on_failure (bool): if True, an exception will be raised if the
  39. operation fails. Defaults to False.
  40. Returns:
  41. A dict of count-object values, or None if count-object run failed.
  42. """
  43. if previous_result:
  44. assert isinstance(previous_result, dict)
  45. assert all(isinstance(v, int) for v in previous_result.values())
  46. assert 'size' in previous_result
  47. assert 'size-pack' in previous_result
  48. step_result = None
  49. try:
  50. step_result = self(
  51. 'count-objects', '-v', stdout=self.m.raw_io.output(),
  52. raise_on_failure=raise_on_failure, **kwargs)
  53. if not step_result.stdout:
  54. return None
  55. result = {}
  56. for line in step_result.stdout.splitlines():
  57. line = line.decode('utf-8')
  58. name, value = line.split(':', 1)
  59. result[name] = int(value.strip())
  60. def results_to_text(results):
  61. return [' %s: %s' % (k, v) for k, v in sorted(results.items())]
  62. step_result.presentation.logs['result'] = results_to_text(result)
  63. if previous_result:
  64. delta = {
  65. key: value - previous_result[key]
  66. for key, value in result.items()
  67. if key in previous_result}
  68. step_result.presentation.logs['delta'] = (
  69. ['before:'] + results_to_text(previous_result) +
  70. ['', 'after:'] + results_to_text(result) +
  71. ['', 'delta:'] + results_to_text(delta)
  72. )
  73. size_delta = (
  74. result['size'] + result['size-pack']
  75. - previous_result['size'] - previous_result['size-pack'])
  76. # size_delta is in KiB.
  77. step_result.presentation.step_text = (
  78. 'size delta: %+.2f MiB' % (size_delta / 1024.0))
  79. return result
  80. except Exception as ex:
  81. if step_result:
  82. step_result.presentation.logs['exception'] = recipe_util.format_ex(ex)
  83. step_result.presentation.status = self.m.step.WARNING
  84. if raise_on_failure:
  85. raise recipe_api.InfraFailure('count-objects failed: %s' % ex)
  86. return None
  87. def checkout(self, url, ref=None, dir_path=None, recursive=False,
  88. submodules=True, submodule_update_force=False,
  89. keep_paths=None, step_suffix=None,
  90. curl_trace_file=None, raise_on_failure=True,
  91. set_got_revision=False, remote_name=None,
  92. display_fetch_size=None, file_name=None,
  93. submodule_update_recursive=True,
  94. use_git_cache=False, progress=True, tags=False):
  95. """Performs a full git checkout and returns sha1 of checked out revision.
  96. Args:
  97. * url (str): url of remote repo to use as upstream
  98. * ref (str): ref to fetch and check out
  99. * dir_path (Path): optional directory to clone into
  100. * recursive (bool): whether to recursively fetch submodules or not
  101. * submodules (bool): whether to sync and update submodules or not
  102. * submodule_update_force (bool): whether to update submodules with --force
  103. * keep_paths (iterable of strings): paths to ignore during git-clean;
  104. paths are gitignore-style patterns relative to checkout_path.
  105. * step_suffix (str): suffix to add to a each step name
  106. * curl_trace_file (Path): if not None, dump GIT_CURL_VERBOSE=1 trace to that
  107. file. Useful for debugging git issue reproducible only on bots. It has
  108. a side effect of all stderr output of 'git fetch' going to that file.
  109. * raise_on_failure (bool): if False, ignore errors during fetch or checkout.
  110. * set_got_revision (bool): if True, resolves HEAD and sets got_revision
  111. property.
  112. * remote_name (str): name of the git remote to use
  113. * display_fetch_size (bool): if True, run `git count-objects` before and
  114. after fetch and display delta. Adds two more steps. Defaults to False.
  115. * file_name (str): optional path to a single file to checkout.
  116. * submodule_update_recursive (bool): if True, updates submodules
  117. recursively.
  118. * use_git_cache (bool): if True, git cache will be used for this checkout.
  119. WARNING, this is EXPERIMENTAL!!! This wasn't tested with:
  120. * submodules
  121. * since origin url is modified
  122. to a local path, may cause problem with scripts that do
  123. "git fetch origin" or "git push origin".
  124. * arbitrary refs such refs/whatever/not-fetched-by-default-to-cache
  125. progress (bool): whether to show progress for fetch or not
  126. * tags (bool): Also fetch tags.
  127. Returns: If the checkout was successful, this returns the commit hash of
  128. the checked-out-repo. Otherwise this returns None.
  129. """
  130. retVal = None
  131. # TODO(robertocn): Break this function and refactor calls to it.
  132. # The problem is that there are way too many unrealated use cases for
  133. # it, and the function's signature is getting unwieldy and its body
  134. # unreadable.
  135. display_fetch_size = display_fetch_size or False
  136. if not dir_path:
  137. dir_path = url.rsplit('/', 1)[-1]
  138. if dir_path.endswith('.git'): # ex: https://host/foobar.git
  139. dir_path = dir_path[:-len('.git')]
  140. # ex: ssh://host:repo/foobar/.git
  141. dir_path = dir_path or dir_path.rsplit('/', 1)[-1]
  142. dir_path = self.m.path['start_dir'].join(dir_path)
  143. if 'checkout' not in self.m.path:
  144. self.m.path['checkout'] = dir_path
  145. git_setup_args = ['--path', dir_path, '--url', url]
  146. if remote_name:
  147. git_setup_args += ['--remote', remote_name]
  148. else:
  149. remote_name = 'origin'
  150. step_suffix = '' if step_suffix is None else ' (%s)' % step_suffix
  151. self.m.step(
  152. 'git setup%s' % step_suffix,
  153. ['python3', '-u', self.resource('git_setup.py')] + git_setup_args)
  154. # Some of the commands below require depot_tools to be in PATH.
  155. path = self.m.path.pathsep.join([
  156. str(self.repo_resource()), '%(PATH)s'])
  157. with self.m.context(cwd=dir_path):
  158. if use_git_cache:
  159. with self.m.context(env={'PATH': path}):
  160. self('cache', 'populate', '-c',
  161. self.m.path['cache'].join('git'), url,
  162. name='populate cache',
  163. raise_on_failure=raise_on_failure)
  164. dir_cmd = self(
  165. 'cache', 'exists', '--quiet',
  166. '--cache-dir', self.m.path['cache'].join('git'), url,
  167. raise_on_failure=raise_on_failure,
  168. stdout=self.m.raw_io.output(),
  169. step_test_data=lambda:
  170. self.m.raw_io.test_api.stream_output('mirror_dir'))
  171. mirror_dir = dir_cmd.stdout.strip().decode('utf-8')
  172. self('remote', 'set-url', 'origin', mirror_dir,
  173. raise_on_failure=raise_on_failure)
  174. # There are five kinds of refs we can be handed:
  175. # 0) None. In this case, we default to api.buildbucket.gitiles_commit.ref.
  176. # 1) A fully qualified branch name, e.g. 'refs/heads/main'.
  177. # Chop off 'refs/heads/' and now it matches case (4).
  178. # 2) A 40-character SHA1 hash.
  179. # 3) A fully-qualifed arbitrary ref, e.g. 'refs/foo/bar/baz'.
  180. # 4) A branch name, e.g. 'main'.
  181. # Note that 'FETCH_HEAD' can be many things (and therefore not a valid
  182. # checkout target) if many refs are fetched, but we only explicitly fetch
  183. # one ref here, so this is safe.
  184. if not ref: # Case 0.
  185. ref = self.m.buildbucket.gitiles_commit.ref or 'main'
  186. # If it's a fully-qualified branch name, trim the 'refs/heads/' prefix.
  187. if ref.startswith('refs/heads/'): # Case 1.
  188. ref = ref[len('refs/heads/'):]
  189. fetch_args = []
  190. if self._GIT_HASH_RE.match(ref): # Case 2.
  191. fetch_remote = remote_name
  192. fetch_ref = ''
  193. checkout_ref = ref
  194. else: # Cases 3 and 4.
  195. fetch_remote = remote_name
  196. fetch_ref = ref
  197. checkout_ref = 'FETCH_HEAD'
  198. fetch_args = [x for x in (fetch_remote, fetch_ref) if x]
  199. if recursive:
  200. fetch_args.append('--recurse-submodules')
  201. if progress:
  202. fetch_args.append('--progress')
  203. fetch_env = {'PATH': path}
  204. fetch_stderr = None
  205. if curl_trace_file:
  206. fetch_env['GIT_CURL_VERBOSE'] = '1'
  207. fetch_stderr = self.m.raw_io.output(leak_to=curl_trace_file)
  208. if tags:
  209. fetch_args.append('--tags')
  210. fetch_step_name = 'git fetch%s' % step_suffix
  211. if display_fetch_size:
  212. count_objects_before_fetch = self.count_objects(
  213. name='count-objects before %s' % fetch_step_name,
  214. step_test_data=lambda: self.m.raw_io.test_api.stream_output(
  215. self.test_api.count_objects_output(1000)))
  216. with self.m.context(env=fetch_env):
  217. self('fetch', *fetch_args,
  218. name=fetch_step_name,
  219. stderr=fetch_stderr,
  220. raise_on_failure=raise_on_failure)
  221. if display_fetch_size:
  222. self.count_objects(
  223. name='count-objects after %s' % fetch_step_name,
  224. previous_result=count_objects_before_fetch,
  225. step_test_data=lambda: self.m.raw_io.test_api.stream_output(
  226. self.test_api.count_objects_output(2000)))
  227. if file_name:
  228. self('checkout', '-f', checkout_ref, '--', file_name,
  229. name='git checkout%s' % step_suffix,
  230. raise_on_failure=raise_on_failure)
  231. else:
  232. self('checkout', '-f', checkout_ref,
  233. name='git checkout%s' % step_suffix,
  234. raise_on_failure=raise_on_failure)
  235. rev_parse_step = self('rev-parse', 'HEAD',
  236. name='read revision',
  237. stdout=self.m.raw_io.output_text(),
  238. raise_on_failure=False,
  239. step_test_data=lambda:
  240. self.m.raw_io.test_api.stream_output_text('deadbeef'))
  241. if rev_parse_step.presentation.status == 'SUCCESS':
  242. sha = rev_parse_step.stdout.strip()
  243. retVal = sha
  244. rev_parse_step.presentation.step_text = "<br/>checked out %r<br/>" % sha
  245. if set_got_revision:
  246. rev_parse_step.presentation.properties['got_revision'] = sha
  247. clean_args = list(itertools.chain(
  248. *[('-e', path) for path in keep_paths or []]))
  249. self('clean', '-f', '-d', '-x', *clean_args,
  250. name='git clean%s' % step_suffix,
  251. raise_on_failure=raise_on_failure)
  252. if submodules:
  253. self('submodule', 'sync',
  254. name='submodule sync%s' % step_suffix,
  255. raise_on_failure=raise_on_failure)
  256. submodule_update = ['submodule', 'update', '--init']
  257. if submodule_update_recursive:
  258. submodule_update.append('--recursive')
  259. if submodule_update_force:
  260. submodule_update.append('--force')
  261. self(*submodule_update,
  262. name='submodule update%s' % step_suffix,
  263. raise_on_failure=raise_on_failure)
  264. return retVal
  265. def get_timestamp(self, commit='HEAD', test_data=None, **kwargs):
  266. """Find and return the timestamp of the given commit."""
  267. step_test_data = None
  268. if test_data is not None:
  269. step_test_data = lambda: self.m.raw_io.test_api.stream_output(test_data)
  270. return self('show', commit, '--format=%at', '-s',
  271. stdout=self.m.raw_io.output(),
  272. step_test_data=step_test_data).stdout.rstrip().decode('utf-8')
  273. def rebase(self, name_prefix, branch, dir_path, remote_name=None,
  274. **kwargs):
  275. """Runs rebase HEAD onto branch
  276. Args:
  277. * name_prefix (str): a prefix used for the step names
  278. * branch (str): a branch name or a hash to rebase onto
  279. * dir_path (Path): directory to clone into
  280. * remote_name (str): the remote name to rebase from if not origin
  281. """
  282. remote_name = remote_name or 'origin'
  283. with self.m.context(cwd=dir_path):
  284. try:
  285. self('rebase', '%s/main' % remote_name,
  286. name="%s rebase" % name_prefix, **kwargs)
  287. except self.m.step.StepFailure:
  288. self('rebase', '--abort', name='%s rebase abort' % name_prefix,
  289. **kwargs)
  290. raise
  291. def config_get(self, prop_name, **kwargs):
  292. """Returns git config output.
  293. Args:
  294. * prop_name: (str) The name of the config property to query.
  295. * kwargs: Forwarded to '__call__'.
  296. Returns: (str) The Git config output, or None if no output was generated.
  297. """
  298. kwargs['name'] = kwargs.get('name', 'git config %s' % (prop_name,))
  299. result = self('config', '--get', prop_name, stdout=self.m.raw_io.output(),
  300. **kwargs)
  301. value = result.stdout
  302. if value:
  303. value = value.strip()
  304. result.presentation.step_text = value.decode('utf-8')
  305. return value
  306. def get_remote_url(self, remote_name=None, **kwargs):
  307. """Returns the remote Git repository URL, or None.
  308. Args:
  309. * remote_name: (str) The name of the remote to query, defaults to 'origin'.
  310. * kwargs: Forwarded to '__call__'.
  311. Returns: (str) The URL of the remote Git repository, or None.
  312. """
  313. remote_name = remote_name or 'origin'
  314. return self.config_get('remote.%s.url' % (remote_name,), **kwargs)
  315. def bundle_create(self, bundle_path, rev_list_args=None, **kwargs):
  316. """Runs 'git bundle create' on a Git repository.
  317. Args:
  318. * bundle_path (Path): The path of the output bundle.
  319. * refs (list): The list of refs to include in the bundle. If None, all
  320. refs in the Git checkout will be bundled.
  321. * kwargs: Forwarded to '__call__'.
  322. """
  323. if not rev_list_args:
  324. rev_list_args = ['--all']
  325. self('bundle', 'create', bundle_path, *rev_list_args, **kwargs)
  326. def new_branch(self,
  327. branch,
  328. name=None,
  329. upstream=None,
  330. upstream_current=False,
  331. **kwargs):
  332. """Runs git new-branch on a Git repository, to be used before git cl
  333. upload.
  334. Args:
  335. * branch (str): new branch name, which must not yet exist.
  336. * name (str): step name.
  337. * upstream (str): to origin/main.
  338. * upstream_current (bool): whether to use '--upstream_current'.
  339. * kwargs: Forwarded to '__call__'.
  340. """
  341. if upstream and upstream_current:
  342. raise ValueError('Can not define both upstream and upstream_current')
  343. env = self.m.context.env
  344. env['PATH'] = self.m.path.pathsep.join([
  345. str(self.repo_resource()), '%(PATH)s'])
  346. args = ['new-branch', branch]
  347. if upstream:
  348. args.extend(['--upstream', upstream])
  349. if upstream_current:
  350. args.append('--upstream_current')
  351. if not name:
  352. name = 'git new-branch %s' % branch
  353. with self.m.context(env=env):
  354. return self(*args, name=name, **kwargs)
  355. def number(self, commitrefs=None, test_values=None):
  356. """Computes the generation number of some commits.
  357. Args:
  358. * commitrefs (list[str]): A list of commit references. If none are
  359. provided, the generation number for HEAD will be retrieved.
  360. * test_values (list[str]): A list of numbers to use as the return
  361. value during tests. It is an error if the length of the list
  362. does not match the number of commitrefs (1 if commitrefs is not
  363. provided).
  364. Returns:
  365. A list of strings containing the generation numbers of the commits.
  366. If non-empty commitrefs was provided, the order of the returned
  367. numbers will correspond to the order of the provided commitrefs.
  368. """
  369. def step_test_data():
  370. refs = commitrefs or ['HEAD']
  371. if test_values:
  372. assert len(test_values) == len(refs)
  373. values = test_values or range(3000, 3000 + len(refs))
  374. output = '\n'.join(str(v) for v in values)
  375. return self.m.raw_io.test_api.stream_output_text(output)
  376. args = ['number']
  377. args.extend(commitrefs or [])
  378. # Put depot_tools on the path so that git-number can be found
  379. with self.m.depot_tools.on_path():
  380. # git-number is only meant for use on bots, so it prints an error message
  381. # if CHROME_HEADLESS is not set
  382. with self.m.context(env={'CHROME_HEADLESS': '1'}):
  383. step_result = self(*args,
  384. stdout=self.m.raw_io.output_text(add_output_log=True),
  385. step_test_data=step_test_data)
  386. return [l.strip() for l in step_result.stdout.strip().splitlines()]