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

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, 1 month 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 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()
55 131
56 def run(self): 132 def run(self):
57 """Runs this Drover instance. 133 """Runs this Drover instance.
58 134
59 Raises: 135 Raises:
60 Error: An error occurred while attempting to cherry-pick this change. 136 Error: An error occurred while attempting to cherry-pick this change.
61 """ 137 """
62 try: 138 try:
63 self._run_internal() 139 self._run_internal()
64 finally: 140 finally:
65 self._cleanup() 141 self._cleanup()
66 142
67 def _run_internal(self): 143 def _run_internal(self):
68 self._check_inputs() 144 self._check_inputs()
69 if not self._confirm('Going to cherry-pick\n"""\n%s"""\nto %s.' % ( 145 if not self._confirm('Going to cherry-pick\n"""\n%s"""\nto %s.' % (
70 self._run_git_command(['show', '-s', self._revision]), self._branch)): 146 self._run_git_command(['show', '-s', self._revision]), self._branch)):
71 return 147 return
72 self._create_checkout() 148 self._create_checkout()
73 self._prepare_cherry_pick() 149 self._perform_cherry_pick()
74 if self._dry_run: 150 self._upload_and_land()
75 logging.info('--dry_run enabled; not landing.') 151
152 def _cleanup(self):
153 if not self._needs_cleanup:
76 return 154 return
77 155
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: 156 if self._workdir:
95 logging.debug('Deleting %s', self._workdir) 157 logging.debug('Deleting %s', self._workdir)
96 if os.name == 'nt': 158 if os.name == 'nt':
97 # Use rmdir to properly handle the junctions we created. 159 try:
98 subprocess.check_call(['rmdir', '/s', '/q', self._workdir], shell=True) 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)
99 else: 167 else:
100 shutil.rmtree(self._workdir) 168 shutil.rmtree(self._workdir)
101 self._dev_null_file.close() 169 self._workdir = None
170 if self._branch_name:
171 self._run_git_command(['branch', '-D', self._branch_name])
102 172
103 @staticmethod 173 @staticmethod
104 def _confirm(message): 174 def _confirm(message):
105 """Show a confirmation prompt with the given message. 175 """Show a confirmation prompt with the given message.
106 176
107 Returns: 177 Returns:
108 A bool representing whether the user wishes to continue. 178 A bool representing whether the user wishes to continue.
109 """ 179 """
110 result = '' 180 result = ''
111 while result not in ('y', 'n'): 181 while result not in ('y', 'n'):
(...skipping 47 matching lines...) Expand 10 before | Expand all | Expand 10 after
159 """ 229 """
160 parent_git_dir = os.path.abspath(self._run_git_command( 230 parent_git_dir = os.path.abspath(self._run_git_command(
161 ['rev-parse', '--git-dir']).strip()) 231 ['rev-parse', '--git-dir']).strip())
162 self._workdir = tempfile.mkdtemp(prefix='drover_%s_' % self._branch) 232 self._workdir = tempfile.mkdtemp(prefix='drover_%s_' % self._branch)
163 logging.debug('Creating checkout in %s', self._workdir) 233 logging.debug('Creating checkout in %s', self._workdir)
164 git_dir = os.path.join(self._workdir, '.git') 234 git_dir = os.path.join(self._workdir, '.git')
165 git_common.make_workdir_common(parent_git_dir, git_dir, self.FILES_TO_LINK, 235 git_common.make_workdir_common(parent_git_dir, git_dir, self.FILES_TO_LINK,
166 self.FILES_TO_COPY, mk_symlink) 236 self.FILES_TO_COPY, mk_symlink)
167 self._run_git_command(['config', 'core.sparsecheckout', 'true']) 237 self._run_git_command(['config', 'core.sparsecheckout', 'true'])
168 with open(os.path.join(git_dir, 'info', 'sparse-checkout'), 'w') as f: 238 with open(os.path.join(git_dir, 'info', 'sparse-checkout'), 'w') as f:
169 f.write('codereview.settings') 239 f.write('/codereview.settings')
170 240
171 branch_name = os.path.split(self._workdir)[-1] 241 branch_name = os.path.split(self._workdir)[-1]
172 self._run_git_command(['checkout', '-b', branch_name, self._branch_ref]) 242 self._run_git_command(['checkout', '-b', branch_name, self._branch_ref])
173 self._branch_name = branch_name 243 self._branch_name = branch_name
174 244
175 def _prepare_cherry_pick(self): 245 def _perform_cherry_pick(self):
176 self._run_git_command(['cherry-pick', '-x', self._revision], 246 try:
177 error_message='Patch failed to apply') 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
178 self._run_git_command(['reset', '--hard']) 276 self._run_git_command(['reset', '--hard'])
277 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
179 285
180 def _run_git_command(self, args, error_message=None, interactive=False): 286 def _run_git_command(self, args, error_message=None, interactive=False):
181 """Runs a git command. 287 """Runs a git command.
182 288
183 Args: 289 Args:
184 args: A list of strings containing the args to pass to git. 290 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 291 error_message: A string containing the error message to report if the
187 command fails. 292 command fails.
293 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.
188 296
189 Raises: 297 Raises:
190 Error: The command failed to complete successfully. 298 Error: The command failed to complete successfully.
191 """ 299 """
192 cwd = self._workdir if self._workdir else self._parent_repo 300 cwd = self._workdir if self._workdir else self._parent_repo
193 logging.debug('Running git %s (cwd %r)', ' '.join('%s' % arg 301 logging.debug('Running git %s (cwd %r)', ' '.join('%s' % arg
194 for arg in args), cwd) 302 for arg in args), cwd)
195 303
196 run = subprocess.check_call if interactive else subprocess.check_output 304 run = subprocess.check_call if interactive else subprocess.check_output
197 305
306 # Discard stderr unless verbose is enabled.
307 stderr = None if self._verbose else _DEV_NULL_FILE
308
198 try: 309 try:
199 return run(['git'] + args, 310 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: 311 except (OSError, subprocess.CalledProcessError) as e:
204 if error_message: 312 if error_message:
205 raise Error(error_message) 313 raise Error(error_message)
206 else: 314 else:
207 raise Error('Command %r failed: %s' % (' '.join(args), e)) 315 raise Error('Command %r failed: %s' % (' '.join(args), e))
208 316
209 317
210 def cherry_pick_change(branch, revision, parent_repo, dry_run): 318 def cherry_pick_change(branch, revision, parent_repo, dry_run, verbose=False):
211 """Cherry-picks a change into a branch. 319 """Cherry-picks a change into a branch.
212 320
213 Args: 321 Args:
214 branch: A string containing the release branch number to which to 322 branch: A string containing the release branch number to which to
215 cherry-pick. 323 cherry-pick.
216 revision: A string containing the revision to cherry-pick. It can be any 324 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 325 string that git-rev-parse can identify as referring to a single
218 revision. 326 revision.
219 parent_repo: A string containing the path to the parent repo to use for this 327 parent_repo: A string containing the path to the parent repo to use for this
220 cherry-pick. 328 cherry-pick.
221 dry_run: A boolean containing whether to stop before uploading the 329 dry_run: A bool containing whether to stop before uploading the
222 cherry-pick cl. 330 cherry-pick cl.
331 verbose: A bool containing whether to print verbose logging.
223 332
224 Raises: 333 Raises:
225 Error: An error occurred while attempting to cherry-pick |cl| to |branch|. 334 Error: An error occurred while attempting to cherry-pick |cl| to |branch|.
226 """ 335 """
227 drover = _Drover(branch, revision, parent_repo, dry_run) 336 drover = _Drover(branch, revision, parent_repo, dry_run, verbose)
228 drover.run() 337 drover.run()
229 338
230 339
340 def 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
349 def 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
231 def main(): 358 def main():
232 parser = argparse.ArgumentParser( 359 parser = argparse.ArgumentParser(
233 description='Cherry-pick a change into a release branch.') 360 description='Cherry-pick a change into a release branch.')
361 group = parser.add_mutually_exclusive_group(required=True)
234 parser.add_argument( 362 parser.add_argument(
235 '--branch', 363 '--branch',
236 type=str, 364 type=str,
237 required=True,
238 metavar='<branch>', 365 metavar='<branch>',
239 help='the name of the branch to which to cherry-pick; e.g. 1234') 366 help='the name of the branch to which to cherry-pick; e.g. 1234')
240 parser.add_argument('--cherry-pick', 367 group.add_argument(
241 type=str, 368 '--cherry-pick',
242 required=True, 369 type=str,
243 metavar='<change>', 370 metavar='<change>',
244 help=('the change to cherry-pick; this can be any string ' 371 help=('the change to cherry-pick; this can be any string '
245 'that unambiguously refers to a revision')) 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')
246 parser.add_argument( 387 parser.add_argument(
247 '--parent_checkout', 388 '--parent_checkout',
248 type=str, 389 type=str,
249 default=os.path.abspath('.'), 390 default=os.path.abspath('.'),
250 metavar='<path_to_parent_checkout>', 391 metavar='<path_to_parent_checkout>',
251 help=('the path to the chromium checkout to use as the source for a ' 392 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; ' 393 'creating git-new-workdir workdir to use for cherry-picking; '
253 'if unspecified, the current directory is used')) 394 'if unspecified, the current directory is used'))
254 parser.add_argument( 395 parser.add_argument(
255 '--dry-run', 396 '--dry-run',
256 action='store_true', 397 action='store_true',
257 default=False, 398 default=False,
258 help=("don't actually upload and land; " 399 help=("don't actually upload and land; "
259 "just check that cherry-picking would succeed")) 400 "just check that cherry-picking would succeed"))
260 parser.add_argument('-v', 401 parser.add_argument('-v',
261 '--verbose', 402 '--verbose',
262 action='store_true', 403 action='store_true',
263 default=False, 404 default=False,
264 help='show verbose logging') 405 help='show verbose logging')
265 options = parser.parse_args() 406 options = parser.parse_args()
266 if options.verbose:
267 logging.getLogger().setLevel(logging.DEBUG)
268 try: 407 try:
269 cherry_pick_change(options.branch, options.cherry_pick, 408 if options.resume:
270 options.parent_checkout, options.dry_run) 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)
271 except Error as e: 418 except Error as e:
272 logging.error(e.message) 419 print 'Error:', e.message
273 sys.exit(128) 420 sys.exit(128)
274 421
275 422
276 if __name__ == '__main__': 423 if __name__ == '__main__':
277 main() 424 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