blob: 7d877fb19a1573985cf77e1817d3e72fe442851d [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.
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'])
Aaron Gablec7e84d02017-04-27 14:42:43 -0700278
279 author = self._run_git_command(['log', '-1', '--format=%ae']).strip()
Aaron Gable897bf0b2017-06-29 11:56:06 -0700280 self._run_git_command(['cl', 'upload', '--send-mail', '--tbrs', author],
sammc@chromium.org89901892015-11-03 00:57:48 +0000281 error_message='Upload failed',
282 interactive=True)
283
284 if not self._confirm('About to land on %s.' % self._branch):
285 return False
286 self._run_git_command(['cl', 'land', '--bypass-hooks'], interactive=True)
287 return True
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000288
289 def _run_git_command(self, args, error_message=None, interactive=False):
290 """Runs a git command.
291
292 Args:
293 args: A list of strings containing the args to pass to git.
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000294 error_message: A string containing the error message to report if the
295 command fails.
sammc@chromium.org89901892015-11-03 00:57:48 +0000296 interactive: A bool containing whether the command requires user
297 interaction. If false, the command will be provided with no input and
298 the output is captured.
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000299
Aaron Gablec7e84d02017-04-27 14:42:43 -0700300 Returns:
301 stdout as a string, or stdout interleaved with stderr if self._verbose
302
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000303 Raises:
304 Error: The command failed to complete successfully.
305 """
306 cwd = self._workdir if self._workdir else self._parent_repo
307 logging.debug('Running git %s (cwd %r)', ' '.join('%s' % arg
308 for arg in args), cwd)
309
310 run = subprocess.check_call if interactive else subprocess.check_output
311
sammc@chromium.org89901892015-11-03 00:57:48 +0000312 # Discard stderr unless verbose is enabled.
313 stderr = None if self._verbose else _DEV_NULL_FILE
314
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000315 try:
sammc@chromium.org89901892015-11-03 00:57:48 +0000316 return run(['git'] + args, shell=False, cwd=cwd, stderr=stderr)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000317 except (OSError, subprocess.CalledProcessError) as e:
318 if error_message:
319 raise Error(error_message)
320 else:
321 raise Error('Command %r failed: %s' % (' '.join(args), e))
322
scottmg02056562016-06-15 17:21:04 -0700323 def _run_git_command_with_stdin(self, args, stdin):
324 """Runs a git command with a provided stdin.
325
326 Args:
327 args: A list of strings containing the args to pass to git.
328 stdin: A string to provide on stdin.
329
Aaron Gablec7e84d02017-04-27 14:42:43 -0700330 Returns:
331 stdout as a string, or stdout interleaved with stderr if self._verbose
332
scottmg02056562016-06-15 17:21:04 -0700333 Raises:
334 Error: The command failed to complete successfully.
335 """
336 cwd = self._workdir if self._workdir else self._parent_repo
337 logging.debug('Running git %s (cwd %r)', ' '.join('%s' % arg
338 for arg in args), cwd)
339
340 # Discard stderr unless verbose is enabled.
341 stderr = None if self._verbose else _DEV_NULL_FILE
342
343 try:
344 popen = subprocess.Popen(['git'] + args, shell=False, cwd=cwd,
345 stderr=stderr, stdin=subprocess.PIPE)
346 popen.communicate(stdin)
347 if popen.returncode != 0:
348 raise Error('Command %r failed' % ' '.join(args))
349 except OSError as e:
350 raise Error('Command %r failed: %s' % (' '.join(args), e))
351
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000352
sammc@chromium.org89901892015-11-03 00:57:48 +0000353def cherry_pick_change(branch, revision, parent_repo, dry_run, verbose=False):
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000354 """Cherry-picks a change into a branch.
355
356 Args:
357 branch: A string containing the release branch number to which to
358 cherry-pick.
359 revision: A string containing the revision to cherry-pick. It can be any
360 string that git-rev-parse can identify as referring to a single
361 revision.
362 parent_repo: A string containing the path to the parent repo to use for this
363 cherry-pick.
sammc@chromium.org89901892015-11-03 00:57:48 +0000364 dry_run: A bool containing whether to stop before uploading the
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000365 cherry-pick cl.
sammc@chromium.org89901892015-11-03 00:57:48 +0000366 verbose: A bool containing whether to print verbose logging.
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000367
368 Raises:
369 Error: An error occurred while attempting to cherry-pick |cl| to |branch|.
370 """
sammc@chromium.org89901892015-11-03 00:57:48 +0000371 drover = _Drover(branch, revision, parent_repo, dry_run, verbose)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000372 drover.run()
373
374
sammc@chromium.org89901892015-11-03 00:57:48 +0000375def continue_cherry_pick(workdir):
376 """Continues 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.resume(workdir)
382
383
384def abort_cherry_pick(workdir):
385 """Aborts a cherry-pick that required manual resolution.
386
387 Args:
388 workdir: A string containing the path to the workdir used by drover.
389 """
390 _Drover.abort(workdir)
391
392
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000393def main():
394 parser = argparse.ArgumentParser(
395 description='Cherry-pick a change into a release branch.')
sammc@chromium.org89901892015-11-03 00:57:48 +0000396 group = parser.add_mutually_exclusive_group(required=True)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000397 parser.add_argument(
398 '--branch',
399 type=str,
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000400 metavar='<branch>',
401 help='the name of the branch to which to cherry-pick; e.g. 1234')
sammc@chromium.org89901892015-11-03 00:57:48 +0000402 group.add_argument(
403 '--cherry-pick',
404 type=str,
405 metavar='<change>',
406 help=('the change to cherry-pick; this can be any string '
407 'that unambiguously refers to a revision not involving HEAD'))
408 group.add_argument(
409 '--continue',
410 type=str,
411 nargs='?',
412 dest='resume',
413 const=os.path.abspath('.'),
414 metavar='path_to_workdir',
415 help='Continue a drover cherry-pick after resolving conflicts')
416 group.add_argument('--abort',
417 type=str,
418 nargs='?',
419 const=os.path.abspath('.'),
420 metavar='path_to_workdir',
421 help='Abort a drover cherry-pick')
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000422 parser.add_argument(
423 '--parent_checkout',
424 type=str,
425 default=os.path.abspath('.'),
426 metavar='<path_to_parent_checkout>',
427 help=('the path to the chromium checkout to use as the source for a '
428 'creating git-new-workdir workdir to use for cherry-picking; '
429 'if unspecified, the current directory is used'))
430 parser.add_argument(
431 '--dry-run',
432 action='store_true',
433 default=False,
434 help=("don't actually upload and land; "
435 "just check that cherry-picking would succeed"))
436 parser.add_argument('-v',
437 '--verbose',
438 action='store_true',
439 default=False,
440 help='show verbose logging')
441 options = parser.parse_args()
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000442 try:
sammc@chromium.org89901892015-11-03 00:57:48 +0000443 if options.resume:
444 _Drover.resume(options.resume)
445 elif options.abort:
446 _Drover.abort(options.abort)
447 else:
448 if not options.branch:
449 parser.error('argument --branch is required for --cherry-pick')
450 cherry_pick_change(options.branch, options.cherry_pick,
451 options.parent_checkout, options.dry_run,
452 options.verbose)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000453 except Error as e:
sammc@chromium.org89901892015-11-03 00:57:48 +0000454 print 'Error:', e.message
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000455 sys.exit(128)
456
457
458if __name__ == '__main__':
459 main()