blob: 52bff63387cbab735c0e0fc6b28f02a53c0413e9 [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:
Johnny(Jianning) Ding08f4d592020-04-14 05:32:09 +0000323 rv = run(['git'] + args, shell=False, cwd=cwd, stderr=stderr)
Edward Lesmesb806e172020-04-28 18:59:38 +0000324 if not interactive and sys.version_info.major == 3:
Johnny(Jianning) Ding08f4d592020-04-14 05:32:09 +0000325 return rv.decode('utf-8', 'ignore')
326 return rv
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000327 except (OSError, subprocess.CalledProcessError) as e:
328 if error_message:
329 raise Error(error_message)
330 else:
331 raise Error('Command %r failed: %s' % (' '.join(args), e))
332
scottmg02056562016-06-15 17:21:04 -0700333 def _run_git_command_with_stdin(self, args, stdin):
334 """Runs a git command with a provided stdin.
335
336 Args:
337 args: A list of strings containing the args to pass to git.
338 stdin: A string to provide on stdin.
339
Aaron Gablec7e84d02017-04-27 14:42:43 -0700340 Returns:
341 stdout as a string, or stdout interleaved with stderr if self._verbose
342
scottmg02056562016-06-15 17:21:04 -0700343 Raises:
344 Error: The command failed to complete successfully.
345 """
346 cwd = self._workdir if self._workdir else self._parent_repo
347 logging.debug('Running git %s (cwd %r)', ' '.join('%s' % arg
348 for arg in args), cwd)
349
350 # Discard stderr unless verbose is enabled.
351 stderr = None if self._verbose else _DEV_NULL_FILE
352
353 try:
354 popen = subprocess.Popen(['git'] + args, shell=False, cwd=cwd,
355 stderr=stderr, stdin=subprocess.PIPE)
356 popen.communicate(stdin)
357 if popen.returncode != 0:
358 raise Error('Command %r failed' % ' '.join(args))
359 except OSError as e:
360 raise Error('Command %r failed: %s' % (' '.join(args), e))
361
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000362
sammc@chromium.org89901892015-11-03 00:57:48 +0000363def cherry_pick_change(branch, revision, parent_repo, dry_run, verbose=False):
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000364 """Cherry-picks a change into a branch.
365
366 Args:
367 branch: A string containing the release branch number to which to
368 cherry-pick.
369 revision: A string containing the revision to cherry-pick. It can be any
370 string that git-rev-parse can identify as referring to a single
371 revision.
372 parent_repo: A string containing the path to the parent repo to use for this
373 cherry-pick.
sammc@chromium.org89901892015-11-03 00:57:48 +0000374 dry_run: A bool containing whether to stop before uploading the
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000375 cherry-pick cl.
sammc@chromium.org89901892015-11-03 00:57:48 +0000376 verbose: A bool containing whether to print verbose logging.
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000377
378 Raises:
379 Error: An error occurred while attempting to cherry-pick |cl| to |branch|.
380 """
sammc@chromium.org89901892015-11-03 00:57:48 +0000381 drover = _Drover(branch, revision, parent_repo, dry_run, verbose)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000382 drover.run()
383
384
sammc@chromium.org89901892015-11-03 00:57:48 +0000385def continue_cherry_pick(workdir):
386 """Continues a cherry-pick that required manual resolution.
387
388 Args:
389 workdir: A string containing the path to the workdir used by drover.
390 """
391 _Drover.resume(workdir)
392
393
394def abort_cherry_pick(workdir):
395 """Aborts a cherry-pick that required manual resolution.
396
397 Args:
398 workdir: A string containing the path to the workdir used by drover.
399 """
400 _Drover.abort(workdir)
401
402
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000403def main():
404 parser = argparse.ArgumentParser(
405 description='Cherry-pick a change into a release branch.')
sammc@chromium.org89901892015-11-03 00:57:48 +0000406 group = parser.add_mutually_exclusive_group(required=True)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000407 parser.add_argument(
408 '--branch',
409 type=str,
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000410 metavar='<branch>',
411 help='the name of the branch to which to cherry-pick; e.g. 1234')
sammc@chromium.org89901892015-11-03 00:57:48 +0000412 group.add_argument(
413 '--cherry-pick',
414 type=str,
415 metavar='<change>',
416 help=('the change to cherry-pick; this can be any string '
417 'that unambiguously refers to a revision not involving HEAD'))
418 group.add_argument(
419 '--continue',
420 type=str,
421 nargs='?',
422 dest='resume',
423 const=os.path.abspath('.'),
424 metavar='path_to_workdir',
425 help='Continue a drover cherry-pick after resolving conflicts')
426 group.add_argument('--abort',
427 type=str,
428 nargs='?',
429 const=os.path.abspath('.'),
430 metavar='path_to_workdir',
431 help='Abort a drover cherry-pick')
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000432 parser.add_argument(
433 '--parent_checkout',
434 type=str,
435 default=os.path.abspath('.'),
436 metavar='<path_to_parent_checkout>',
437 help=('the path to the chromium checkout to use as the source for a '
438 'creating git-new-workdir workdir to use for cherry-picking; '
439 'if unspecified, the current directory is used'))
440 parser.add_argument(
441 '--dry-run',
442 action='store_true',
443 default=False,
444 help=("don't actually upload and land; "
445 "just check that cherry-picking would succeed"))
446 parser.add_argument('-v',
447 '--verbose',
448 action='store_true',
449 default=False,
450 help='show verbose logging')
451 options = parser.parse_args()
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000452 try:
sammc@chromium.org89901892015-11-03 00:57:48 +0000453 if options.resume:
454 _Drover.resume(options.resume)
455 elif options.abort:
456 _Drover.abort(options.abort)
457 else:
458 if not options.branch:
459 parser.error('argument --branch is required for --cherry-pick')
460 cherry_pick_change(options.branch, options.cherry_pick,
461 options.parent_checkout, options.dry_run,
462 options.verbose)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000463 except Error as e:
Edward Lesmesd7d0b902020-10-09 23:14:26 +0000464 print('Error:', e)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000465 sys.exit(128)
466
467
468if __name__ == '__main__':
469 main()