blob: 05bde9208dcbd9f8616f4f9664239a03799eacb1 [file] [log] [blame]
sammc@chromium.org900a33f2015-09-29 06:57:09 +00001#!/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
7import argparse
sammc@chromium.org89901892015-11-03 00:57:48 +00008import cPickle
sammc@chromium.org900a33f2015-09-29 06:57:09 +00009import functools
10import logging
11import os
Aaron Gablec7e84d02017-04-27 14:42:43 -070012import re
sammc@chromium.org900a33f2015-09-29 06:57:09 +000013import shutil
14import subprocess
15import sys
16import tempfile
17
18import git_common
19
20
21class Error(Exception):
22 pass
23
24
sammc@chromium.org89901892015-11-03 00:57:48 +000025_PATCH_ERROR_MESSAGE = """Patch failed to apply.
26
27A workdir for this cherry-pick has been created in
28 {0}
29
30To continue, resolve the conflicts there and run
31 git drover --continue {0}
32
33To abort this cherry-pick run
34 git drover --abort {0}
35"""
36
37
38class PatchError(Error):
39 """An error indicating that the patch failed to apply."""
40
41 def __init__(self, workdir):
42 super(PatchError, self).__init__(_PATCH_ERROR_MESSAGE.format(workdir))
43
44
45_DEV_NULL_FILE = open(os.devnull, 'w')
46
scottmg@chromium.orgd4218d42015-10-07 23:49:20 +000047if os.name == 'nt':
48 # This is a just-good-enough emulation of os.symlink for drover to work on
49 # Windows. It uses junctioning of directories (most of the contents of
50 # the .git directory), but copies files. Note that we can't use
51 # CreateSymbolicLink or CreateHardLink here, as they both require elevation.
52 # Creating reparse points is what we want for the directories, but doing so
53 # is a relatively messy set of DeviceIoControl work at the API level, so we
54 # simply shell to `mklink /j` instead.
55 def emulate_symlink_windows(source, link_name):
56 if os.path.isdir(source):
57 subprocess.check_call(['mklink', '/j',
58 link_name.replace('/', '\\'),
59 source.replace('/', '\\')],
60 shell=True)
61 else:
62 shutil.copy(source, link_name)
63 mk_symlink = emulate_symlink_windows
64else:
65 mk_symlink = os.symlink
66
67
sammc@chromium.org900a33f2015-09-29 06:57:09 +000068class _Drover(object):
69
sammc@chromium.org89901892015-11-03 00:57:48 +000070 def __init__(self, branch, revision, parent_repo, dry_run, verbose):
sammc@chromium.org900a33f2015-09-29 06:57:09 +000071 self._branch = branch
72 self._branch_ref = 'refs/remotes/branch-heads/%s' % branch
73 self._revision = revision
74 self._parent_repo = os.path.abspath(parent_repo)
75 self._dry_run = dry_run
76 self._workdir = None
77 self._branch_name = None
sammc@chromium.org89901892015-11-03 00:57:48 +000078 self._needs_cleanup = True
79 self._verbose = verbose
80 self._process_options()
81
82 def _process_options(self):
83 if self._verbose:
84 logging.getLogger().setLevel(logging.DEBUG)
85
86
87 @classmethod
88 def resume(cls, workdir):
89 """Continues a cherry-pick that required manual resolution.
90
91 Args:
92 workdir: A string containing the path to the workdir used by drover.
93 """
94 drover = cls._restore_drover(workdir)
95 drover._continue()
96
97 @classmethod
98 def abort(cls, workdir):
99 """Aborts a cherry-pick that required manual resolution.
100
101 Args:
102 workdir: A string containing the path to the workdir used by drover.
103 """
104 drover = cls._restore_drover(workdir)
105 drover._cleanup()
106
107 @staticmethod
108 def _restore_drover(workdir):
109 """Restores a saved drover state contained within a workdir.
110
111 Args:
112 workdir: A string containing the path to the workdir used by drover.
113 """
114 try:
115 with open(os.path.join(workdir, '.git', 'drover'), 'rb') as f:
116 drover = cPickle.load(f)
117 drover._process_options()
118 return drover
119 except (IOError, cPickle.UnpicklingError):
120 raise Error('%r is not git drover workdir' % workdir)
121
122 def _continue(self):
123 if os.path.exists(os.path.join(self._workdir, '.git', 'CHERRY_PICK_HEAD')):
124 self._run_git_command(
125 ['commit', '--no-edit'],
126 error_message='All conflicts must be resolved before continuing')
127
128 if self._upload_and_land():
129 # Only clean up the workdir on success. The manually resolved cherry-pick
130 # can be reused if the user cancels before landing.
131 self._cleanup()
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000132
133 def run(self):
134 """Runs this Drover instance.
135
136 Raises:
137 Error: An error occurred while attempting to cherry-pick this change.
138 """
139 try:
140 self._run_internal()
141 finally:
142 self._cleanup()
143
144 def _run_internal(self):
145 self._check_inputs()
146 if not self._confirm('Going to cherry-pick\n"""\n%s"""\nto %s.' % (
147 self._run_git_command(['show', '-s', self._revision]), self._branch)):
148 return
149 self._create_checkout()
sammc@chromium.org89901892015-11-03 00:57:48 +0000150 self._perform_cherry_pick()
151 self._upload_and_land()
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000152
153 def _cleanup(self):
sammc@chromium.org89901892015-11-03 00:57:48 +0000154 if not self._needs_cleanup:
155 return
156
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000157 if self._workdir:
158 logging.debug('Deleting %s', self._workdir)
scottmg@chromium.orgd4218d42015-10-07 23:49:20 +0000159 if os.name == 'nt':
sammc@chromium.org89901892015-11-03 00:57:48 +0000160 try:
161 # Use rmdir to properly handle the junctions we created.
162 subprocess.check_call(
163 ['rmdir', '/s', '/q', self._workdir], shell=True)
164 except subprocess.CalledProcessError:
165 logging.error(
166 'Failed to delete workdir %r. Please remove it manually.',
167 self._workdir)
scottmg@chromium.orgd4218d42015-10-07 23:49:20 +0000168 else:
169 shutil.rmtree(self._workdir)
sammc@chromium.org89901892015-11-03 00:57:48 +0000170 self._workdir = None
171 if self._branch_name:
172 self._run_git_command(['branch', '-D', self._branch_name])
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000173
174 @staticmethod
175 def _confirm(message):
176 """Show a confirmation prompt with the given message.
177
178 Returns:
179 A bool representing whether the user wishes to continue.
180 """
181 result = ''
182 while result not in ('y', 'n'):
183 try:
184 result = raw_input('%s Continue (y/n)? ' % message)
185 except EOFError:
186 result = 'n'
187 return result == 'y'
188
189 def _check_inputs(self):
190 """Check the input arguments and ensure the parent repo is up to date."""
191
192 if not os.path.isdir(self._parent_repo):
193 raise Error('Invalid parent repo path %r' % self._parent_repo)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000194
195 self._run_git_command(['--help'], error_message='Unable to run git')
196 self._run_git_command(['status'],
197 error_message='%r is not a valid git repo' %
198 os.path.abspath(self._parent_repo))
199 self._run_git_command(['fetch', 'origin'],
200 error_message='Failed to fetch origin')
201 self._run_git_command(
202 ['rev-parse', '%s^{commit}' % self._branch_ref],
203 error_message='Branch %s not found' % self._branch_ref)
204 self._run_git_command(
205 ['rev-parse', '%s^{commit}' % self._revision],
206 error_message='Revision "%s" not found' % self._revision)
207
208 FILES_TO_LINK = [
209 'refs',
210 'logs/refs',
211 'info/refs',
212 'info/exclude',
213 'objects',
214 'hooks',
215 'packed-refs',
216 'remotes',
217 'rr-cache',
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000218 ]
219 FILES_TO_COPY = ['config', 'HEAD']
220
221 def _create_checkout(self):
222 """Creates a checkout to use for cherry-picking.
223
224 This creates a checkout similarly to git-new-workdir. Most of the .git
225 directory is shared with the |self._parent_repo| using symlinks. This
226 differs from git-new-workdir in that the config is forked instead of shared.
227 This is so the new workdir can be a sparse checkout without affecting
228 |self._parent_repo|.
229 """
rob@robwu.nl93aa0732016-01-27 19:22:28 +0000230 parent_git_dir = os.path.join(self._parent_repo, self._run_git_command(
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000231 ['rev-parse', '--git-dir']).strip())
232 self._workdir = tempfile.mkdtemp(prefix='drover_%s_' % self._branch)
233 logging.debug('Creating checkout in %s', self._workdir)
234 git_dir = os.path.join(self._workdir, '.git')
235 git_common.make_workdir_common(parent_git_dir, git_dir, self.FILES_TO_LINK,
scottmg@chromium.orgd4218d42015-10-07 23:49:20 +0000236 self.FILES_TO_COPY, mk_symlink)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000237 self._run_git_command(['config', 'core.sparsecheckout', 'true'])
238 with open(os.path.join(git_dir, 'info', 'sparse-checkout'), 'w') as f:
sammc@chromium.org89901892015-11-03 00:57:48 +0000239 f.write('/codereview.settings')
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000240
241 branch_name = os.path.split(self._workdir)[-1]
242 self._run_git_command(['checkout', '-b', branch_name, self._branch_ref])
243 self._branch_name = branch_name
244
sammc@chromium.org89901892015-11-03 00:57:48 +0000245 def _perform_cherry_pick(self):
246 try:
247 self._run_git_command(['cherry-pick', '-x', self._revision],
248 error_message='Patch failed to apply')
249 except Error:
250 self._prepare_manual_resolve()
251 self._save_state()
252 self._needs_cleanup = False
253 raise PatchError(self._workdir)
254
255 def _save_state(self):
256 """Saves the state of this Drover instances to the workdir."""
257 with open(os.path.join(self._workdir, '.git', 'drover'), 'wb') as f:
258 cPickle.dump(self, f)
259
260 def _prepare_manual_resolve(self):
261 """Prepare the workdir for the user to manually resolve the cherry-pick."""
262 # Files that have been deleted between branch and cherry-pick will not have
263 # their skip-worktree bit set so set it manually for those files to avoid
264 # git status incorrectly listing them as unstaged deletes.
Aaron Gable7817f022017-12-12 09:43:17 -0800265 repo_status = self._run_git_command(
266 ['-c', 'core.quotePath=false', 'status', '--porcelain']).splitlines()
sammc@chromium.org89901892015-11-03 00:57:48 +0000267 extra_files = [f[3:] for f in repo_status if f[:2] == ' D']
268 if extra_files:
scottmg02056562016-06-15 17:21:04 -0700269 self._run_git_command_with_stdin(
270 ['update-index', '--skip-worktree', '--stdin'],
271 stdin='\n'.join(extra_files) + '\n')
sammc@chromium.org89901892015-11-03 00:57:48 +0000272
273 def _upload_and_land(self):
274 if self._dry_run:
275 logging.info('--dry_run enabled; not landing.')
276 return True
277
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000278 self._run_git_command(['reset', '--hard'])
Aaron Gablec7e84d02017-04-27 14:42:43 -0700279
280 author = self._run_git_command(['log', '-1', '--format=%ae']).strip()
Aaron Gable897bf0b2017-06-29 11:56:06 -0700281 self._run_git_command(['cl', 'upload', '--send-mail', '--tbrs', author],
sammc@chromium.org89901892015-11-03 00:57:48 +0000282 error_message='Upload failed',
283 interactive=True)
284
285 if not self._confirm('About to land on %s.' % self._branch):
286 return False
287 self._run_git_command(['cl', 'land', '--bypass-hooks'], interactive=True)
288 return True
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000289
290 def _run_git_command(self, args, error_message=None, interactive=False):
291 """Runs a git command.
292
293 Args:
294 args: A list of strings containing the args to pass to git.
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000295 error_message: A string containing the error message to report if the
296 command fails.
sammc@chromium.org89901892015-11-03 00:57:48 +0000297 interactive: A bool containing whether the command requires user
298 interaction. If false, the command will be provided with no input and
299 the output is captured.
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000300
Aaron Gablec7e84d02017-04-27 14:42:43 -0700301 Returns:
302 stdout as a string, or stdout interleaved with stderr if self._verbose
303
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000304 Raises:
305 Error: The command failed to complete successfully.
306 """
307 cwd = self._workdir if self._workdir else self._parent_repo
308 logging.debug('Running git %s (cwd %r)', ' '.join('%s' % arg
309 for arg in args), cwd)
310
311 run = subprocess.check_call if interactive else subprocess.check_output
312
sammc@chromium.org89901892015-11-03 00:57:48 +0000313 # Discard stderr unless verbose is enabled.
314 stderr = None if self._verbose else _DEV_NULL_FILE
315
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000316 try:
sammc@chromium.org89901892015-11-03 00:57:48 +0000317 return run(['git'] + args, shell=False, cwd=cwd, stderr=stderr)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000318 except (OSError, subprocess.CalledProcessError) as e:
319 if error_message:
320 raise Error(error_message)
321 else:
322 raise Error('Command %r failed: %s' % (' '.join(args), e))
323
scottmg02056562016-06-15 17:21:04 -0700324 def _run_git_command_with_stdin(self, args, stdin):
325 """Runs a git command with a provided stdin.
326
327 Args:
328 args: A list of strings containing the args to pass to git.
329 stdin: A string to provide on stdin.
330
Aaron Gablec7e84d02017-04-27 14:42:43 -0700331 Returns:
332 stdout as a string, or stdout interleaved with stderr if self._verbose
333
scottmg02056562016-06-15 17:21:04 -0700334 Raises:
335 Error: The command failed to complete successfully.
336 """
337 cwd = self._workdir if self._workdir else self._parent_repo
338 logging.debug('Running git %s (cwd %r)', ' '.join('%s' % arg
339 for arg in args), cwd)
340
341 # Discard stderr unless verbose is enabled.
342 stderr = None if self._verbose else _DEV_NULL_FILE
343
344 try:
345 popen = subprocess.Popen(['git'] + args, shell=False, cwd=cwd,
346 stderr=stderr, stdin=subprocess.PIPE)
347 popen.communicate(stdin)
348 if popen.returncode != 0:
349 raise Error('Command %r failed' % ' '.join(args))
350 except OSError as e:
351 raise Error('Command %r failed: %s' % (' '.join(args), e))
352
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000353
sammc@chromium.org89901892015-11-03 00:57:48 +0000354def cherry_pick_change(branch, revision, parent_repo, dry_run, verbose=False):
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000355 """Cherry-picks a change into a branch.
356
357 Args:
358 branch: A string containing the release branch number to which to
359 cherry-pick.
360 revision: A string containing the revision to cherry-pick. It can be any
361 string that git-rev-parse can identify as referring to a single
362 revision.
363 parent_repo: A string containing the path to the parent repo to use for this
364 cherry-pick.
sammc@chromium.org89901892015-11-03 00:57:48 +0000365 dry_run: A bool containing whether to stop before uploading the
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000366 cherry-pick cl.
sammc@chromium.org89901892015-11-03 00:57:48 +0000367 verbose: A bool containing whether to print verbose logging.
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000368
369 Raises:
370 Error: An error occurred while attempting to cherry-pick |cl| to |branch|.
371 """
sammc@chromium.org89901892015-11-03 00:57:48 +0000372 drover = _Drover(branch, revision, parent_repo, dry_run, verbose)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000373 drover.run()
374
375
sammc@chromium.org89901892015-11-03 00:57:48 +0000376def continue_cherry_pick(workdir):
377 """Continues a cherry-pick that required manual resolution.
378
379 Args:
380 workdir: A string containing the path to the workdir used by drover.
381 """
382 _Drover.resume(workdir)
383
384
385def abort_cherry_pick(workdir):
386 """Aborts 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.abort(workdir)
392
393
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000394def main():
395 parser = argparse.ArgumentParser(
396 description='Cherry-pick a change into a release branch.')
sammc@chromium.org89901892015-11-03 00:57:48 +0000397 group = parser.add_mutually_exclusive_group(required=True)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000398 parser.add_argument(
399 '--branch',
400 type=str,
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000401 metavar='<branch>',
402 help='the name of the branch to which to cherry-pick; e.g. 1234')
sammc@chromium.org89901892015-11-03 00:57:48 +0000403 group.add_argument(
404 '--cherry-pick',
405 type=str,
406 metavar='<change>',
407 help=('the change to cherry-pick; this can be any string '
408 'that unambiguously refers to a revision not involving HEAD'))
409 group.add_argument(
410 '--continue',
411 type=str,
412 nargs='?',
413 dest='resume',
414 const=os.path.abspath('.'),
415 metavar='path_to_workdir',
416 help='Continue a drover cherry-pick after resolving conflicts')
417 group.add_argument('--abort',
418 type=str,
419 nargs='?',
420 const=os.path.abspath('.'),
421 metavar='path_to_workdir',
422 help='Abort a drover cherry-pick')
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000423 parser.add_argument(
424 '--parent_checkout',
425 type=str,
426 default=os.path.abspath('.'),
427 metavar='<path_to_parent_checkout>',
428 help=('the path to the chromium checkout to use as the source for a '
429 'creating git-new-workdir workdir to use for cherry-picking; '
430 'if unspecified, the current directory is used'))
431 parser.add_argument(
432 '--dry-run',
433 action='store_true',
434 default=False,
435 help=("don't actually upload and land; "
436 "just check that cherry-picking would succeed"))
437 parser.add_argument('-v',
438 '--verbose',
439 action='store_true',
440 default=False,
441 help='show verbose logging')
442 options = parser.parse_args()
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000443 try:
sammc@chromium.org89901892015-11-03 00:57:48 +0000444 if options.resume:
445 _Drover.resume(options.resume)
446 elif options.abort:
447 _Drover.abort(options.abort)
448 else:
449 if not options.branch:
450 parser.error('argument --branch is required for --cherry-pick')
451 cherry_pick_change(options.branch, options.cherry_pick,
452 options.parent_checkout, options.dry_run,
453 options.verbose)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000454 except Error as e:
sammc@chromium.org89901892015-11-03 00:57:48 +0000455 print 'Error:', e.message
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000456 sys.exit(128)
457
458
459if __name__ == '__main__':
460 main()