Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(224)

Side by Side Diff: git_drover.py

Issue 1397313002: Support merging with conflicts with git-drover. (Closed) Base URL: https://chromium.googlesource.com/chromium/tools/depot_tools.git@master
Patch Set: Created 5 years, 2 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch
« no previous file with comments | « no previous file | man/html/git-drover.html » ('j') | no next file with comments »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
1 #!/usr/bin/env python 1 #!/usr/bin/env python
2 # Copyright 2015 The Chromium Authors. All rights reserved. 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 3 # Use of this source code is governed by a BSD-style license that can be
4 # found in the LICENSE file. 4 # found in the LICENSE file.
5 """git drover: A tool for merging changes to release branches.""" 5 """git drover: A tool for merging changes to release branches."""
6 6
7 import argparse 7 import argparse
8 import cPickle
8 import functools 9 import functools
9 import logging 10 import logging
10 import os 11 import os
11 import shutil 12 import shutil
12 import subprocess 13 import subprocess
13 import sys 14 import sys
14 import tempfile 15 import tempfile
15 16
16 import git_common 17 import git_common
17 18
18 19
19 class Error(Exception): 20 class Error(Exception):
20 pass 21 pass
21 22
22 23
24 _PATCH_ERROR_MESSAGE = """Patch failed to apply.
25
26 A workdir for this cherry-pick has been created in
27 {0}
28
29 To continue, resolve the conflicts there and run
30 git drover --continue {0}
31
32 To abort this cherry-pick run
33 git drover --abort {0}
34 """
35
36
37 class 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
23 if os.name == 'nt': 46 if os.name == 'nt':
24 # This is a just-good-enough emulation of os.symlink for drover to work on 47 # This is a just-good-enough emulation of os.symlink for drover to work on
25 # Windows. It uses junctioning of directories (most of the contents of 48 # Windows. It uses junctioning of directories (most of the contents of
26 # the .git directory), but copies files. Note that we can't use 49 # the .git directory), but copies files. Note that we can't use
27 # CreateSymbolicLink or CreateHardLink here, as they both require elevation. 50 # CreateSymbolicLink or CreateHardLink here, as they both require elevation.
28 # Creating reparse points is what we want for the directories, but doing so 51 # Creating reparse points is what we want for the directories, but doing so
29 # is a relatively messy set of DeviceIoControl work at the API level, so we 52 # is a relatively messy set of DeviceIoControl work at the API level, so we
30 # simply shell to `mklink /j` instead. 53 # simply shell to `mklink /j` instead.
31 def emulate_symlink_windows(source, link_name): 54 def emulate_symlink_windows(source, link_name):
32 if os.path.isdir(source): 55 if os.path.isdir(source):
33 subprocess.check_call(['mklink', '/j', 56 subprocess.check_call(['mklink', '/j',
34 link_name.replace('/', '\\'), 57 link_name.replace('/', '\\'),
35 source.replace('/', '\\')], 58 source.replace('/', '\\')],
36 shell=True) 59 shell=True)
37 else: 60 else:
38 shutil.copy(source, link_name) 61 shutil.copy(source, link_name)
39 mk_symlink = emulate_symlink_windows 62 mk_symlink = emulate_symlink_windows
40 else: 63 else:
41 mk_symlink = os.symlink 64 mk_symlink = os.symlink
42 65
43 66
44 class _Drover(object): 67 class _Drover(object):
45 68
46 def __init__(self, branch, revision, parent_repo, dry_run): 69 def __init__(self, branch, revision, parent_repo, dry_run, verbose):
47 self._branch = branch 70 self._branch = branch
48 self._branch_ref = 'refs/remotes/branch-heads/%s' % branch 71 self._branch_ref = 'refs/remotes/branch-heads/%s' % branch
49 self._revision = revision 72 self._revision = revision
50 self._parent_repo = os.path.abspath(parent_repo) 73 self._parent_repo = os.path.abspath(parent_repo)
51 self._dry_run = dry_run 74 self._dry_run = dry_run
52 self._workdir = None 75 self._workdir = None
53 self._branch_name = None 76 self._branch_name = None
54 self._dev_null_file = open(os.devnull, 'w') 77 self._needs_cleanup = True
78 self._verbose = verbose
79
80 @classmethod
81 def resume(cls, workdir):
82 """Continues a cherry-pick that required manual resolution.
83
84 Args:
85 workdir: A string containing the path to the workdir used by drover.
86 """
87 drover = cls._restore_drover(workdir)
88 drover._continue()
89
90 @classmethod
91 def abort(cls, workdir):
92 """Aborts a cherry-pick that required manual resolution.
93
94 Args:
95 workdir: A string containing the path to the workdir used by drover.
96 """
97 drover = cls._restore_drover(workdir)
98 drover._cleanup()
iannucci 2015/11/02 20:37:58 will this work if they're cd'd into the directory?
Sam McNally 2015/11/03 00:53:44 Yes, except on Windows. On Windows it reports it t
99
100 @staticmethod
101 def _restore_drover(workdir):
102 """Restores a saved drover state contained within a workdir.
103
104 Args:
105 workdir: A string containing the path to the workdir used by drover.
106 """
107 try:
108 with open(os.path.join(workdir, '.git', 'drover'), 'rb') as f:
109 drover = cPickle.load(f)
110 if drover._verbose:
111 logging.getLogger().setLevel(logging.DEBUG)
iannucci 2015/11/02 20:37:58 weird, but I see what you did there. Would be nice
Sam McNally 2015/11/03 00:53:44 Done.
112 return drover
113 except (IOError, cPickle.UnpicklingError):
114 raise Error('%r is not git drover workdir' % workdir)
115
116 def _continue(self):
117 if os.path.exists(os.path.join(self._workdir, '.git', 'CHERRY_PICK_HEAD')):
118 self._run_git_command(
119 ['commit', '--no-edit'],
120 error_message='All conflicts must be resolved before continuing')
121
122 if self._upload_and_land():
123 # Only clean up the workdir on success. The manually resolved cherry-pick
124 # can be reused if the user cancels before landing.
125 self._cleanup()
55 126
56 def run(self): 127 def run(self):
57 """Runs this Drover instance. 128 """Runs this Drover instance.
58 129
59 Raises: 130 Raises:
60 Error: An error occurred while attempting to cherry-pick this change. 131 Error: An error occurred while attempting to cherry-pick this change.
61 """ 132 """
62 try: 133 try:
63 self._run_internal() 134 self._run_internal()
64 finally: 135 finally:
65 self._cleanup() 136 self._cleanup()
66 137
67 def _run_internal(self): 138 def _run_internal(self):
68 self._check_inputs() 139 self._check_inputs()
69 if not self._confirm('Going to cherry-pick\n"""\n%s"""\nto %s.' % ( 140 if not self._confirm('Going to cherry-pick\n"""\n%s"""\nto %s.' % (
70 self._run_git_command(['show', '-s', self._revision]), self._branch)): 141 self._run_git_command(['show', '-s', self._revision]), self._branch)):
71 return 142 return
72 self._create_checkout() 143 self._create_checkout()
73 self._prepare_cherry_pick() 144 self._perform_cherry_pick()
74 if self._dry_run: 145 self._upload_and_land()
75 logging.info('--dry_run enabled; not landing.') 146
147 def _cleanup(self):
148 if not self._needs_cleanup:
76 return 149 return
77 150
78 self._run_git_command(['cl', 'upload'],
79 error_message='Upload failed',
80 interactive=True)
81
82 if not self._confirm('About to land on %s.' % self._branch):
83 return
84 self._run_git_command(['cl', 'land', '--bypass-hooks'], interactive=True)
85
86 def _cleanup(self):
87 if self._branch_name:
88 try:
89 self._run_git_command(['cherry-pick', '--abort'])
90 except Error:
91 pass
92 self._run_git_command(['checkout', '--detach'])
93 self._run_git_command(['branch', '-D', self._branch_name])
94 if self._workdir: 151 if self._workdir:
95 logging.debug('Deleting %s', self._workdir) 152 logging.debug('Deleting %s', self._workdir)
96 if os.name == 'nt': 153 if os.name == 'nt':
97 # Use rmdir to properly handle the junctions we created. 154 try:
98 subprocess.check_call(['rmdir', '/s', '/q', self._workdir], shell=True) 155 # Use rmdir to properly handle the junctions we created.
156 subprocess.check_call(
157 ['rmdir', '/s', '/q', self._workdir], shell=True)
158 except subprocess.CalledProcessError:
159 logging.error(
160 'Failed to delete workdir %r. Please remove it manually.',
161 self._workdir)
99 else: 162 else:
100 shutil.rmtree(self._workdir) 163 shutil.rmtree(self._workdir)
101 self._dev_null_file.close() 164 self._workdir = None
165 if self._branch_name:
166 self._run_git_command(['branch', '-D', self._branch_name])
102 167
103 @staticmethod 168 @staticmethod
104 def _confirm(message): 169 def _confirm(message):
105 """Show a confirmation prompt with the given message. 170 """Show a confirmation prompt with the given message.
106 171
107 Returns: 172 Returns:
108 A bool representing whether the user wishes to continue. 173 A bool representing whether the user wishes to continue.
109 """ 174 """
110 result = '' 175 result = ''
111 while result not in ('y', 'n'): 176 while result not in ('y', 'n'):
(...skipping 47 matching lines...) Expand 10 before | Expand all | Expand 10 after
159 """ 224 """
160 parent_git_dir = os.path.abspath(self._run_git_command( 225 parent_git_dir = os.path.abspath(self._run_git_command(
161 ['rev-parse', '--git-dir']).strip()) 226 ['rev-parse', '--git-dir']).strip())
162 self._workdir = tempfile.mkdtemp(prefix='drover_%s_' % self._branch) 227 self._workdir = tempfile.mkdtemp(prefix='drover_%s_' % self._branch)
163 logging.debug('Creating checkout in %s', self._workdir) 228 logging.debug('Creating checkout in %s', self._workdir)
164 git_dir = os.path.join(self._workdir, '.git') 229 git_dir = os.path.join(self._workdir, '.git')
165 git_common.make_workdir_common(parent_git_dir, git_dir, self.FILES_TO_LINK, 230 git_common.make_workdir_common(parent_git_dir, git_dir, self.FILES_TO_LINK,
166 self.FILES_TO_COPY, mk_symlink) 231 self.FILES_TO_COPY, mk_symlink)
167 self._run_git_command(['config', 'core.sparsecheckout', 'true']) 232 self._run_git_command(['config', 'core.sparsecheckout', 'true'])
168 with open(os.path.join(git_dir, 'info', 'sparse-checkout'), 'w') as f: 233 with open(os.path.join(git_dir, 'info', 'sparse-checkout'), 'w') as f:
169 f.write('codereview.settings') 234 f.write('/codereview.settings')
170 235
171 branch_name = os.path.split(self._workdir)[-1] 236 branch_name = os.path.split(self._workdir)[-1]
172 self._run_git_command(['checkout', '-b', branch_name, self._branch_ref]) 237 self._run_git_command(['checkout', '-b', branch_name, self._branch_ref])
173 self._branch_name = branch_name 238 self._branch_name = branch_name
174 239
175 def _prepare_cherry_pick(self): 240 def _perform_cherry_pick(self):
176 self._run_git_command(['cherry-pick', '-x', self._revision], 241 try:
177 error_message='Patch failed to apply') 242 self._run_git_command(['cherry-pick', '-x', self._revision],
243 error_message='Patch failed to apply')
244 except Error:
245 self._prepare_manual_resolve()
246 self._save_state()
247 self._needs_cleanup = False
248 raise PatchError(self._workdir)
249
250 def _save_state(self):
251 """Saves the state of this Drover instances to the workdir."""
252 with open(os.path.join(self._workdir, '.git', 'drover'), 'wb') as f:
253 cPickle.dump(self, f)
254
255 def _prepare_manual_resolve(self):
256 """Prepare the workdir for the user to manually resolve the cherry-pick."""
257 # Files that have been deleted between branch and cherry-pick will not have
258 # their skip-worktree bit set so set it manually for those files to avoid
259 # git status incorrectly listing them as unstaged deletes.
260 repo_status = self._run_git_command(['status', '--porcelain']).splitlines()
261 extra_files = [f[3:] for f in repo_status if f[:2] == ' D']
262 if extra_files:
263 self._run_git_command(['update-index', '--skip-worktree', '--'] +
264 extra_files)
265
266 def _upload_and_land(self):
267 if self._dry_run:
268 logging.info('--dry_run enabled; not landing.')
269 return True
270
178 self._run_git_command(['reset', '--hard']) 271 self._run_git_command(['reset', '--hard'])
272 self._run_git_command(['cl', 'upload'],
273 error_message='Upload failed',
274 interactive=True)
275
276 if not self._confirm('About to land on %s.' % self._branch):
277 return False
278 self._run_git_command(['cl', 'land', '--bypass-hooks'], interactive=True)
279 return True
179 280
180 def _run_git_command(self, args, error_message=None, interactive=False): 281 def _run_git_command(self, args, error_message=None, interactive=False):
181 """Runs a git command. 282 """Runs a git command.
182 283
183 Args: 284 Args:
184 args: A list of strings containing the args to pass to git. 285 args: A list of strings containing the args to pass to git.
185 interactive:
186 error_message: A string containing the error message to report if the 286 error_message: A string containing the error message to report if the
187 command fails. 287 command fails.
288 interactive: A bool containing whether the command requires user
289 interaction. If false, the command will be provided with no input and
290 the output is captured.
188 291
189 Raises: 292 Raises:
190 Error: The command failed to complete successfully. 293 Error: The command failed to complete successfully.
191 """ 294 """
192 cwd = self._workdir if self._workdir else self._parent_repo 295 cwd = self._workdir if self._workdir else self._parent_repo
193 logging.debug('Running git %s (cwd %r)', ' '.join('%s' % arg 296 logging.debug('Running git %s (cwd %r)', ' '.join('%s' % arg
194 for arg in args), cwd) 297 for arg in args), cwd)
195 298
196 run = subprocess.check_call if interactive else subprocess.check_output 299 run = subprocess.check_call if interactive else subprocess.check_output
197 300
301 # Discard stderr unless verbose is enabled.
302 stderr = None if self._verbose else _DEV_NULL_FILE
303
198 try: 304 try:
199 return run(['git'] + args, 305 return run(['git'] + args, shell=False, cwd=cwd, stderr=stderr)
200 shell=False,
201 cwd=cwd,
202 stderr=self._dev_null_file)
203 except (OSError, subprocess.CalledProcessError) as e: 306 except (OSError, subprocess.CalledProcessError) as e:
204 if error_message: 307 if error_message:
205 raise Error(error_message) 308 raise Error(error_message)
206 else: 309 else:
207 raise Error('Command %r failed: %s' % (' '.join(args), e)) 310 raise Error('Command %r failed: %s' % (' '.join(args), e))
208 311
209 312
210 def cherry_pick_change(branch, revision, parent_repo, dry_run): 313 def cherry_pick_change(branch, revision, parent_repo, dry_run, verbose=False):
211 """Cherry-picks a change into a branch. 314 """Cherry-picks a change into a branch.
212 315
213 Args: 316 Args:
214 branch: A string containing the release branch number to which to 317 branch: A string containing the release branch number to which to
215 cherry-pick. 318 cherry-pick.
216 revision: A string containing the revision to cherry-pick. It can be any 319 revision: A string containing the revision to cherry-pick. It can be any
217 string that git-rev-parse can identify as referring to a single 320 string that git-rev-parse can identify as referring to a single
218 revision. 321 revision.
219 parent_repo: A string containing the path to the parent repo to use for this 322 parent_repo: A string containing the path to the parent repo to use for this
220 cherry-pick. 323 cherry-pick.
221 dry_run: A boolean containing whether to stop before uploading the 324 dry_run: A bool containing whether to stop before uploading the
222 cherry-pick cl. 325 cherry-pick cl.
326 verbose: A bool containing whether to print verbose logging.
223 327
224 Raises: 328 Raises:
225 Error: An error occurred while attempting to cherry-pick |cl| to |branch|. 329 Error: An error occurred while attempting to cherry-pick |cl| to |branch|.
226 """ 330 """
227 drover = _Drover(branch, revision, parent_repo, dry_run) 331 drover = _Drover(branch, revision, parent_repo, dry_run, verbose)
228 drover.run() 332 drover.run()
229 333
230 334
335 def continue_cherry_pick(workdir):
336 """Continues a cherry-pick that required manual resolution.
337
338 Args:
339 workdir: A string containing the path to the workdir used by drover.
340 """
341 _Drover.resume(workdir)
342
343
344 def abort_cherry_pick(workdir):
345 """Aborts a cherry-pick that required manual resolution.
346
347 Args:
348 workdir: A string containing the path to the workdir used by drover.
349 """
350 _Drover.abort(workdir)
351
352
231 def main(): 353 def main():
232 parser = argparse.ArgumentParser( 354 parser = argparse.ArgumentParser(
233 description='Cherry-pick a change into a release branch.') 355 description='Cherry-pick a change into a release branch.')
356 group = parser.add_mutually_exclusive_group(required=True)
234 parser.add_argument( 357 parser.add_argument(
235 '--branch', 358 '--branch',
236 type=str, 359 type=str,
237 required=True,
238 metavar='<branch>', 360 metavar='<branch>',
239 help='the name of the branch to which to cherry-pick; e.g. 1234') 361 help='the name of the branch to which to cherry-pick; e.g. 1234')
240 parser.add_argument('--cherry-pick', 362 group.add_argument(
241 type=str, 363 '--cherry-pick',
242 required=True, 364 type=str,
243 metavar='<change>', 365 metavar='<change>',
244 help=('the change to cherry-pick; this can be any string ' 366 help=('the change to cherry-pick; this can be any string '
245 'that unambiguously refers to a revision')) 367 'that unambiguously refers to a revision not involving HEAD'))
368 group.add_argument(
369 '--continue',
370 type=str,
371 nargs='?',
372 dest='resume',
373 const=os.path.abspath('.'),
374 metavar='path_to_workdir',
375 help='Continue a drover cherry-pick after resolving conflicts')
376 group.add_argument('--abort',
377 type=str,
378 nargs='?',
379 const=os.path.abspath('.'),
380 metavar='path_to_workdir',
381 help='Abort a drover cherry-pick')
246 parser.add_argument( 382 parser.add_argument(
247 '--parent_checkout', 383 '--parent_checkout',
248 type=str, 384 type=str,
249 default=os.path.abspath('.'), 385 default=os.path.abspath('.'),
250 metavar='<path_to_parent_checkout>', 386 metavar='<path_to_parent_checkout>',
251 help=('the path to the chromium checkout to use as the source for a ' 387 help=('the path to the chromium checkout to use as the source for a '
252 'creating git-new-workdir workdir to use for cherry-picking; ' 388 'creating git-new-workdir workdir to use for cherry-picking; '
253 'if unspecified, the current directory is used')) 389 'if unspecified, the current directory is used'))
254 parser.add_argument( 390 parser.add_argument(
255 '--dry-run', 391 '--dry-run',
256 action='store_true', 392 action='store_true',
257 default=False, 393 default=False,
258 help=("don't actually upload and land; " 394 help=("don't actually upload and land; "
259 "just check that cherry-picking would succeed")) 395 "just check that cherry-picking would succeed"))
260 parser.add_argument('-v', 396 parser.add_argument('-v',
261 '--verbose', 397 '--verbose',
262 action='store_true', 398 action='store_true',
263 default=False, 399 default=False,
264 help='show verbose logging') 400 help='show verbose logging')
265 options = parser.parse_args() 401 options = parser.parse_args()
266 if options.verbose: 402 if options.verbose:
267 logging.getLogger().setLevel(logging.DEBUG) 403 logging.getLogger().setLevel(logging.DEBUG)
268 try: 404 try:
269 cherry_pick_change(options.branch, options.cherry_pick, 405 if options.resume:
270 options.parent_checkout, options.dry_run) 406 _Drover.resume(options.resume)
407 elif options.abort:
408 _Drover.abort(options.abort)
409 else:
410 if not options.branch:
411 parser.error('argument --branch is required for --cherry-pick')
412 cherry_pick_change(options.branch, options.cherry_pick,
413 options.parent_checkout, options.dry_run,
414 options.verbose)
271 except Error as e: 415 except Error as e:
272 logging.error(e.message) 416 print 'Error:', e.message
273 sys.exit(128) 417 sys.exit(128)
274 418
275 419
276 if __name__ == '__main__': 420 if __name__ == '__main__':
277 main() 421 main()
OLDNEW
« no previous file with comments | « no previous file | man/html/git-drover.html » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698