blob: ad6d0e13c745b7dc3e1e0c1983c52ac8339f84ef [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:
268 self._run_git_command(['update-index', '--skip-worktree', '--'] +
269 extra_files)
270
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
317
sammc@chromium.org89901892015-11-03 00:57:48 +0000318def cherry_pick_change(branch, revision, parent_repo, dry_run, verbose=False):
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000319 """Cherry-picks a change into a branch.
320
321 Args:
322 branch: A string containing the release branch number to which to
323 cherry-pick.
324 revision: A string containing the revision to cherry-pick. It can be any
325 string that git-rev-parse can identify as referring to a single
326 revision.
327 parent_repo: A string containing the path to the parent repo to use for this
328 cherry-pick.
sammc@chromium.org89901892015-11-03 00:57:48 +0000329 dry_run: A bool containing whether to stop before uploading the
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000330 cherry-pick cl.
sammc@chromium.org89901892015-11-03 00:57:48 +0000331 verbose: A bool containing whether to print verbose logging.
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000332
333 Raises:
334 Error: An error occurred while attempting to cherry-pick |cl| to |branch|.
335 """
sammc@chromium.org89901892015-11-03 00:57:48 +0000336 drover = _Drover(branch, revision, parent_repo, dry_run, verbose)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000337 drover.run()
338
339
sammc@chromium.org89901892015-11-03 00:57:48 +0000340def continue_cherry_pick(workdir):
341 """Continues a cherry-pick that required manual resolution.
342
343 Args:
344 workdir: A string containing the path to the workdir used by drover.
345 """
346 _Drover.resume(workdir)
347
348
349def abort_cherry_pick(workdir):
350 """Aborts a cherry-pick that required manual resolution.
351
352 Args:
353 workdir: A string containing the path to the workdir used by drover.
354 """
355 _Drover.abort(workdir)
356
357
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000358def main():
359 parser = argparse.ArgumentParser(
360 description='Cherry-pick a change into a release branch.')
sammc@chromium.org89901892015-11-03 00:57:48 +0000361 group = parser.add_mutually_exclusive_group(required=True)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000362 parser.add_argument(
363 '--branch',
364 type=str,
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000365 metavar='<branch>',
366 help='the name of the branch to which to cherry-pick; e.g. 1234')
sammc@chromium.org89901892015-11-03 00:57:48 +0000367 group.add_argument(
368 '--cherry-pick',
369 type=str,
370 metavar='<change>',
371 help=('the change to cherry-pick; this can be any string '
372 'that unambiguously refers to a revision not involving HEAD'))
373 group.add_argument(
374 '--continue',
375 type=str,
376 nargs='?',
377 dest='resume',
378 const=os.path.abspath('.'),
379 metavar='path_to_workdir',
380 help='Continue a drover cherry-pick after resolving conflicts')
381 group.add_argument('--abort',
382 type=str,
383 nargs='?',
384 const=os.path.abspath('.'),
385 metavar='path_to_workdir',
386 help='Abort a drover cherry-pick')
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000387 parser.add_argument(
388 '--parent_checkout',
389 type=str,
390 default=os.path.abspath('.'),
391 metavar='<path_to_parent_checkout>',
392 help=('the path to the chromium checkout to use as the source for a '
393 'creating git-new-workdir workdir to use for cherry-picking; '
394 'if unspecified, the current directory is used'))
395 parser.add_argument(
396 '--dry-run',
397 action='store_true',
398 default=False,
399 help=("don't actually upload and land; "
400 "just check that cherry-picking would succeed"))
401 parser.add_argument('-v',
402 '--verbose',
403 action='store_true',
404 default=False,
405 help='show verbose logging')
406 options = parser.parse_args()
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000407 try:
sammc@chromium.org89901892015-11-03 00:57:48 +0000408 if options.resume:
409 _Drover.resume(options.resume)
410 elif options.abort:
411 _Drover.abort(options.abort)
412 else:
413 if not options.branch:
414 parser.error('argument --branch is required for --cherry-pick')
415 cherry_pick_change(options.branch, options.cherry_pick,
416 options.parent_checkout, options.dry_run,
417 options.verbose)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000418 except Error as e:
sammc@chromium.org89901892015-11-03 00:57:48 +0000419 print 'Error:', e.message
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000420 sys.exit(128)
421
422
423if __name__ == '__main__':
424 main()