blob: 12021bf241367cb9c9dfbae129c6776f34be949b [file] [log] [blame]
Edward Lesmes98eda3f2019-08-12 21:09:53 +00001#!/usr/bin/env python
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
10import functools
11import logging
12import os
Aaron Gablec7e84d02017-04-27 14:42:43 -070013import re
sammc@chromium.org900a33f2015-09-29 06:57:09 +000014import shutil
15import subprocess
16import sys
17import tempfile
18
19import git_common
20
Edward Lemur6f661162019-10-08 00:17:11 +000021if sys.version_info.major == 2:
22 import cPickle
23else:
24 import pickle as cPickle
sammc@chromium.org900a33f2015-09-29 06:57:09 +000025
26class Error(Exception):
27 pass
28
29
sammc@chromium.org89901892015-11-03 00:57:48 +000030_PATCH_ERROR_MESSAGE = """Patch failed to apply.
31
32A workdir for this cherry-pick has been created in
33 {0}
34
35To continue, resolve the conflicts there and run
36 git drover --continue {0}
37
38To abort this cherry-pick run
39 git drover --abort {0}
40"""
41
42
43class PatchError(Error):
44 """An error indicating that the patch failed to apply."""
45
46 def __init__(self, workdir):
47 super(PatchError, self).__init__(_PATCH_ERROR_MESSAGE.format(workdir))
48
49
50_DEV_NULL_FILE = open(os.devnull, 'w')
51
scottmg@chromium.orgd4218d42015-10-07 23:49:20 +000052if os.name == 'nt':
53 # This is a just-good-enough emulation of os.symlink for drover to work on
54 # Windows. It uses junctioning of directories (most of the contents of
55 # the .git directory), but copies files. Note that we can't use
56 # CreateSymbolicLink or CreateHardLink here, as they both require elevation.
57 # Creating reparse points is what we want for the directories, but doing so
58 # is a relatively messy set of DeviceIoControl work at the API level, so we
59 # simply shell to `mklink /j` instead.
60 def emulate_symlink_windows(source, link_name):
61 if os.path.isdir(source):
62 subprocess.check_call(['mklink', '/j',
63 link_name.replace('/', '\\'),
64 source.replace('/', '\\')],
65 shell=True)
66 else:
67 shutil.copy(source, link_name)
68 mk_symlink = emulate_symlink_windows
69else:
70 mk_symlink = os.symlink
71
72
Edward Lemur6f661162019-10-08 00:17:11 +000073def _raw_input(message):
74 # Use this so that it can be mocked in tests on Python 2 and 3.
75 if sys.version_info.major == 2:
76 return raw_input(message)
77 return input(message)
78
sammc@chromium.org900a33f2015-09-29 06:57:09 +000079class _Drover(object):
80
sammc@chromium.org89901892015-11-03 00:57:48 +000081 def __init__(self, branch, revision, parent_repo, dry_run, verbose):
sammc@chromium.org900a33f2015-09-29 06:57:09 +000082 self._branch = branch
83 self._branch_ref = 'refs/remotes/branch-heads/%s' % branch
84 self._revision = revision
85 self._parent_repo = os.path.abspath(parent_repo)
86 self._dry_run = dry_run
87 self._workdir = None
88 self._branch_name = None
sammc@chromium.org89901892015-11-03 00:57:48 +000089 self._needs_cleanup = True
90 self._verbose = verbose
91 self._process_options()
92
93 def _process_options(self):
94 if self._verbose:
95 logging.getLogger().setLevel(logging.DEBUG)
96
97
98 @classmethod
99 def resume(cls, workdir):
100 """Continues a cherry-pick that required manual resolution.
101
102 Args:
103 workdir: A string containing the path to the workdir used by drover.
104 """
105 drover = cls._restore_drover(workdir)
106 drover._continue()
107
108 @classmethod
109 def abort(cls, workdir):
110 """Aborts a cherry-pick that required manual resolution.
111
112 Args:
113 workdir: A string containing the path to the workdir used by drover.
114 """
115 drover = cls._restore_drover(workdir)
116 drover._cleanup()
117
118 @staticmethod
119 def _restore_drover(workdir):
120 """Restores a saved drover state contained within a workdir.
121
122 Args:
123 workdir: A string containing the path to the workdir used by drover.
124 """
125 try:
126 with open(os.path.join(workdir, '.git', 'drover'), 'rb') as f:
127 drover = cPickle.load(f)
128 drover._process_options()
129 return drover
130 except (IOError, cPickle.UnpicklingError):
131 raise Error('%r is not git drover workdir' % workdir)
132
133 def _continue(self):
134 if os.path.exists(os.path.join(self._workdir, '.git', 'CHERRY_PICK_HEAD')):
135 self._run_git_command(
136 ['commit', '--no-edit'],
137 error_message='All conflicts must be resolved before continuing')
138
139 if self._upload_and_land():
140 # Only clean up the workdir on success. The manually resolved cherry-pick
141 # can be reused if the user cancels before landing.
142 self._cleanup()
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000143
144 def run(self):
145 """Runs this Drover instance.
146
147 Raises:
148 Error: An error occurred while attempting to cherry-pick this change.
149 """
150 try:
151 self._run_internal()
152 finally:
153 self._cleanup()
154
155 def _run_internal(self):
156 self._check_inputs()
157 if not self._confirm('Going to cherry-pick\n"""\n%s"""\nto %s.' % (
158 self._run_git_command(['show', '-s', self._revision]), self._branch)):
159 return
160 self._create_checkout()
sammc@chromium.org89901892015-11-03 00:57:48 +0000161 self._perform_cherry_pick()
162 self._upload_and_land()
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000163
164 def _cleanup(self):
sammc@chromium.org89901892015-11-03 00:57:48 +0000165 if not self._needs_cleanup:
166 return
167
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000168 if self._workdir:
169 logging.debug('Deleting %s', self._workdir)
scottmg@chromium.orgd4218d42015-10-07 23:49:20 +0000170 if os.name == 'nt':
sammc@chromium.org89901892015-11-03 00:57:48 +0000171 try:
172 # Use rmdir to properly handle the junctions we created.
173 subprocess.check_call(
174 ['rmdir', '/s', '/q', self._workdir], shell=True)
175 except subprocess.CalledProcessError:
176 logging.error(
177 'Failed to delete workdir %r. Please remove it manually.',
178 self._workdir)
scottmg@chromium.orgd4218d42015-10-07 23:49:20 +0000179 else:
180 shutil.rmtree(self._workdir)
sammc@chromium.org89901892015-11-03 00:57:48 +0000181 self._workdir = None
182 if self._branch_name:
183 self._run_git_command(['branch', '-D', self._branch_name])
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000184
185 @staticmethod
186 def _confirm(message):
187 """Show a confirmation prompt with the given message.
188
189 Returns:
190 A bool representing whether the user wishes to continue.
191 """
192 result = ''
193 while result not in ('y', 'n'):
194 try:
Edward Lemur6f661162019-10-08 00:17:11 +0000195 result = _raw_input('%s Continue (y/n)? ' % message)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000196 except EOFError:
197 result = 'n'
198 return result == 'y'
199
200 def _check_inputs(self):
201 """Check the input arguments and ensure the parent repo is up to date."""
202
203 if not os.path.isdir(self._parent_repo):
204 raise Error('Invalid parent repo path %r' % self._parent_repo)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000205
206 self._run_git_command(['--help'], error_message='Unable to run git')
207 self._run_git_command(['status'],
208 error_message='%r is not a valid git repo' %
209 os.path.abspath(self._parent_repo))
210 self._run_git_command(['fetch', 'origin'],
211 error_message='Failed to fetch origin')
212 self._run_git_command(
213 ['rev-parse', '%s^{commit}' % self._branch_ref],
214 error_message='Branch %s not found' % self._branch_ref)
215 self._run_git_command(
216 ['rev-parse', '%s^{commit}' % self._revision],
217 error_message='Revision "%s" not found' % self._revision)
218
219 FILES_TO_LINK = [
220 'refs',
221 'logs/refs',
222 'info/refs',
223 'info/exclude',
224 'objects',
225 'hooks',
226 'packed-refs',
227 'remotes',
228 'rr-cache',
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000229 ]
230 FILES_TO_COPY = ['config', 'HEAD']
231
232 def _create_checkout(self):
233 """Creates a checkout to use for cherry-picking.
234
235 This creates a checkout similarly to git-new-workdir. Most of the .git
236 directory is shared with the |self._parent_repo| using symlinks. This
237 differs from git-new-workdir in that the config is forked instead of shared.
238 This is so the new workdir can be a sparse checkout without affecting
239 |self._parent_repo|.
240 """
rob@robwu.nl93aa0732016-01-27 19:22:28 +0000241 parent_git_dir = os.path.join(self._parent_repo, self._run_git_command(
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000242 ['rev-parse', '--git-dir']).strip())
243 self._workdir = tempfile.mkdtemp(prefix='drover_%s_' % self._branch)
244 logging.debug('Creating checkout in %s', self._workdir)
245 git_dir = os.path.join(self._workdir, '.git')
246 git_common.make_workdir_common(parent_git_dir, git_dir, self.FILES_TO_LINK,
scottmg@chromium.orgd4218d42015-10-07 23:49:20 +0000247 self.FILES_TO_COPY, mk_symlink)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000248 self._run_git_command(['config', 'core.sparsecheckout', 'true'])
249 with open(os.path.join(git_dir, 'info', 'sparse-checkout'), 'w') as f:
sammc@chromium.org89901892015-11-03 00:57:48 +0000250 f.write('/codereview.settings')
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000251
252 branch_name = os.path.split(self._workdir)[-1]
253 self._run_git_command(['checkout', '-b', branch_name, self._branch_ref])
254 self._branch_name = branch_name
255
sammc@chromium.org89901892015-11-03 00:57:48 +0000256 def _perform_cherry_pick(self):
257 try:
258 self._run_git_command(['cherry-pick', '-x', self._revision],
259 error_message='Patch failed to apply')
260 except Error:
261 self._prepare_manual_resolve()
262 self._save_state()
263 self._needs_cleanup = False
264 raise PatchError(self._workdir)
265
266 def _save_state(self):
267 """Saves the state of this Drover instances to the workdir."""
268 with open(os.path.join(self._workdir, '.git', 'drover'), 'wb') as f:
269 cPickle.dump(self, f)
270
271 def _prepare_manual_resolve(self):
272 """Prepare the workdir for the user to manually resolve the cherry-pick."""
273 # Files that have been deleted between branch and cherry-pick will not have
274 # their skip-worktree bit set so set it manually for those files to avoid
275 # git status incorrectly listing them as unstaged deletes.
Aaron Gable7817f022017-12-12 09:43:17 -0800276 repo_status = self._run_git_command(
277 ['-c', 'core.quotePath=false', 'status', '--porcelain']).splitlines()
sammc@chromium.org89901892015-11-03 00:57:48 +0000278 extra_files = [f[3:] for f in repo_status if f[:2] == ' D']
279 if extra_files:
Edward Lemur6f661162019-10-08 00:17:11 +0000280 stdin = '\n'.join(extra_files) + '\n'
scottmg02056562016-06-15 17:21:04 -0700281 self._run_git_command_with_stdin(
Edward Lemur6f661162019-10-08 00:17:11 +0000282 ['update-index', '--skip-worktree', '--stdin'], stdin=stdin.encode())
sammc@chromium.org89901892015-11-03 00:57:48 +0000283
284 def _upload_and_land(self):
285 if self._dry_run:
286 logging.info('--dry_run enabled; not landing.')
287 return True
288
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000289 self._run_git_command(['reset', '--hard'])
Aaron Gablec7e84d02017-04-27 14:42:43 -0700290
291 author = self._run_git_command(['log', '-1', '--format=%ae']).strip()
Aaron Gable897bf0b2017-06-29 11:56:06 -0700292 self._run_git_command(['cl', 'upload', '--send-mail', '--tbrs', author],
sammc@chromium.org89901892015-11-03 00:57:48 +0000293 error_message='Upload failed',
294 interactive=True)
295
296 if not self._confirm('About to land on %s.' % self._branch):
297 return False
298 self._run_git_command(['cl', 'land', '--bypass-hooks'], interactive=True)
299 return True
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000300
301 def _run_git_command(self, args, error_message=None, interactive=False):
302 """Runs a git command.
303
304 Args:
305 args: A list of strings containing the args to pass to git.
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000306 error_message: A string containing the error message to report if the
307 command fails.
sammc@chromium.org89901892015-11-03 00:57:48 +0000308 interactive: A bool containing whether the command requires user
309 interaction. If false, the command will be provided with no input and
310 the output is captured.
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000311
Aaron Gablec7e84d02017-04-27 14:42:43 -0700312 Returns:
313 stdout as a string, or stdout interleaved with stderr if self._verbose
314
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000315 Raises:
316 Error: The command failed to complete successfully.
317 """
318 cwd = self._workdir if self._workdir else self._parent_repo
319 logging.debug('Running git %s (cwd %r)', ' '.join('%s' % arg
320 for arg in args), cwd)
321
322 run = subprocess.check_call if interactive else subprocess.check_output
323
sammc@chromium.org89901892015-11-03 00:57:48 +0000324 # Discard stderr unless verbose is enabled.
325 stderr = None if self._verbose else _DEV_NULL_FILE
326
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000327 try:
sammc@chromium.org89901892015-11-03 00:57:48 +0000328 return run(['git'] + args, shell=False, cwd=cwd, stderr=stderr)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000329 except (OSError, subprocess.CalledProcessError) as e:
330 if error_message:
331 raise Error(error_message)
332 else:
333 raise Error('Command %r failed: %s' % (' '.join(args), e))
334
scottmg02056562016-06-15 17:21:04 -0700335 def _run_git_command_with_stdin(self, args, stdin):
336 """Runs a git command with a provided stdin.
337
338 Args:
339 args: A list of strings containing the args to pass to git.
340 stdin: A string to provide on stdin.
341
Aaron Gablec7e84d02017-04-27 14:42:43 -0700342 Returns:
343 stdout as a string, or stdout interleaved with stderr if self._verbose
344
scottmg02056562016-06-15 17:21:04 -0700345 Raises:
346 Error: The command failed to complete successfully.
347 """
348 cwd = self._workdir if self._workdir else self._parent_repo
349 logging.debug('Running git %s (cwd %r)', ' '.join('%s' % arg
350 for arg in args), cwd)
351
352 # Discard stderr unless verbose is enabled.
353 stderr = None if self._verbose else _DEV_NULL_FILE
354
355 try:
356 popen = subprocess.Popen(['git'] + args, shell=False, cwd=cwd,
357 stderr=stderr, stdin=subprocess.PIPE)
358 popen.communicate(stdin)
359 if popen.returncode != 0:
360 raise Error('Command %r failed' % ' '.join(args))
361 except OSError as e:
362 raise Error('Command %r failed: %s' % (' '.join(args), e))
363
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000364
sammc@chromium.org89901892015-11-03 00:57:48 +0000365def cherry_pick_change(branch, revision, parent_repo, dry_run, verbose=False):
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000366 """Cherry-picks a change into a branch.
367
368 Args:
369 branch: A string containing the release branch number to which to
370 cherry-pick.
371 revision: A string containing the revision to cherry-pick. It can be any
372 string that git-rev-parse can identify as referring to a single
373 revision.
374 parent_repo: A string containing the path to the parent repo to use for this
375 cherry-pick.
sammc@chromium.org89901892015-11-03 00:57:48 +0000376 dry_run: A bool containing whether to stop before uploading the
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000377 cherry-pick cl.
sammc@chromium.org89901892015-11-03 00:57:48 +0000378 verbose: A bool containing whether to print verbose logging.
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000379
380 Raises:
381 Error: An error occurred while attempting to cherry-pick |cl| to |branch|.
382 """
sammc@chromium.org89901892015-11-03 00:57:48 +0000383 drover = _Drover(branch, revision, parent_repo, dry_run, verbose)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000384 drover.run()
385
386
sammc@chromium.org89901892015-11-03 00:57:48 +0000387def continue_cherry_pick(workdir):
388 """Continues 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.resume(workdir)
394
395
396def abort_cherry_pick(workdir):
397 """Aborts a cherry-pick that required manual resolution.
398
399 Args:
400 workdir: A string containing the path to the workdir used by drover.
401 """
402 _Drover.abort(workdir)
403
404
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000405def main():
406 parser = argparse.ArgumentParser(
407 description='Cherry-pick a change into a release branch.')
sammc@chromium.org89901892015-11-03 00:57:48 +0000408 group = parser.add_mutually_exclusive_group(required=True)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000409 parser.add_argument(
410 '--branch',
411 type=str,
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000412 metavar='<branch>',
413 help='the name of the branch to which to cherry-pick; e.g. 1234')
sammc@chromium.org89901892015-11-03 00:57:48 +0000414 group.add_argument(
415 '--cherry-pick',
416 type=str,
417 metavar='<change>',
418 help=('the change to cherry-pick; this can be any string '
419 'that unambiguously refers to a revision not involving HEAD'))
420 group.add_argument(
421 '--continue',
422 type=str,
423 nargs='?',
424 dest='resume',
425 const=os.path.abspath('.'),
426 metavar='path_to_workdir',
427 help='Continue a drover cherry-pick after resolving conflicts')
428 group.add_argument('--abort',
429 type=str,
430 nargs='?',
431 const=os.path.abspath('.'),
432 metavar='path_to_workdir',
433 help='Abort a drover cherry-pick')
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000434 parser.add_argument(
435 '--parent_checkout',
436 type=str,
437 default=os.path.abspath('.'),
438 metavar='<path_to_parent_checkout>',
439 help=('the path to the chromium checkout to use as the source for a '
440 'creating git-new-workdir workdir to use for cherry-picking; '
441 'if unspecified, the current directory is used'))
442 parser.add_argument(
443 '--dry-run',
444 action='store_true',
445 default=False,
446 help=("don't actually upload and land; "
447 "just check that cherry-picking would succeed"))
448 parser.add_argument('-v',
449 '--verbose',
450 action='store_true',
451 default=False,
452 help='show verbose logging')
453 options = parser.parse_args()
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000454 try:
sammc@chromium.org89901892015-11-03 00:57:48 +0000455 if options.resume:
456 _Drover.resume(options.resume)
457 elif options.abort:
458 _Drover.abort(options.abort)
459 else:
460 if not options.branch:
461 parser.error('argument --branch is required for --cherry-pick')
462 cherry_pick_change(options.branch, options.cherry_pick,
463 options.parent_checkout, options.dry_run,
464 options.verbose)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000465 except Error as e:
Raul Tambre80ee78e2019-05-06 22:41:05 +0000466 print('Error:', e.message)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000467 sys.exit(128)
468
469
470if __name__ == '__main__':
471 main()