blob: c9a4af31527e3299d346f6fe724866f0c96c4d8c [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
12import shutil
13import subprocess
14import sys
15import tempfile
16
17import git_common
18
19
20class Error(Exception):
21 pass
22
23
sammc@chromium.org89901892015-11-03 00:57:48 +000024_PATCH_ERROR_MESSAGE = """Patch failed to apply.
25
26A workdir for this cherry-pick has been created in
27 {0}
28
29To continue, resolve the conflicts there and run
30 git drover --continue {0}
31
32To abort this cherry-pick run
33 git drover --abort {0}
34"""
35
36
37class PatchError(Error):
38 """An error indicating that the patch failed to apply."""
39
40 def __init__(self, workdir):
41 super(PatchError, self).__init__(_PATCH_ERROR_MESSAGE.format(workdir))
42
43
44_DEV_NULL_FILE = open(os.devnull, 'w')
45
scottmg@chromium.orgd4218d42015-10-07 23:49:20 +000046if os.name == 'nt':
47 # This is a just-good-enough emulation of os.symlink for drover to work on
48 # Windows. It uses junctioning of directories (most of the contents of
49 # the .git directory), but copies files. Note that we can't use
50 # CreateSymbolicLink or CreateHardLink here, as they both require elevation.
51 # Creating reparse points is what we want for the directories, but doing so
52 # is a relatively messy set of DeviceIoControl work at the API level, so we
53 # simply shell to `mklink /j` instead.
54 def emulate_symlink_windows(source, link_name):
55 if os.path.isdir(source):
56 subprocess.check_call(['mklink', '/j',
57 link_name.replace('/', '\\'),
58 source.replace('/', '\\')],
59 shell=True)
60 else:
61 shutil.copy(source, link_name)
62 mk_symlink = emulate_symlink_windows
63else:
64 mk_symlink = os.symlink
65
66
sammc@chromium.org900a33f2015-09-29 06:57:09 +000067class _Drover(object):
68
sammc@chromium.org89901892015-11-03 00:57:48 +000069 def __init__(self, branch, revision, parent_repo, dry_run, verbose):
sammc@chromium.org900a33f2015-09-29 06:57:09 +000070 self._branch = branch
71 self._branch_ref = 'refs/remotes/branch-heads/%s' % branch
72 self._revision = revision
73 self._parent_repo = os.path.abspath(parent_repo)
74 self._dry_run = dry_run
75 self._workdir = None
76 self._branch_name = None
sammc@chromium.org89901892015-11-03 00:57:48 +000077 self._needs_cleanup = True
78 self._verbose = verbose
79 self._process_options()
80
81 def _process_options(self):
82 if self._verbose:
83 logging.getLogger().setLevel(logging.DEBUG)
84
85
86 @classmethod
87 def resume(cls, workdir):
88 """Continues a cherry-pick that required manual resolution.
89
90 Args:
91 workdir: A string containing the path to the workdir used by drover.
92 """
93 drover = cls._restore_drover(workdir)
94 drover._continue()
95
96 @classmethod
97 def abort(cls, workdir):
98 """Aborts a cherry-pick that required manual resolution.
99
100 Args:
101 workdir: A string containing the path to the workdir used by drover.
102 """
103 drover = cls._restore_drover(workdir)
104 drover._cleanup()
105
106 @staticmethod
107 def _restore_drover(workdir):
108 """Restores a saved drover state contained within a workdir.
109
110 Args:
111 workdir: A string containing the path to the workdir used by drover.
112 """
113 try:
114 with open(os.path.join(workdir, '.git', 'drover'), 'rb') as f:
115 drover = cPickle.load(f)
116 drover._process_options()
117 return drover
118 except (IOError, cPickle.UnpicklingError):
119 raise Error('%r is not git drover workdir' % workdir)
120
121 def _continue(self):
122 if os.path.exists(os.path.join(self._workdir, '.git', 'CHERRY_PICK_HEAD')):
123 self._run_git_command(
124 ['commit', '--no-edit'],
125 error_message='All conflicts must be resolved before continuing')
126
127 if self._upload_and_land():
128 # Only clean up the workdir on success. The manually resolved cherry-pick
129 # can be reused if the user cancels before landing.
130 self._cleanup()
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000131
132 def run(self):
133 """Runs this Drover instance.
134
135 Raises:
136 Error: An error occurred while attempting to cherry-pick this change.
137 """
138 try:
139 self._run_internal()
140 finally:
141 self._cleanup()
142
143 def _run_internal(self):
144 self._check_inputs()
145 if not self._confirm('Going to cherry-pick\n"""\n%s"""\nto %s.' % (
146 self._run_git_command(['show', '-s', self._revision]), self._branch)):
147 return
148 self._create_checkout()
sammc@chromium.org89901892015-11-03 00:57:48 +0000149 self._perform_cherry_pick()
150 self._upload_and_land()
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000151
152 def _cleanup(self):
sammc@chromium.org89901892015-11-03 00:57:48 +0000153 if not self._needs_cleanup:
154 return
155
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000156 if self._workdir:
157 logging.debug('Deleting %s', self._workdir)
scottmg@chromium.orgd4218d42015-10-07 23:49:20 +0000158 if os.name == 'nt':
sammc@chromium.org89901892015-11-03 00:57:48 +0000159 try:
160 # Use rmdir to properly handle the junctions we created.
161 subprocess.check_call(
162 ['rmdir', '/s', '/q', self._workdir], shell=True)
163 except subprocess.CalledProcessError:
164 logging.error(
165 'Failed to delete workdir %r. Please remove it manually.',
166 self._workdir)
scottmg@chromium.orgd4218d42015-10-07 23:49:20 +0000167 else:
168 shutil.rmtree(self._workdir)
sammc@chromium.org89901892015-11-03 00:57:48 +0000169 self._workdir = None
170 if self._branch_name:
171 self._run_git_command(['branch', '-D', self._branch_name])
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000172
173 @staticmethod
174 def _confirm(message):
175 """Show a confirmation prompt with the given message.
176
177 Returns:
178 A bool representing whether the user wishes to continue.
179 """
180 result = ''
181 while result not in ('y', 'n'):
182 try:
183 result = raw_input('%s Continue (y/n)? ' % message)
184 except EOFError:
185 result = 'n'
186 return result == 'y'
187
188 def _check_inputs(self):
189 """Check the input arguments and ensure the parent repo is up to date."""
190
191 if not os.path.isdir(self._parent_repo):
192 raise Error('Invalid parent repo path %r' % self._parent_repo)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000193
194 self._run_git_command(['--help'], error_message='Unable to run git')
195 self._run_git_command(['status'],
196 error_message='%r is not a valid git repo' %
197 os.path.abspath(self._parent_repo))
198 self._run_git_command(['fetch', 'origin'],
199 error_message='Failed to fetch origin')
200 self._run_git_command(
201 ['rev-parse', '%s^{commit}' % self._branch_ref],
202 error_message='Branch %s not found' % self._branch_ref)
203 self._run_git_command(
204 ['rev-parse', '%s^{commit}' % self._revision],
205 error_message='Revision "%s" not found' % self._revision)
206
207 FILES_TO_LINK = [
208 'refs',
209 'logs/refs',
210 'info/refs',
211 'info/exclude',
212 'objects',
213 'hooks',
214 'packed-refs',
215 'remotes',
216 'rr-cache',
217 'svn',
218 ]
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.
265 repo_status = self._run_git_command(['status', '--porcelain']).splitlines()
266 extra_files = [f[3:] for f in repo_status if f[:2] == ' D']
267 if extra_files:
scottmg02056562016-06-15 17:21:04 -0700268 self._run_git_command_with_stdin(
269 ['update-index', '--skip-worktree', '--stdin'],
270 stdin='\n'.join(extra_files) + '\n')
sammc@chromium.org89901892015-11-03 00:57:48 +0000271
272 def _upload_and_land(self):
273 if self._dry_run:
274 logging.info('--dry_run enabled; not landing.')
275 return True
276
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000277 self._run_git_command(['reset', '--hard'])
sammc@chromium.org89901892015-11-03 00:57:48 +0000278 self._run_git_command(['cl', 'upload'],
279 error_message='Upload failed',
280 interactive=True)
281
282 if not self._confirm('About to land on %s.' % self._branch):
283 return False
284 self._run_git_command(['cl', 'land', '--bypass-hooks'], interactive=True)
285 return True
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000286
287 def _run_git_command(self, args, error_message=None, interactive=False):
288 """Runs a git command.
289
290 Args:
291 args: A list of strings containing the args to pass to git.
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000292 error_message: A string containing the error message to report if the
293 command fails.
sammc@chromium.org89901892015-11-03 00:57:48 +0000294 interactive: A bool containing whether the command requires user
295 interaction. If false, the command will be provided with no input and
296 the output is captured.
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000297
298 Raises:
299 Error: The command failed to complete successfully.
300 """
301 cwd = self._workdir if self._workdir else self._parent_repo
302 logging.debug('Running git %s (cwd %r)', ' '.join('%s' % arg
303 for arg in args), cwd)
304
305 run = subprocess.check_call if interactive else subprocess.check_output
306
sammc@chromium.org89901892015-11-03 00:57:48 +0000307 # Discard stderr unless verbose is enabled.
308 stderr = None if self._verbose else _DEV_NULL_FILE
309
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000310 try:
sammc@chromium.org89901892015-11-03 00:57:48 +0000311 return run(['git'] + args, shell=False, cwd=cwd, stderr=stderr)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000312 except (OSError, subprocess.CalledProcessError) as e:
313 if error_message:
314 raise Error(error_message)
315 else:
316 raise Error('Command %r failed: %s' % (' '.join(args), e))
317
scottmg02056562016-06-15 17:21:04 -0700318 def _run_git_command_with_stdin(self, args, stdin):
319 """Runs a git command with a provided stdin.
320
321 Args:
322 args: A list of strings containing the args to pass to git.
323 stdin: A string to provide on stdin.
324
325 Raises:
326 Error: The command failed to complete successfully.
327 """
328 cwd = self._workdir if self._workdir else self._parent_repo
329 logging.debug('Running git %s (cwd %r)', ' '.join('%s' % arg
330 for arg in args), cwd)
331
332 # Discard stderr unless verbose is enabled.
333 stderr = None if self._verbose else _DEV_NULL_FILE
334
335 try:
336 popen = subprocess.Popen(['git'] + args, shell=False, cwd=cwd,
337 stderr=stderr, stdin=subprocess.PIPE)
338 popen.communicate(stdin)
339 if popen.returncode != 0:
340 raise Error('Command %r failed' % ' '.join(args))
341 except OSError as e:
342 raise Error('Command %r failed: %s' % (' '.join(args), e))
343
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000344
sammc@chromium.org89901892015-11-03 00:57:48 +0000345def cherry_pick_change(branch, revision, parent_repo, dry_run, verbose=False):
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000346 """Cherry-picks a change into a branch.
347
348 Args:
349 branch: A string containing the release branch number to which to
350 cherry-pick.
351 revision: A string containing the revision to cherry-pick. It can be any
352 string that git-rev-parse can identify as referring to a single
353 revision.
354 parent_repo: A string containing the path to the parent repo to use for this
355 cherry-pick.
sammc@chromium.org89901892015-11-03 00:57:48 +0000356 dry_run: A bool containing whether to stop before uploading the
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000357 cherry-pick cl.
sammc@chromium.org89901892015-11-03 00:57:48 +0000358 verbose: A bool containing whether to print verbose logging.
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000359
360 Raises:
361 Error: An error occurred while attempting to cherry-pick |cl| to |branch|.
362 """
sammc@chromium.org89901892015-11-03 00:57:48 +0000363 drover = _Drover(branch, revision, parent_repo, dry_run, verbose)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000364 drover.run()
365
366
sammc@chromium.org89901892015-11-03 00:57:48 +0000367def continue_cherry_pick(workdir):
368 """Continues a cherry-pick that required manual resolution.
369
370 Args:
371 workdir: A string containing the path to the workdir used by drover.
372 """
373 _Drover.resume(workdir)
374
375
376def abort_cherry_pick(workdir):
377 """Aborts 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.abort(workdir)
383
384
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000385def main():
386 parser = argparse.ArgumentParser(
387 description='Cherry-pick a change into a release branch.')
sammc@chromium.org89901892015-11-03 00:57:48 +0000388 group = parser.add_mutually_exclusive_group(required=True)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000389 parser.add_argument(
390 '--branch',
391 type=str,
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000392 metavar='<branch>',
393 help='the name of the branch to which to cherry-pick; e.g. 1234')
sammc@chromium.org89901892015-11-03 00:57:48 +0000394 group.add_argument(
395 '--cherry-pick',
396 type=str,
397 metavar='<change>',
398 help=('the change to cherry-pick; this can be any string '
399 'that unambiguously refers to a revision not involving HEAD'))
400 group.add_argument(
401 '--continue',
402 type=str,
403 nargs='?',
404 dest='resume',
405 const=os.path.abspath('.'),
406 metavar='path_to_workdir',
407 help='Continue a drover cherry-pick after resolving conflicts')
408 group.add_argument('--abort',
409 type=str,
410 nargs='?',
411 const=os.path.abspath('.'),
412 metavar='path_to_workdir',
413 help='Abort a drover cherry-pick')
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000414 parser.add_argument(
415 '--parent_checkout',
416 type=str,
417 default=os.path.abspath('.'),
418 metavar='<path_to_parent_checkout>',
419 help=('the path to the chromium checkout to use as the source for a '
420 'creating git-new-workdir workdir to use for cherry-picking; '
421 'if unspecified, the current directory is used'))
422 parser.add_argument(
423 '--dry-run',
424 action='store_true',
425 default=False,
426 help=("don't actually upload and land; "
427 "just check that cherry-picking would succeed"))
428 parser.add_argument('-v',
429 '--verbose',
430 action='store_true',
431 default=False,
432 help='show verbose logging')
433 options = parser.parse_args()
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000434 try:
sammc@chromium.org89901892015-11-03 00:57:48 +0000435 if options.resume:
436 _Drover.resume(options.resume)
437 elif options.abort:
438 _Drover.abort(options.abort)
439 else:
440 if not options.branch:
441 parser.error('argument --branch is required for --cherry-pick')
442 cherry_pick_change(options.branch, options.cherry_pick,
443 options.parent_checkout, options.dry_run,
444 options.verbose)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000445 except Error as e:
sammc@chromium.org89901892015-11-03 00:57:48 +0000446 print 'Error:', e.message
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000447 sys.exit(128)
448
449
450if __name__ == '__main__':
451 main()