blob: a8651815bf0fc909b7bce908e8a0eaeaa96a1ee5 [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
Edward Lesmesae3586b2020-03-23 21:21:14 +000020import gclient_utils
sammc@chromium.org900a33f2015-09-29 06:57:09 +000021
Edward Lemur6f661162019-10-08 00:17:11 +000022if sys.version_info.major == 2:
23 import cPickle
24else:
25 import pickle as cPickle
sammc@chromium.org900a33f2015-09-29 06:57:09 +000026
27class Error(Exception):
28 pass
29
30
sammc@chromium.org89901892015-11-03 00:57:48 +000031_PATCH_ERROR_MESSAGE = """Patch failed to apply.
32
33A workdir for this cherry-pick has been created in
34 {0}
35
36To continue, resolve the conflicts there and run
37 git drover --continue {0}
38
39To abort this cherry-pick run
40 git drover --abort {0}
41"""
42
43
44class PatchError(Error):
45 """An error indicating that the patch failed to apply."""
46
47 def __init__(self, workdir):
48 super(PatchError, self).__init__(_PATCH_ERROR_MESSAGE.format(workdir))
49
50
51_DEV_NULL_FILE = open(os.devnull, 'w')
52
scottmg@chromium.orgd4218d42015-10-07 23:49:20 +000053if os.name == 'nt':
54 # This is a just-good-enough emulation of os.symlink for drover to work on
55 # Windows. It uses junctioning of directories (most of the contents of
56 # the .git directory), but copies files. Note that we can't use
57 # CreateSymbolicLink or CreateHardLink here, as they both require elevation.
58 # Creating reparse points is what we want for the directories, but doing so
59 # is a relatively messy set of DeviceIoControl work at the API level, so we
60 # simply shell to `mklink /j` instead.
61 def emulate_symlink_windows(source, link_name):
62 if os.path.isdir(source):
63 subprocess.check_call(['mklink', '/j',
64 link_name.replace('/', '\\'),
65 source.replace('/', '\\')],
66 shell=True)
67 else:
68 shutil.copy(source, link_name)
69 mk_symlink = emulate_symlink_windows
70else:
71 mk_symlink = os.symlink
72
73
sammc@chromium.org900a33f2015-09-29 06:57:09 +000074class _Drover(object):
75
sammc@chromium.org89901892015-11-03 00:57:48 +000076 def __init__(self, branch, revision, parent_repo, dry_run, verbose):
sammc@chromium.org900a33f2015-09-29 06:57:09 +000077 self._branch = branch
78 self._branch_ref = 'refs/remotes/branch-heads/%s' % branch
79 self._revision = revision
80 self._parent_repo = os.path.abspath(parent_repo)
81 self._dry_run = dry_run
82 self._workdir = None
83 self._branch_name = None
sammc@chromium.org89901892015-11-03 00:57:48 +000084 self._needs_cleanup = True
85 self._verbose = verbose
86 self._process_options()
87
88 def _process_options(self):
89 if self._verbose:
90 logging.getLogger().setLevel(logging.DEBUG)
91
92
93 @classmethod
94 def resume(cls, workdir):
95 """Continues a cherry-pick that required manual resolution.
96
97 Args:
98 workdir: A string containing the path to the workdir used by drover.
99 """
100 drover = cls._restore_drover(workdir)
101 drover._continue()
102
103 @classmethod
104 def abort(cls, workdir):
105 """Aborts a cherry-pick that required manual resolution.
106
107 Args:
108 workdir: A string containing the path to the workdir used by drover.
109 """
110 drover = cls._restore_drover(workdir)
111 drover._cleanup()
112
113 @staticmethod
114 def _restore_drover(workdir):
115 """Restores a saved drover state contained within a workdir.
116
117 Args:
118 workdir: A string containing the path to the workdir used by drover.
119 """
120 try:
121 with open(os.path.join(workdir, '.git', 'drover'), 'rb') as f:
122 drover = cPickle.load(f)
123 drover._process_options()
124 return drover
125 except (IOError, cPickle.UnpicklingError):
126 raise Error('%r is not git drover workdir' % workdir)
127
128 def _continue(self):
129 if os.path.exists(os.path.join(self._workdir, '.git', 'CHERRY_PICK_HEAD')):
130 self._run_git_command(
131 ['commit', '--no-edit'],
132 error_message='All conflicts must be resolved before continuing')
133
134 if self._upload_and_land():
135 # Only clean up the workdir on success. The manually resolved cherry-pick
136 # can be reused if the user cancels before landing.
137 self._cleanup()
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000138
139 def run(self):
140 """Runs this Drover instance.
141
142 Raises:
143 Error: An error occurred while attempting to cherry-pick this change.
144 """
145 try:
146 self._run_internal()
147 finally:
148 self._cleanup()
149
150 def _run_internal(self):
151 self._check_inputs()
152 if not self._confirm('Going to cherry-pick\n"""\n%s"""\nto %s.' % (
153 self._run_git_command(['show', '-s', self._revision]), self._branch)):
154 return
155 self._create_checkout()
sammc@chromium.org89901892015-11-03 00:57:48 +0000156 self._perform_cherry_pick()
157 self._upload_and_land()
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000158
159 def _cleanup(self):
sammc@chromium.org89901892015-11-03 00:57:48 +0000160 if not self._needs_cleanup:
161 return
162
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000163 if self._workdir:
164 logging.debug('Deleting %s', self._workdir)
scottmg@chromium.orgd4218d42015-10-07 23:49:20 +0000165 if os.name == 'nt':
sammc@chromium.org89901892015-11-03 00:57:48 +0000166 try:
167 # Use rmdir to properly handle the junctions we created.
168 subprocess.check_call(
169 ['rmdir', '/s', '/q', self._workdir], shell=True)
170 except subprocess.CalledProcessError:
171 logging.error(
172 'Failed to delete workdir %r. Please remove it manually.',
173 self._workdir)
scottmg@chromium.orgd4218d42015-10-07 23:49:20 +0000174 else:
175 shutil.rmtree(self._workdir)
sammc@chromium.org89901892015-11-03 00:57:48 +0000176 self._workdir = None
177 if self._branch_name:
178 self._run_git_command(['branch', '-D', self._branch_name])
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000179
180 @staticmethod
181 def _confirm(message):
182 """Show a confirmation prompt with the given message.
183
184 Returns:
185 A bool representing whether the user wishes to continue.
186 """
187 result = ''
188 while result not in ('y', 'n'):
189 try:
Edward Lesmesae3586b2020-03-23 21:21:14 +0000190 result = gclient_utils.AskForData('%s Continue (y/n)? ' % message)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000191 except EOFError:
192 result = 'n'
193 return result == 'y'
194
195 def _check_inputs(self):
196 """Check the input arguments and ensure the parent repo is up to date."""
197
198 if not os.path.isdir(self._parent_repo):
199 raise Error('Invalid parent repo path %r' % self._parent_repo)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000200
201 self._run_git_command(['--help'], error_message='Unable to run git')
202 self._run_git_command(['status'],
203 error_message='%r is not a valid git repo' %
204 os.path.abspath(self._parent_repo))
205 self._run_git_command(['fetch', 'origin'],
206 error_message='Failed to fetch origin')
207 self._run_git_command(
208 ['rev-parse', '%s^{commit}' % self._branch_ref],
209 error_message='Branch %s not found' % self._branch_ref)
210 self._run_git_command(
211 ['rev-parse', '%s^{commit}' % self._revision],
212 error_message='Revision "%s" not found' % self._revision)
213
214 FILES_TO_LINK = [
215 'refs',
216 'logs/refs',
217 'info/refs',
218 'info/exclude',
219 'objects',
220 'hooks',
221 'packed-refs',
222 'remotes',
223 'rr-cache',
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000224 ]
225 FILES_TO_COPY = ['config', 'HEAD']
226
227 def _create_checkout(self):
228 """Creates a checkout to use for cherry-picking.
229
230 This creates a checkout similarly to git-new-workdir. Most of the .git
231 directory is shared with the |self._parent_repo| using symlinks. This
232 differs from git-new-workdir in that the config is forked instead of shared.
233 This is so the new workdir can be a sparse checkout without affecting
234 |self._parent_repo|.
235 """
rob@robwu.nl93aa0732016-01-27 19:22:28 +0000236 parent_git_dir = os.path.join(self._parent_repo, self._run_git_command(
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000237 ['rev-parse', '--git-dir']).strip())
238 self._workdir = tempfile.mkdtemp(prefix='drover_%s_' % self._branch)
239 logging.debug('Creating checkout in %s', self._workdir)
240 git_dir = os.path.join(self._workdir, '.git')
241 git_common.make_workdir_common(parent_git_dir, git_dir, self.FILES_TO_LINK,
scottmg@chromium.orgd4218d42015-10-07 23:49:20 +0000242 self.FILES_TO_COPY, mk_symlink)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000243 self._run_git_command(['config', 'core.sparsecheckout', 'true'])
244 with open(os.path.join(git_dir, 'info', 'sparse-checkout'), 'w') as f:
sammc@chromium.org89901892015-11-03 00:57:48 +0000245 f.write('/codereview.settings')
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000246
247 branch_name = os.path.split(self._workdir)[-1]
248 self._run_git_command(['checkout', '-b', branch_name, self._branch_ref])
249 self._branch_name = branch_name
250
sammc@chromium.org89901892015-11-03 00:57:48 +0000251 def _perform_cherry_pick(self):
252 try:
253 self._run_git_command(['cherry-pick', '-x', self._revision],
254 error_message='Patch failed to apply')
255 except Error:
256 self._prepare_manual_resolve()
257 self._save_state()
258 self._needs_cleanup = False
259 raise PatchError(self._workdir)
260
261 def _save_state(self):
262 """Saves the state of this Drover instances to the workdir."""
263 with open(os.path.join(self._workdir, '.git', 'drover'), 'wb') as f:
264 cPickle.dump(self, f)
265
266 def _prepare_manual_resolve(self):
267 """Prepare the workdir for the user to manually resolve the cherry-pick."""
268 # Files that have been deleted between branch and cherry-pick will not have
269 # their skip-worktree bit set so set it manually for those files to avoid
270 # git status incorrectly listing them as unstaged deletes.
Aaron Gable7817f022017-12-12 09:43:17 -0800271 repo_status = self._run_git_command(
272 ['-c', 'core.quotePath=false', 'status', '--porcelain']).splitlines()
sammc@chromium.org89901892015-11-03 00:57:48 +0000273 extra_files = [f[3:] for f in repo_status if f[:2] == ' D']
274 if extra_files:
Edward Lemur6f661162019-10-08 00:17:11 +0000275 stdin = '\n'.join(extra_files) + '\n'
scottmg02056562016-06-15 17:21:04 -0700276 self._run_git_command_with_stdin(
Edward Lemur6f661162019-10-08 00:17:11 +0000277 ['update-index', '--skip-worktree', '--stdin'], stdin=stdin.encode())
sammc@chromium.org89901892015-11-03 00:57:48 +0000278
279 def _upload_and_land(self):
280 if self._dry_run:
281 logging.info('--dry_run enabled; not landing.')
282 return True
283
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000284 self._run_git_command(['reset', '--hard'])
Aaron Gablec7e84d02017-04-27 14:42:43 -0700285
286 author = self._run_git_command(['log', '-1', '--format=%ae']).strip()
Aaron Gable897bf0b2017-06-29 11:56:06 -0700287 self._run_git_command(['cl', 'upload', '--send-mail', '--tbrs', author],
sammc@chromium.org89901892015-11-03 00:57:48 +0000288 error_message='Upload failed',
289 interactive=True)
290
Aaron Gable6865d132019-12-10 22:33:14 +0000291 if not self._confirm('About to start CQ on %s.' % self._branch):
sammc@chromium.org89901892015-11-03 00:57:48 +0000292 return False
Aaron Gable6865d132019-12-10 22:33:14 +0000293 self._run_git_command(['cl', 'set-commit'], interactive=True)
sammc@chromium.org89901892015-11-03 00:57:48 +0000294 return True
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000295
296 def _run_git_command(self, args, error_message=None, interactive=False):
297 """Runs a git command.
298
299 Args:
300 args: A list of strings containing the args to pass to git.
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000301 error_message: A string containing the error message to report if the
302 command fails.
sammc@chromium.org89901892015-11-03 00:57:48 +0000303 interactive: A bool containing whether the command requires user
304 interaction. If false, the command will be provided with no input and
305 the output is captured.
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000306
Aaron Gablec7e84d02017-04-27 14:42:43 -0700307 Returns:
308 stdout as a string, or stdout interleaved with stderr if self._verbose
309
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000310 Raises:
311 Error: The command failed to complete successfully.
312 """
313 cwd = self._workdir if self._workdir else self._parent_repo
314 logging.debug('Running git %s (cwd %r)', ' '.join('%s' % arg
315 for arg in args), cwd)
316
317 run = subprocess.check_call if interactive else subprocess.check_output
318
sammc@chromium.org89901892015-11-03 00:57:48 +0000319 # Discard stderr unless verbose is enabled.
320 stderr = None if self._verbose else _DEV_NULL_FILE
321
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000322 try:
sammc@chromium.org89901892015-11-03 00:57:48 +0000323 return run(['git'] + args, shell=False, cwd=cwd, stderr=stderr)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000324 except (OSError, subprocess.CalledProcessError) as e:
325 if error_message:
326 raise Error(error_message)
327 else:
328 raise Error('Command %r failed: %s' % (' '.join(args), e))
329
scottmg02056562016-06-15 17:21:04 -0700330 def _run_git_command_with_stdin(self, args, stdin):
331 """Runs a git command with a provided stdin.
332
333 Args:
334 args: A list of strings containing the args to pass to git.
335 stdin: A string to provide on stdin.
336
Aaron Gablec7e84d02017-04-27 14:42:43 -0700337 Returns:
338 stdout as a string, or stdout interleaved with stderr if self._verbose
339
scottmg02056562016-06-15 17:21:04 -0700340 Raises:
341 Error: The command failed to complete successfully.
342 """
343 cwd = self._workdir if self._workdir else self._parent_repo
344 logging.debug('Running git %s (cwd %r)', ' '.join('%s' % arg
345 for arg in args), cwd)
346
347 # Discard stderr unless verbose is enabled.
348 stderr = None if self._verbose else _DEV_NULL_FILE
349
350 try:
351 popen = subprocess.Popen(['git'] + args, shell=False, cwd=cwd,
352 stderr=stderr, stdin=subprocess.PIPE)
353 popen.communicate(stdin)
354 if popen.returncode != 0:
355 raise Error('Command %r failed' % ' '.join(args))
356 except OSError as e:
357 raise Error('Command %r failed: %s' % (' '.join(args), e))
358
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000359
sammc@chromium.org89901892015-11-03 00:57:48 +0000360def cherry_pick_change(branch, revision, parent_repo, dry_run, verbose=False):
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000361 """Cherry-picks a change into a branch.
362
363 Args:
364 branch: A string containing the release branch number to which to
365 cherry-pick.
366 revision: A string containing the revision to cherry-pick. It can be any
367 string that git-rev-parse can identify as referring to a single
368 revision.
369 parent_repo: A string containing the path to the parent repo to use for this
370 cherry-pick.
sammc@chromium.org89901892015-11-03 00:57:48 +0000371 dry_run: A bool containing whether to stop before uploading the
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000372 cherry-pick cl.
sammc@chromium.org89901892015-11-03 00:57:48 +0000373 verbose: A bool containing whether to print verbose logging.
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000374
375 Raises:
376 Error: An error occurred while attempting to cherry-pick |cl| to |branch|.
377 """
sammc@chromium.org89901892015-11-03 00:57:48 +0000378 drover = _Drover(branch, revision, parent_repo, dry_run, verbose)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000379 drover.run()
380
381
sammc@chromium.org89901892015-11-03 00:57:48 +0000382def continue_cherry_pick(workdir):
383 """Continues a cherry-pick that required manual resolution.
384
385 Args:
386 workdir: A string containing the path to the workdir used by drover.
387 """
388 _Drover.resume(workdir)
389
390
391def abort_cherry_pick(workdir):
392 """Aborts a cherry-pick that required manual resolution.
393
394 Args:
395 workdir: A string containing the path to the workdir used by drover.
396 """
397 _Drover.abort(workdir)
398
399
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000400def main():
401 parser = argparse.ArgumentParser(
402 description='Cherry-pick a change into a release branch.')
sammc@chromium.org89901892015-11-03 00:57:48 +0000403 group = parser.add_mutually_exclusive_group(required=True)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000404 parser.add_argument(
405 '--branch',
406 type=str,
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000407 metavar='<branch>',
408 help='the name of the branch to which to cherry-pick; e.g. 1234')
sammc@chromium.org89901892015-11-03 00:57:48 +0000409 group.add_argument(
410 '--cherry-pick',
411 type=str,
412 metavar='<change>',
413 help=('the change to cherry-pick; this can be any string '
414 'that unambiguously refers to a revision not involving HEAD'))
415 group.add_argument(
416 '--continue',
417 type=str,
418 nargs='?',
419 dest='resume',
420 const=os.path.abspath('.'),
421 metavar='path_to_workdir',
422 help='Continue a drover cherry-pick after resolving conflicts')
423 group.add_argument('--abort',
424 type=str,
425 nargs='?',
426 const=os.path.abspath('.'),
427 metavar='path_to_workdir',
428 help='Abort a drover cherry-pick')
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000429 parser.add_argument(
430 '--parent_checkout',
431 type=str,
432 default=os.path.abspath('.'),
433 metavar='<path_to_parent_checkout>',
434 help=('the path to the chromium checkout to use as the source for a '
435 'creating git-new-workdir workdir to use for cherry-picking; '
436 'if unspecified, the current directory is used'))
437 parser.add_argument(
438 '--dry-run',
439 action='store_true',
440 default=False,
441 help=("don't actually upload and land; "
442 "just check that cherry-picking would succeed"))
443 parser.add_argument('-v',
444 '--verbose',
445 action='store_true',
446 default=False,
447 help='show verbose logging')
448 options = parser.parse_args()
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000449 try:
sammc@chromium.org89901892015-11-03 00:57:48 +0000450 if options.resume:
451 _Drover.resume(options.resume)
452 elif options.abort:
453 _Drover.abort(options.abort)
454 else:
455 if not options.branch:
456 parser.error('argument --branch is required for --cherry-pick')
457 cherry_pick_change(options.branch, options.cherry_pick,
458 options.parent_checkout, options.dry_run,
459 options.verbose)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000460 except Error as e:
Raul Tambre80ee78e2019-05-06 22:41:05 +0000461 print('Error:', e.message)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000462 sys.exit(128)
463
464
465if __name__ == '__main__':
466 main()