git_drover.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469
  1. #!/usr/bin/env python
  2. # Copyright 2015 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. """git drover: A tool for merging changes to release branches."""
  6. from __future__ import print_function
  7. import argparse
  8. import functools
  9. import logging
  10. import os
  11. import re
  12. import shutil
  13. import subprocess
  14. import sys
  15. import tempfile
  16. import git_common
  17. import gclient_utils
  18. if sys.version_info.major == 2:
  19. import cPickle
  20. else:
  21. import pickle as cPickle
  22. class Error(Exception):
  23. pass
  24. _PATCH_ERROR_MESSAGE = """Patch failed to apply.
  25. A workdir for this cherry-pick has been created in
  26. {0}
  27. To continue, resolve the conflicts there and run
  28. git drover --continue {0}
  29. To abort this cherry-pick run
  30. git drover --abort {0}
  31. """
  32. class PatchError(Error):
  33. """An error indicating that the patch failed to apply."""
  34. def __init__(self, workdir):
  35. super(PatchError, self).__init__(_PATCH_ERROR_MESSAGE.format(workdir))
  36. _DEV_NULL_FILE = open(os.devnull, 'w')
  37. if os.name == 'nt':
  38. # This is a just-good-enough emulation of os.symlink for drover to work on
  39. # Windows. It uses junctioning of directories (most of the contents of
  40. # the .git directory), but copies files. Note that we can't use
  41. # CreateSymbolicLink or CreateHardLink here, as they both require elevation.
  42. # Creating reparse points is what we want for the directories, but doing so
  43. # is a relatively messy set of DeviceIoControl work at the API level, so we
  44. # simply shell to `mklink /j` instead.
  45. def emulate_symlink_windows(source, link_name):
  46. if os.path.isdir(source):
  47. subprocess.check_call(['mklink', '/j',
  48. link_name.replace('/', '\\'),
  49. source.replace('/', '\\')],
  50. shell=True)
  51. else:
  52. shutil.copy(source, link_name)
  53. mk_symlink = emulate_symlink_windows
  54. else:
  55. mk_symlink = os.symlink
  56. class _Drover(object):
  57. def __init__(self, branch, revision, parent_repo, dry_run, verbose):
  58. self._branch = branch
  59. self._branch_ref = 'refs/remotes/branch-heads/%s' % branch
  60. self._revision = revision
  61. self._parent_repo = os.path.abspath(parent_repo)
  62. self._dry_run = dry_run
  63. self._workdir = None
  64. self._branch_name = None
  65. self._needs_cleanup = True
  66. self._verbose = verbose
  67. self._process_options()
  68. def _process_options(self):
  69. if self._verbose:
  70. logging.getLogger().setLevel(logging.DEBUG)
  71. @classmethod
  72. def resume(cls, workdir):
  73. """Continues a cherry-pick that required manual resolution.
  74. Args:
  75. workdir: A string containing the path to the workdir used by drover.
  76. """
  77. drover = cls._restore_drover(workdir)
  78. drover._continue()
  79. @classmethod
  80. def abort(cls, workdir):
  81. """Aborts a cherry-pick that required manual resolution.
  82. Args:
  83. workdir: A string containing the path to the workdir used by drover.
  84. """
  85. drover = cls._restore_drover(workdir)
  86. drover._cleanup()
  87. @staticmethod
  88. def _restore_drover(workdir):
  89. """Restores a saved drover state contained within a workdir.
  90. Args:
  91. workdir: A string containing the path to the workdir used by drover.
  92. """
  93. try:
  94. with open(os.path.join(workdir, '.git', 'drover'), 'rb') as f:
  95. drover = cPickle.load(f)
  96. drover._process_options()
  97. return drover
  98. except (IOError, cPickle.UnpicklingError):
  99. raise Error('%r is not git drover workdir' % workdir)
  100. def _continue(self):
  101. if os.path.exists(os.path.join(self._workdir, '.git', 'CHERRY_PICK_HEAD')):
  102. self._run_git_command(
  103. ['commit', '--no-edit'],
  104. error_message='All conflicts must be resolved before continuing')
  105. if self._upload_and_land():
  106. # Only clean up the workdir on success. The manually resolved cherry-pick
  107. # can be reused if the user cancels before landing.
  108. self._cleanup()
  109. def run(self):
  110. """Runs this Drover instance.
  111. Raises:
  112. Error: An error occurred while attempting to cherry-pick this change.
  113. """
  114. try:
  115. self._run_internal()
  116. finally:
  117. self._cleanup()
  118. def _run_internal(self):
  119. self._check_inputs()
  120. if not self._confirm('Going to cherry-pick\n"""\n%s"""\nto %s.' % (
  121. self._run_git_command(['show', '-s', self._revision]), self._branch)):
  122. return
  123. self._create_checkout()
  124. self._perform_cherry_pick()
  125. self._upload_and_land()
  126. def _cleanup(self):
  127. if not self._needs_cleanup:
  128. return
  129. if self._workdir:
  130. logging.debug('Deleting %s', self._workdir)
  131. if os.name == 'nt':
  132. try:
  133. # Use rmdir to properly handle the junctions we created.
  134. subprocess.check_call(
  135. ['rmdir', '/s', '/q', self._workdir], shell=True)
  136. except subprocess.CalledProcessError:
  137. logging.error(
  138. 'Failed to delete workdir %r. Please remove it manually.',
  139. self._workdir)
  140. else:
  141. shutil.rmtree(self._workdir)
  142. self._workdir = None
  143. if self._branch_name:
  144. self._run_git_command(['branch', '-D', self._branch_name])
  145. @staticmethod
  146. def _confirm(message):
  147. """Show a confirmation prompt with the given message.
  148. Returns:
  149. A bool representing whether the user wishes to continue.
  150. """
  151. result = ''
  152. while result not in ('y', 'n'):
  153. try:
  154. result = gclient_utils.AskForData('%s Continue (y/n)? ' % message)
  155. except EOFError:
  156. result = 'n'
  157. return result == 'y'
  158. def _check_inputs(self):
  159. """Check the input arguments and ensure the parent repo is up to date."""
  160. if not os.path.isdir(self._parent_repo):
  161. raise Error('Invalid parent repo path %r' % self._parent_repo)
  162. self._run_git_command(['--help'], error_message='Unable to run git')
  163. self._run_git_command(['status'],
  164. error_message='%r is not a valid git repo' %
  165. os.path.abspath(self._parent_repo))
  166. self._run_git_command(['fetch', 'origin'],
  167. error_message='Failed to fetch origin')
  168. self._run_git_command(
  169. ['rev-parse', '%s^{commit}' % self._branch_ref],
  170. error_message='Branch %s not found' % self._branch_ref)
  171. self._run_git_command(
  172. ['rev-parse', '%s^{commit}' % self._revision],
  173. error_message='Revision "%s" not found' % self._revision)
  174. FILES_TO_LINK = [
  175. 'refs',
  176. 'logs/refs',
  177. 'info/refs',
  178. 'info/exclude',
  179. 'objects',
  180. 'hooks',
  181. 'packed-refs',
  182. 'remotes',
  183. 'rr-cache',
  184. ]
  185. FILES_TO_COPY = ['config', 'HEAD']
  186. def _create_checkout(self):
  187. """Creates a checkout to use for cherry-picking.
  188. This creates a checkout similarly to git-new-workdir. Most of the .git
  189. directory is shared with the |self._parent_repo| using symlinks. This
  190. differs from git-new-workdir in that the config is forked instead of shared.
  191. This is so the new workdir can be a sparse checkout without affecting
  192. |self._parent_repo|.
  193. """
  194. parent_git_dir = os.path.join(self._parent_repo, self._run_git_command(
  195. ['rev-parse', '--git-dir']).strip())
  196. self._workdir = tempfile.mkdtemp(prefix='drover_%s_' % self._branch)
  197. logging.debug('Creating checkout in %s', self._workdir)
  198. git_dir = os.path.join(self._workdir, '.git')
  199. git_common.make_workdir_common(parent_git_dir, git_dir, self.FILES_TO_LINK,
  200. self.FILES_TO_COPY, mk_symlink)
  201. self._run_git_command(['config', 'core.sparsecheckout', 'true'])
  202. with open(os.path.join(git_dir, 'info', 'sparse-checkout'), 'w') as f:
  203. f.write('/codereview.settings')
  204. branch_name = os.path.split(self._workdir)[-1]
  205. self._run_git_command(['checkout', '-b', branch_name, self._branch_ref])
  206. self._branch_name = branch_name
  207. def _perform_cherry_pick(self):
  208. try:
  209. self._run_git_command(['cherry-pick', '-x', self._revision],
  210. error_message='Patch failed to apply')
  211. except Error:
  212. self._prepare_manual_resolve()
  213. self._save_state()
  214. self._needs_cleanup = False
  215. raise PatchError(self._workdir)
  216. def _save_state(self):
  217. """Saves the state of this Drover instances to the workdir."""
  218. with open(os.path.join(self._workdir, '.git', 'drover'), 'wb') as f:
  219. cPickle.dump(self, f)
  220. def _prepare_manual_resolve(self):
  221. """Prepare the workdir for the user to manually resolve the cherry-pick."""
  222. # Files that have been deleted between branch and cherry-pick will not have
  223. # their skip-worktree bit set so set it manually for those files to avoid
  224. # git status incorrectly listing them as unstaged deletes.
  225. repo_status = self._run_git_command(
  226. ['-c', 'core.quotePath=false', 'status', '--porcelain']).splitlines()
  227. extra_files = [f[3:] for f in repo_status if f[:2] == ' D']
  228. if extra_files:
  229. stdin = '\n'.join(extra_files) + '\n'
  230. self._run_git_command_with_stdin(
  231. ['update-index', '--skip-worktree', '--stdin'], stdin=stdin.encode())
  232. def _upload_and_land(self):
  233. if self._dry_run:
  234. logging.info('--dry_run enabled; not landing.')
  235. return True
  236. self._run_git_command(['reset', '--hard'])
  237. author = self._run_git_command(['log', '-1', '--format=%ae']).strip()
  238. self._run_git_command(['cl', 'upload', '--send-mail', '--tbrs', author],
  239. error_message='Upload failed',
  240. interactive=True)
  241. if not self._confirm('About to start CQ on %s.' % self._branch):
  242. return False
  243. self._run_git_command(['cl', 'set-commit'], interactive=True)
  244. return True
  245. def _run_git_command(self, args, error_message=None, interactive=False):
  246. """Runs a git command.
  247. Args:
  248. args: A list of strings containing the args to pass to git.
  249. error_message: A string containing the error message to report if the
  250. command fails.
  251. interactive: A bool containing whether the command requires user
  252. interaction. If false, the command will be provided with no input and
  253. the output is captured.
  254. Returns:
  255. stdout as a string, or stdout interleaved with stderr if self._verbose
  256. Raises:
  257. Error: The command failed to complete successfully.
  258. """
  259. cwd = self._workdir if self._workdir else self._parent_repo
  260. logging.debug('Running git %s (cwd %r)', ' '.join('%s' % arg
  261. for arg in args), cwd)
  262. run = subprocess.check_call if interactive else subprocess.check_output
  263. # Discard stderr unless verbose is enabled.
  264. stderr = None if self._verbose else _DEV_NULL_FILE
  265. try:
  266. rv = run(['git'] + args, shell=False, cwd=cwd, stderr=stderr)
  267. if not interactive and sys.version_info.major == 3:
  268. return rv.decode('utf-8', 'ignore')
  269. return rv
  270. except (OSError, subprocess.CalledProcessError) as e:
  271. if error_message:
  272. raise Error(error_message)
  273. else:
  274. raise Error('Command %r failed: %s' % (' '.join(args), e))
  275. def _run_git_command_with_stdin(self, args, stdin):
  276. """Runs a git command with a provided stdin.
  277. Args:
  278. args: A list of strings containing the args to pass to git.
  279. stdin: A string to provide on stdin.
  280. Returns:
  281. stdout as a string, or stdout interleaved with stderr if self._verbose
  282. Raises:
  283. Error: The command failed to complete successfully.
  284. """
  285. cwd = self._workdir if self._workdir else self._parent_repo
  286. logging.debug('Running git %s (cwd %r)', ' '.join('%s' % arg
  287. for arg in args), cwd)
  288. # Discard stderr unless verbose is enabled.
  289. stderr = None if self._verbose else _DEV_NULL_FILE
  290. try:
  291. popen = subprocess.Popen(['git'] + args, shell=False, cwd=cwd,
  292. stderr=stderr, stdin=subprocess.PIPE)
  293. popen.communicate(stdin)
  294. if popen.returncode != 0:
  295. raise Error('Command %r failed' % ' '.join(args))
  296. except OSError as e:
  297. raise Error('Command %r failed: %s' % (' '.join(args), e))
  298. def cherry_pick_change(branch, revision, parent_repo, dry_run, verbose=False):
  299. """Cherry-picks a change into a branch.
  300. Args:
  301. branch: A string containing the release branch number to which to
  302. cherry-pick.
  303. revision: A string containing the revision to cherry-pick. It can be any
  304. string that git-rev-parse can identify as referring to a single
  305. revision.
  306. parent_repo: A string containing the path to the parent repo to use for this
  307. cherry-pick.
  308. dry_run: A bool containing whether to stop before uploading the
  309. cherry-pick cl.
  310. verbose: A bool containing whether to print verbose logging.
  311. Raises:
  312. Error: An error occurred while attempting to cherry-pick |cl| to |branch|.
  313. """
  314. drover = _Drover(branch, revision, parent_repo, dry_run, verbose)
  315. drover.run()
  316. def continue_cherry_pick(workdir):
  317. """Continues a cherry-pick that required manual resolution.
  318. Args:
  319. workdir: A string containing the path to the workdir used by drover.
  320. """
  321. _Drover.resume(workdir)
  322. def abort_cherry_pick(workdir):
  323. """Aborts a cherry-pick that required manual resolution.
  324. Args:
  325. workdir: A string containing the path to the workdir used by drover.
  326. """
  327. _Drover.abort(workdir)
  328. def main():
  329. parser = argparse.ArgumentParser(
  330. description='Cherry-pick a change into a release branch.')
  331. group = parser.add_mutually_exclusive_group(required=True)
  332. parser.add_argument(
  333. '--branch',
  334. type=str,
  335. metavar='<branch>',
  336. help='the name of the branch to which to cherry-pick; e.g. 1234')
  337. group.add_argument(
  338. '--cherry-pick',
  339. type=str,
  340. metavar='<change>',
  341. help=('the change to cherry-pick; this can be any string '
  342. 'that unambiguously refers to a revision not involving HEAD'))
  343. group.add_argument(
  344. '--continue',
  345. type=str,
  346. nargs='?',
  347. dest='resume',
  348. const=os.path.abspath('.'),
  349. metavar='path_to_workdir',
  350. help='Continue a drover cherry-pick after resolving conflicts')
  351. group.add_argument('--abort',
  352. type=str,
  353. nargs='?',
  354. const=os.path.abspath('.'),
  355. metavar='path_to_workdir',
  356. help='Abort a drover cherry-pick')
  357. parser.add_argument(
  358. '--parent_checkout',
  359. type=str,
  360. default=os.path.abspath('.'),
  361. metavar='<path_to_parent_checkout>',
  362. help=('the path to the chromium checkout to use as the source for a '
  363. 'creating git-new-workdir workdir to use for cherry-picking; '
  364. 'if unspecified, the current directory is used'))
  365. parser.add_argument(
  366. '--dry-run',
  367. action='store_true',
  368. default=False,
  369. help=("don't actually upload and land; "
  370. "just check that cherry-picking would succeed"))
  371. parser.add_argument('-v',
  372. '--verbose',
  373. action='store_true',
  374. default=False,
  375. help='show verbose logging')
  376. options = parser.parse_args()
  377. try:
  378. if options.resume:
  379. _Drover.resume(options.resume)
  380. elif options.abort:
  381. _Drover.abort(options.abort)
  382. else:
  383. if not options.branch:
  384. parser.error('argument --branch is required for --cherry-pick')
  385. cherry_pick_change(options.branch, options.cherry_pick,
  386. options.parent_checkout, options.dry_run,
  387. options.verbose)
  388. except Error as e:
  389. print('Error:', e)
  390. sys.exit(128)
  391. if __name__ == '__main__':
  392. main()