blob: f477566aa6bb9191b779e0fceb2fec16732d26aa [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',
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000217 ]
218 FILES_TO_COPY = ['config', 'HEAD']
219
220 def _create_checkout(self):
221 """Creates a checkout to use for cherry-picking.
222
223 This creates a checkout similarly to git-new-workdir. Most of the .git
224 directory is shared with the |self._parent_repo| using symlinks. This
225 differs from git-new-workdir in that the config is forked instead of shared.
226 This is so the new workdir can be a sparse checkout without affecting
227 |self._parent_repo|.
228 """
rob@robwu.nl93aa0732016-01-27 19:22:28 +0000229 parent_git_dir = os.path.join(self._parent_repo, self._run_git_command(
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000230 ['rev-parse', '--git-dir']).strip())
231 self._workdir = tempfile.mkdtemp(prefix='drover_%s_' % self._branch)
232 logging.debug('Creating checkout in %s', self._workdir)
233 git_dir = os.path.join(self._workdir, '.git')
234 git_common.make_workdir_common(parent_git_dir, git_dir, self.FILES_TO_LINK,
scottmg@chromium.orgd4218d42015-10-07 23:49:20 +0000235 self.FILES_TO_COPY, mk_symlink)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000236 self._run_git_command(['config', 'core.sparsecheckout', 'true'])
237 with open(os.path.join(git_dir, 'info', 'sparse-checkout'), 'w') as f:
sammc@chromium.org89901892015-11-03 00:57:48 +0000238 f.write('/codereview.settings')
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000239
240 branch_name = os.path.split(self._workdir)[-1]
241 self._run_git_command(['checkout', '-b', branch_name, self._branch_ref])
242 self._branch_name = branch_name
243
sammc@chromium.org89901892015-11-03 00:57:48 +0000244 def _perform_cherry_pick(self):
245 try:
246 self._run_git_command(['cherry-pick', '-x', self._revision],
247 error_message='Patch failed to apply')
248 except Error:
249 self._prepare_manual_resolve()
250 self._save_state()
251 self._needs_cleanup = False
252 raise PatchError(self._workdir)
253
254 def _save_state(self):
255 """Saves the state of this Drover instances to the workdir."""
256 with open(os.path.join(self._workdir, '.git', 'drover'), 'wb') as f:
257 cPickle.dump(self, f)
258
259 def _prepare_manual_resolve(self):
260 """Prepare the workdir for the user to manually resolve the cherry-pick."""
261 # Files that have been deleted between branch and cherry-pick will not have
262 # their skip-worktree bit set so set it manually for those files to avoid
263 # git status incorrectly listing them as unstaged deletes.
264 repo_status = self._run_git_command(['status', '--porcelain']).splitlines()
265 extra_files = [f[3:] for f in repo_status if f[:2] == ' D']
266 if extra_files:
scottmg02056562016-06-15 17:21:04 -0700267 self._run_git_command_with_stdin(
268 ['update-index', '--skip-worktree', '--stdin'],
269 stdin='\n'.join(extra_files) + '\n')
sammc@chromium.org89901892015-11-03 00:57:48 +0000270
271 def _upload_and_land(self):
272 if self._dry_run:
273 logging.info('--dry_run enabled; not landing.')
274 return True
275
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000276 self._run_git_command(['reset', '--hard'])
sammc@chromium.org89901892015-11-03 00:57:48 +0000277 self._run_git_command(['cl', 'upload'],
278 error_message='Upload failed',
279 interactive=True)
280
281 if not self._confirm('About to land on %s.' % self._branch):
282 return False
283 self._run_git_command(['cl', 'land', '--bypass-hooks'], interactive=True)
284 return True
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000285
286 def _run_git_command(self, args, error_message=None, interactive=False):
287 """Runs a git command.
288
289 Args:
290 args: A list of strings containing the args to pass to git.
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000291 error_message: A string containing the error message to report if the
292 command fails.
sammc@chromium.org89901892015-11-03 00:57:48 +0000293 interactive: A bool containing whether the command requires user
294 interaction. If false, the command will be provided with no input and
295 the output is captured.
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000296
297 Raises:
298 Error: The command failed to complete successfully.
299 """
300 cwd = self._workdir if self._workdir else self._parent_repo
301 logging.debug('Running git %s (cwd %r)', ' '.join('%s' % arg
302 for arg in args), cwd)
303
304 run = subprocess.check_call if interactive else subprocess.check_output
305
sammc@chromium.org89901892015-11-03 00:57:48 +0000306 # Discard stderr unless verbose is enabled.
307 stderr = None if self._verbose else _DEV_NULL_FILE
308
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000309 try:
sammc@chromium.org89901892015-11-03 00:57:48 +0000310 return run(['git'] + args, shell=False, cwd=cwd, stderr=stderr)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000311 except (OSError, subprocess.CalledProcessError) as e:
312 if error_message:
313 raise Error(error_message)
314 else:
315 raise Error('Command %r failed: %s' % (' '.join(args), e))
316
scottmg02056562016-06-15 17:21:04 -0700317 def _run_git_command_with_stdin(self, args, stdin):
318 """Runs a git command with a provided stdin.
319
320 Args:
321 args: A list of strings containing the args to pass to git.
322 stdin: A string to provide on stdin.
323
324 Raises:
325 Error: The command failed to complete successfully.
326 """
327 cwd = self._workdir if self._workdir else self._parent_repo
328 logging.debug('Running git %s (cwd %r)', ' '.join('%s' % arg
329 for arg in args), cwd)
330
331 # Discard stderr unless verbose is enabled.
332 stderr = None if self._verbose else _DEV_NULL_FILE
333
334 try:
335 popen = subprocess.Popen(['git'] + args, shell=False, cwd=cwd,
336 stderr=stderr, stdin=subprocess.PIPE)
337 popen.communicate(stdin)
338 if popen.returncode != 0:
339 raise Error('Command %r failed' % ' '.join(args))
340 except OSError as e:
341 raise Error('Command %r failed: %s' % (' '.join(args), e))
342
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000343
sammc@chromium.org89901892015-11-03 00:57:48 +0000344def cherry_pick_change(branch, revision, parent_repo, dry_run, verbose=False):
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000345 """Cherry-picks a change into a branch.
346
347 Args:
348 branch: A string containing the release branch number to which to
349 cherry-pick.
350 revision: A string containing the revision to cherry-pick. It can be any
351 string that git-rev-parse can identify as referring to a single
352 revision.
353 parent_repo: A string containing the path to the parent repo to use for this
354 cherry-pick.
sammc@chromium.org89901892015-11-03 00:57:48 +0000355 dry_run: A bool containing whether to stop before uploading the
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000356 cherry-pick cl.
sammc@chromium.org89901892015-11-03 00:57:48 +0000357 verbose: A bool containing whether to print verbose logging.
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000358
359 Raises:
360 Error: An error occurred while attempting to cherry-pick |cl| to |branch|.
361 """
sammc@chromium.org89901892015-11-03 00:57:48 +0000362 drover = _Drover(branch, revision, parent_repo, dry_run, verbose)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000363 drover.run()
364
365
sammc@chromium.org89901892015-11-03 00:57:48 +0000366def continue_cherry_pick(workdir):
367 """Continues a cherry-pick that required manual resolution.
368
369 Args:
370 workdir: A string containing the path to the workdir used by drover.
371 """
372 _Drover.resume(workdir)
373
374
375def abort_cherry_pick(workdir):
376 """Aborts a cherry-pick that required manual resolution.
377
378 Args:
379 workdir: A string containing the path to the workdir used by drover.
380 """
381 _Drover.abort(workdir)
382
383
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000384def main():
385 parser = argparse.ArgumentParser(
386 description='Cherry-pick a change into a release branch.')
sammc@chromium.org89901892015-11-03 00:57:48 +0000387 group = parser.add_mutually_exclusive_group(required=True)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000388 parser.add_argument(
389 '--branch',
390 type=str,
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000391 metavar='<branch>',
392 help='the name of the branch to which to cherry-pick; e.g. 1234')
sammc@chromium.org89901892015-11-03 00:57:48 +0000393 group.add_argument(
394 '--cherry-pick',
395 type=str,
396 metavar='<change>',
397 help=('the change to cherry-pick; this can be any string '
398 'that unambiguously refers to a revision not involving HEAD'))
399 group.add_argument(
400 '--continue',
401 type=str,
402 nargs='?',
403 dest='resume',
404 const=os.path.abspath('.'),
405 metavar='path_to_workdir',
406 help='Continue a drover cherry-pick after resolving conflicts')
407 group.add_argument('--abort',
408 type=str,
409 nargs='?',
410 const=os.path.abspath('.'),
411 metavar='path_to_workdir',
412 help='Abort a drover cherry-pick')
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000413 parser.add_argument(
414 '--parent_checkout',
415 type=str,
416 default=os.path.abspath('.'),
417 metavar='<path_to_parent_checkout>',
418 help=('the path to the chromium checkout to use as the source for a '
419 'creating git-new-workdir workdir to use for cherry-picking; '
420 'if unspecified, the current directory is used'))
421 parser.add_argument(
422 '--dry-run',
423 action='store_true',
424 default=False,
425 help=("don't actually upload and land; "
426 "just check that cherry-picking would succeed"))
427 parser.add_argument('-v',
428 '--verbose',
429 action='store_true',
430 default=False,
431 help='show verbose logging')
432 options = parser.parse_args()
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000433 try:
sammc@chromium.org89901892015-11-03 00:57:48 +0000434 if options.resume:
435 _Drover.resume(options.resume)
436 elif options.abort:
437 _Drover.abort(options.abort)
438 else:
439 if not options.branch:
440 parser.error('argument --branch is required for --cherry-pick')
441 cherry_pick_change(options.branch, options.cherry_pick,
442 options.parent_checkout, options.dry_run,
443 options.verbose)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000444 except Error as e:
sammc@chromium.org89901892015-11-03 00:57:48 +0000445 print 'Error:', e.message
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000446 sys.exit(128)
447
448
449if __name__ == '__main__':
450 main()