git_drover.py 15 KB

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