blob: cec079042c0ae02aa3c64512c8e90c42fd365fa7 [file] [log] [blame]
Edward Lemurd6186f92019-08-12 17:56:58 +00001#!/usr/bin/env vpython
sammc@chromium.org900a33f2015-09-29 06:57:09 +00002# 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
Raul Tambre80ee78e2019-05-06 22:41:05 +00007from __future__ import print_function
8
sammc@chromium.org900a33f2015-09-29 06:57:09 +00009import argparse
sammc@chromium.org89901892015-11-03 00:57:48 +000010import cPickle
sammc@chromium.org900a33f2015-09-29 06:57:09 +000011import functools
12import logging
13import os
Aaron Gablec7e84d02017-04-27 14:42:43 -070014import re
sammc@chromium.org900a33f2015-09-29 06:57:09 +000015import shutil
16import subprocess
17import sys
18import tempfile
19
20import git_common
21
22
23class Error(Exception):
24 pass
25
26
sammc@chromium.org89901892015-11-03 00:57:48 +000027_PATCH_ERROR_MESSAGE = """Patch failed to apply.
28
29A workdir for this cherry-pick has been created in
30 {0}
31
32To continue, resolve the conflicts there and run
33 git drover --continue {0}
34
35To abort this cherry-pick run
36 git drover --abort {0}
37"""
38
39
40class PatchError(Error):
41 """An error indicating that the patch failed to apply."""
42
43 def __init__(self, workdir):
44 super(PatchError, self).__init__(_PATCH_ERROR_MESSAGE.format(workdir))
45
46
47_DEV_NULL_FILE = open(os.devnull, 'w')
48
scottmg@chromium.orgd4218d42015-10-07 23:49:20 +000049if os.name == 'nt':
50 # This is a just-good-enough emulation of os.symlink for drover to work on
51 # Windows. It uses junctioning of directories (most of the contents of
52 # the .git directory), but copies files. Note that we can't use
53 # CreateSymbolicLink or CreateHardLink here, as they both require elevation.
54 # Creating reparse points is what we want for the directories, but doing so
55 # is a relatively messy set of DeviceIoControl work at the API level, so we
56 # simply shell to `mklink /j` instead.
57 def emulate_symlink_windows(source, link_name):
58 if os.path.isdir(source):
59 subprocess.check_call(['mklink', '/j',
60 link_name.replace('/', '\\'),
61 source.replace('/', '\\')],
62 shell=True)
63 else:
64 shutil.copy(source, link_name)
65 mk_symlink = emulate_symlink_windows
66else:
67 mk_symlink = os.symlink
68
69
sammc@chromium.org900a33f2015-09-29 06:57:09 +000070class _Drover(object):
71
sammc@chromium.org89901892015-11-03 00:57:48 +000072 def __init__(self, branch, revision, parent_repo, dry_run, verbose):
sammc@chromium.org900a33f2015-09-29 06:57:09 +000073 self._branch = branch
74 self._branch_ref = 'refs/remotes/branch-heads/%s' % branch
75 self._revision = revision
76 self._parent_repo = os.path.abspath(parent_repo)
77 self._dry_run = dry_run
78 self._workdir = None
79 self._branch_name = None
sammc@chromium.org89901892015-11-03 00:57:48 +000080 self._needs_cleanup = True
81 self._verbose = verbose
82 self._process_options()
83
84 def _process_options(self):
85 if self._verbose:
86 logging.getLogger().setLevel(logging.DEBUG)
87
88
89 @classmethod
90 def resume(cls, workdir):
91 """Continues a cherry-pick that required manual resolution.
92
93 Args:
94 workdir: A string containing the path to the workdir used by drover.
95 """
96 drover = cls._restore_drover(workdir)
97 drover._continue()
98
99 @classmethod
100 def abort(cls, workdir):
101 """Aborts a cherry-pick that required manual resolution.
102
103 Args:
104 workdir: A string containing the path to the workdir used by drover.
105 """
106 drover = cls._restore_drover(workdir)
107 drover._cleanup()
108
109 @staticmethod
110 def _restore_drover(workdir):
111 """Restores a saved drover state contained within a workdir.
112
113 Args:
114 workdir: A string containing the path to the workdir used by drover.
115 """
116 try:
117 with open(os.path.join(workdir, '.git', 'drover'), 'rb') as f:
118 drover = cPickle.load(f)
119 drover._process_options()
120 return drover
121 except (IOError, cPickle.UnpicklingError):
122 raise Error('%r is not git drover workdir' % workdir)
123
124 def _continue(self):
125 if os.path.exists(os.path.join(self._workdir, '.git', 'CHERRY_PICK_HEAD')):
126 self._run_git_command(
127 ['commit', '--no-edit'],
128 error_message='All conflicts must be resolved before continuing')
129
130 if self._upload_and_land():
131 # Only clean up the workdir on success. The manually resolved cherry-pick
132 # can be reused if the user cancels before landing.
133 self._cleanup()
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000134
135 def run(self):
136 """Runs this Drover instance.
137
138 Raises:
139 Error: An error occurred while attempting to cherry-pick this change.
140 """
141 try:
142 self._run_internal()
143 finally:
144 self._cleanup()
145
146 def _run_internal(self):
147 self._check_inputs()
148 if not self._confirm('Going to cherry-pick\n"""\n%s"""\nto %s.' % (
149 self._run_git_command(['show', '-s', self._revision]), self._branch)):
150 return
151 self._create_checkout()
sammc@chromium.org89901892015-11-03 00:57:48 +0000152 self._perform_cherry_pick()
153 self._upload_and_land()
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000154
155 def _cleanup(self):
sammc@chromium.org89901892015-11-03 00:57:48 +0000156 if not self._needs_cleanup:
157 return
158
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000159 if self._workdir:
160 logging.debug('Deleting %s', self._workdir)
scottmg@chromium.orgd4218d42015-10-07 23:49:20 +0000161 if os.name == 'nt':
sammc@chromium.org89901892015-11-03 00:57:48 +0000162 try:
163 # Use rmdir to properly handle the junctions we created.
164 subprocess.check_call(
165 ['rmdir', '/s', '/q', self._workdir], shell=True)
166 except subprocess.CalledProcessError:
167 logging.error(
168 'Failed to delete workdir %r. Please remove it manually.',
169 self._workdir)
scottmg@chromium.orgd4218d42015-10-07 23:49:20 +0000170 else:
171 shutil.rmtree(self._workdir)
sammc@chromium.org89901892015-11-03 00:57:48 +0000172 self._workdir = None
173 if self._branch_name:
174 self._run_git_command(['branch', '-D', self._branch_name])
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000175
176 @staticmethod
177 def _confirm(message):
178 """Show a confirmation prompt with the given message.
179
180 Returns:
181 A bool representing whether the user wishes to continue.
182 """
183 result = ''
184 while result not in ('y', 'n'):
185 try:
186 result = raw_input('%s Continue (y/n)? ' % message)
187 except EOFError:
188 result = 'n'
189 return result == 'y'
190
191 def _check_inputs(self):
192 """Check the input arguments and ensure the parent repo is up to date."""
193
194 if not os.path.isdir(self._parent_repo):
195 raise Error('Invalid parent repo path %r' % self._parent_repo)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000196
197 self._run_git_command(['--help'], error_message='Unable to run git')
198 self._run_git_command(['status'],
199 error_message='%r is not a valid git repo' %
200 os.path.abspath(self._parent_repo))
201 self._run_git_command(['fetch', 'origin'],
202 error_message='Failed to fetch origin')
203 self._run_git_command(
204 ['rev-parse', '%s^{commit}' % self._branch_ref],
205 error_message='Branch %s not found' % self._branch_ref)
206 self._run_git_command(
207 ['rev-parse', '%s^{commit}' % self._revision],
208 error_message='Revision "%s" not found' % self._revision)
209
210 FILES_TO_LINK = [
211 'refs',
212 'logs/refs',
213 'info/refs',
214 'info/exclude',
215 'objects',
216 'hooks',
217 'packed-refs',
218 'remotes',
219 'rr-cache',
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000220 ]
221 FILES_TO_COPY = ['config', 'HEAD']
222
223 def _create_checkout(self):
224 """Creates a checkout to use for cherry-picking.
225
226 This creates a checkout similarly to git-new-workdir. Most of the .git
227 directory is shared with the |self._parent_repo| using symlinks. This
228 differs from git-new-workdir in that the config is forked instead of shared.
229 This is so the new workdir can be a sparse checkout without affecting
230 |self._parent_repo|.
231 """
rob@robwu.nl93aa0732016-01-27 19:22:28 +0000232 parent_git_dir = os.path.join(self._parent_repo, self._run_git_command(
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000233 ['rev-parse', '--git-dir']).strip())
234 self._workdir = tempfile.mkdtemp(prefix='drover_%s_' % self._branch)
235 logging.debug('Creating checkout in %s', self._workdir)
236 git_dir = os.path.join(self._workdir, '.git')
237 git_common.make_workdir_common(parent_git_dir, git_dir, self.FILES_TO_LINK,
scottmg@chromium.orgd4218d42015-10-07 23:49:20 +0000238 self.FILES_TO_COPY, mk_symlink)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000239 self._run_git_command(['config', 'core.sparsecheckout', 'true'])
240 with open(os.path.join(git_dir, 'info', 'sparse-checkout'), 'w') as f:
sammc@chromium.org89901892015-11-03 00:57:48 +0000241 f.write('/codereview.settings')
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000242
243 branch_name = os.path.split(self._workdir)[-1]
244 self._run_git_command(['checkout', '-b', branch_name, self._branch_ref])
245 self._branch_name = branch_name
246
sammc@chromium.org89901892015-11-03 00:57:48 +0000247 def _perform_cherry_pick(self):
248 try:
249 self._run_git_command(['cherry-pick', '-x', self._revision],
250 error_message='Patch failed to apply')
251 except Error:
252 self._prepare_manual_resolve()
253 self._save_state()
254 self._needs_cleanup = False
255 raise PatchError(self._workdir)
256
257 def _save_state(self):
258 """Saves the state of this Drover instances to the workdir."""
259 with open(os.path.join(self._workdir, '.git', 'drover'), 'wb') as f:
260 cPickle.dump(self, f)
261
262 def _prepare_manual_resolve(self):
263 """Prepare the workdir for the user to manually resolve the cherry-pick."""
264 # Files that have been deleted between branch and cherry-pick will not have
265 # their skip-worktree bit set so set it manually for those files to avoid
266 # git status incorrectly listing them as unstaged deletes.
Aaron Gable7817f022017-12-12 09:43:17 -0800267 repo_status = self._run_git_command(
268 ['-c', 'core.quotePath=false', 'status', '--porcelain']).splitlines()
sammc@chromium.org89901892015-11-03 00:57:48 +0000269 extra_files = [f[3:] for f in repo_status if f[:2] == ' D']
270 if extra_files:
scottmg02056562016-06-15 17:21:04 -0700271 self._run_git_command_with_stdin(
272 ['update-index', '--skip-worktree', '--stdin'],
273 stdin='\n'.join(extra_files) + '\n')
sammc@chromium.org89901892015-11-03 00:57:48 +0000274
275 def _upload_and_land(self):
276 if self._dry_run:
277 logging.info('--dry_run enabled; not landing.')
278 return True
279
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000280 self._run_git_command(['reset', '--hard'])
Aaron Gablec7e84d02017-04-27 14:42:43 -0700281
282 author = self._run_git_command(['log', '-1', '--format=%ae']).strip()
Aaron Gable897bf0b2017-06-29 11:56:06 -0700283 self._run_git_command(['cl', 'upload', '--send-mail', '--tbrs', author],
sammc@chromium.org89901892015-11-03 00:57:48 +0000284 error_message='Upload failed',
285 interactive=True)
286
287 if not self._confirm('About to land on %s.' % self._branch):
288 return False
289 self._run_git_command(['cl', 'land', '--bypass-hooks'], interactive=True)
290 return True
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000291
292 def _run_git_command(self, args, error_message=None, interactive=False):
293 """Runs a git command.
294
295 Args:
296 args: A list of strings containing the args to pass to git.
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000297 error_message: A string containing the error message to report if the
298 command fails.
sammc@chromium.org89901892015-11-03 00:57:48 +0000299 interactive: A bool containing whether the command requires user
300 interaction. If false, the command will be provided with no input and
301 the output is captured.
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000302
Aaron Gablec7e84d02017-04-27 14:42:43 -0700303 Returns:
304 stdout as a string, or stdout interleaved with stderr if self._verbose
305
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000306 Raises:
307 Error: The command failed to complete successfully.
308 """
309 cwd = self._workdir if self._workdir else self._parent_repo
310 logging.debug('Running git %s (cwd %r)', ' '.join('%s' % arg
311 for arg in args), cwd)
312
313 run = subprocess.check_call if interactive else subprocess.check_output
314
sammc@chromium.org89901892015-11-03 00:57:48 +0000315 # Discard stderr unless verbose is enabled.
316 stderr = None if self._verbose else _DEV_NULL_FILE
317
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000318 try:
sammc@chromium.org89901892015-11-03 00:57:48 +0000319 return run(['git'] + args, shell=False, cwd=cwd, stderr=stderr)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000320 except (OSError, subprocess.CalledProcessError) as e:
321 if error_message:
322 raise Error(error_message)
323 else:
324 raise Error('Command %r failed: %s' % (' '.join(args), e))
325
scottmg02056562016-06-15 17:21:04 -0700326 def _run_git_command_with_stdin(self, args, stdin):
327 """Runs a git command with a provided stdin.
328
329 Args:
330 args: A list of strings containing the args to pass to git.
331 stdin: A string to provide on stdin.
332
Aaron Gablec7e84d02017-04-27 14:42:43 -0700333 Returns:
334 stdout as a string, or stdout interleaved with stderr if self._verbose
335
scottmg02056562016-06-15 17:21:04 -0700336 Raises:
337 Error: The command failed to complete successfully.
338 """
339 cwd = self._workdir if self._workdir else self._parent_repo
340 logging.debug('Running git %s (cwd %r)', ' '.join('%s' % arg
341 for arg in args), cwd)
342
343 # Discard stderr unless verbose is enabled.
344 stderr = None if self._verbose else _DEV_NULL_FILE
345
346 try:
347 popen = subprocess.Popen(['git'] + args, shell=False, cwd=cwd,
348 stderr=stderr, stdin=subprocess.PIPE)
349 popen.communicate(stdin)
350 if popen.returncode != 0:
351 raise Error('Command %r failed' % ' '.join(args))
352 except OSError as e:
353 raise Error('Command %r failed: %s' % (' '.join(args), e))
354
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000355
sammc@chromium.org89901892015-11-03 00:57:48 +0000356def cherry_pick_change(branch, revision, parent_repo, dry_run, verbose=False):
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000357 """Cherry-picks a change into a branch.
358
359 Args:
360 branch: A string containing the release branch number to which to
361 cherry-pick.
362 revision: A string containing the revision to cherry-pick. It can be any
363 string that git-rev-parse can identify as referring to a single
364 revision.
365 parent_repo: A string containing the path to the parent repo to use for this
366 cherry-pick.
sammc@chromium.org89901892015-11-03 00:57:48 +0000367 dry_run: A bool containing whether to stop before uploading the
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000368 cherry-pick cl.
sammc@chromium.org89901892015-11-03 00:57:48 +0000369 verbose: A bool containing whether to print verbose logging.
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000370
371 Raises:
372 Error: An error occurred while attempting to cherry-pick |cl| to |branch|.
373 """
sammc@chromium.org89901892015-11-03 00:57:48 +0000374 drover = _Drover(branch, revision, parent_repo, dry_run, verbose)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000375 drover.run()
376
377
sammc@chromium.org89901892015-11-03 00:57:48 +0000378def continue_cherry_pick(workdir):
379 """Continues a cherry-pick that required manual resolution.
380
381 Args:
382 workdir: A string containing the path to the workdir used by drover.
383 """
384 _Drover.resume(workdir)
385
386
387def abort_cherry_pick(workdir):
388 """Aborts a cherry-pick that required manual resolution.
389
390 Args:
391 workdir: A string containing the path to the workdir used by drover.
392 """
393 _Drover.abort(workdir)
394
395
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000396def main():
397 parser = argparse.ArgumentParser(
398 description='Cherry-pick a change into a release branch.')
sammc@chromium.org89901892015-11-03 00:57:48 +0000399 group = parser.add_mutually_exclusive_group(required=True)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000400 parser.add_argument(
401 '--branch',
402 type=str,
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000403 metavar='<branch>',
404 help='the name of the branch to which to cherry-pick; e.g. 1234')
sammc@chromium.org89901892015-11-03 00:57:48 +0000405 group.add_argument(
406 '--cherry-pick',
407 type=str,
408 metavar='<change>',
409 help=('the change to cherry-pick; this can be any string '
410 'that unambiguously refers to a revision not involving HEAD'))
411 group.add_argument(
412 '--continue',
413 type=str,
414 nargs='?',
415 dest='resume',
416 const=os.path.abspath('.'),
417 metavar='path_to_workdir',
418 help='Continue a drover cherry-pick after resolving conflicts')
419 group.add_argument('--abort',
420 type=str,
421 nargs='?',
422 const=os.path.abspath('.'),
423 metavar='path_to_workdir',
424 help='Abort a drover cherry-pick')
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000425 parser.add_argument(
426 '--parent_checkout',
427 type=str,
428 default=os.path.abspath('.'),
429 metavar='<path_to_parent_checkout>',
430 help=('the path to the chromium checkout to use as the source for a '
431 'creating git-new-workdir workdir to use for cherry-picking; '
432 'if unspecified, the current directory is used'))
433 parser.add_argument(
434 '--dry-run',
435 action='store_true',
436 default=False,
437 help=("don't actually upload and land; "
438 "just check that cherry-picking would succeed"))
439 parser.add_argument('-v',
440 '--verbose',
441 action='store_true',
442 default=False,
443 help='show verbose logging')
444 options = parser.parse_args()
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000445 try:
sammc@chromium.org89901892015-11-03 00:57:48 +0000446 if options.resume:
447 _Drover.resume(options.resume)
448 elif options.abort:
449 _Drover.abort(options.abort)
450 else:
451 if not options.branch:
452 parser.error('argument --branch is required for --cherry-pick')
453 cherry_pick_change(options.branch, options.cherry_pick,
454 options.parent_checkout, options.dry_run,
455 options.verbose)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000456 except Error as e:
Raul Tambre80ee78e2019-05-06 22:41:05 +0000457 print('Error:', e.message)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000458 sys.exit(128)
459
460
461if __name__ == '__main__':
462 main()