Chromium Code Reviews| OLD | NEW |
|---|---|
| (Empty) | |
| 1 #!/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 | |
| 7 import argparse | |
| 8 import logging | |
| 9 import os | |
| 10 import shutil | |
| 11 import subprocess | |
| 12 import sys | |
| 13 import tempfile | |
| 14 | |
| 15 | |
| 16 class Error(Exception): | |
| 17 pass | |
| 18 | |
| 19 | |
| 20 class _Drover(object): | |
| 21 | |
| 22 def __init__(self, branch, cl, parent_repo, dry_run): | |
| 23 self._branch = branch | |
| 24 self._cl = cl | |
| 25 self._parent_repo = os.path.abspath(parent_repo) | |
| 26 self._dry_run = dry_run | |
| 27 self._workdir = None | |
| 28 self._branch_name = None | |
| 29 self._dev_null_file = None | |
| 30 self._cherry_pick_in_progress = False | |
| 31 self._cwd = os.getcwd() | |
| 32 | |
| 33 def run(self): | |
| 34 """Runs this Drover instance. | |
| 35 | |
| 36 Raises: | |
| 37 Error: An error occurred while attempting to merge this change. | |
| 38 """ | |
| 39 try: | |
| 40 self._run_internal() | |
| 41 finally: | |
| 42 self._cleanup() | |
| 43 | |
| 44 def _run_internal(self): | |
| 45 self._check_inputs() | |
| 46 if not self._confirm('Going to merge this to %s.' % self._branch): | |
| 47 return | |
| 48 self._create_checkout() | |
| 49 self._prepare_merge() | |
| 50 if self._dry_run: | |
| 51 logging.info('--dry_run enabled; not landing.') | |
| 52 return | |
| 53 | |
| 54 self._run_git_command(['cl', 'upload'], error_message='Upload failed') | |
| 55 | |
| 56 if not self._confirm('About to land on %s.' % self._branch): | |
| 57 return | |
| 58 self._run_git_command(['cl', 'land', '--bypass-hooks']) | |
| 59 | |
| 60 def _cleanup(self): | |
| 61 if self._cherry_pick_in_progress: | |
| 62 self._run_git_command(['cherry-pick', '--abort']) | |
|
iannucci
2015/09/22 04:18:11
you could also just run this always and swallow th
Sam McNally
2015/09/23 01:16:07
Done.
| |
| 63 if self._branch_name: | |
| 64 self._run_git_command(['checkout', '--detach'], quiet=True) | |
| 65 self._run_git_command(['branch', '-D', self._branch_name], quiet=True) | |
| 66 os.chdir(self._cwd) | |
|
iannucci
2015/09/22 04:18:11
I'd very strongly advise to avoid chdir'ing (excep
Sam McNally
2015/09/23 01:16:07
Done.
| |
| 67 if self._workdir: | |
| 68 logging.debug('Deleting %s', self._workdir) | |
| 69 shutil.rmtree(self._workdir) | |
| 70 if self._dev_null_file: | |
| 71 self._dev_null_file.close() | |
| 72 self._dev_null_file = None | |
| 73 | |
| 74 @staticmethod | |
| 75 def _confirm(message): | |
| 76 """Show a confirmation prompt with the given message. | |
| 77 | |
| 78 Returns: | |
| 79 A bool representing whether the user wishes to continue. | |
| 80 """ | |
| 81 result = '' | |
| 82 while result not in ('y', 'n'): | |
| 83 try: | |
| 84 result = raw_input('%s Continue (y/n)? ' % message) | |
| 85 except EOFError: | |
| 86 result = 'n' | |
| 87 return result == 'y' | |
| 88 | |
| 89 def _check_inputs(self): | |
| 90 """Check the input arguments and ensure the parent repo is up to date.""" | |
| 91 | |
| 92 if not os.path.isdir(self._parent_repo): | |
| 93 raise Error('Invalid parent repo path %r' % self._parent_repo) | |
| 94 if not hasattr(os, 'symlink'): | |
| 95 raise Error('Symlink support is required') | |
| 96 | |
| 97 os.chdir(self._parent_repo) | |
| 98 self._run_git_command(['--help'], | |
| 99 error_message='Unable to run git', | |
| 100 quiet=True) | |
| 101 self._run_git_command(['status'], | |
| 102 quiet=True, | |
| 103 error_message='%r is not a valid git repo' % | |
| 104 os.path.abspath(self._parent_repo)) | |
| 105 self._run_git_command(['fetch', 'origin'], | |
| 106 error_message='Failed to fetch origin') | |
| 107 self._run_git_command( | |
| 108 ['rev-parse', 'branch-heads/%s' % self._branch], | |
|
iannucci
2015/09/22 04:18:11
let's use absolute refs (so: `refs/branch-heads/__
Sam McNally
2015/09/23 01:16:07
Done. I had to use refs/remotes/branch-heads/...
| |
| 109 quiet=True, | |
| 110 error_message='Branch %s not found' % self._branch) | |
| 111 self._run_git_command(['rev-parse', self._cl], | |
| 112 quiet=True, | |
| 113 error_message='Revision "%s" not found' % self._cl) | |
| 114 self._run_git_command(['show', '-s', self._cl]) | |
| 115 | |
| 116 @staticmethod | |
| 117 def _process_files(source_dir, target_dir, filenames, operation): | |
| 118 """Performs a given operation on a list of files. | |
| 119 | |
| 120 Args: | |
| 121 source_dir: A string containing the source directory. | |
| 122 source_dir: A string containing the target directory. | |
| 123 filenames: A list of strings containing the files within |source_dir| to | |
| 124 be processed. | |
| 125 operation: A binary function to be called for each file in |filenames|, in | |
| 126 the form operation(|source_dir|/file, |target_dir|/file). | |
| 127 """ | |
| 128 for filename in filenames: | |
| 129 if not isinstance(filename, tuple): | |
| 130 filename = (filename,) | |
| 131 target = os.path.join(target_dir, *filename) | |
| 132 source = os.path.join(source_dir, *filename) | |
| 133 if not os.path.exists(os.path.dirname(target)): | |
| 134 os.makedirs(os.path.dirname(target)) | |
| 135 logging.debug('Cloning %r to %r', source, target) | |
| 136 operation(source, target) | |
| 137 | |
| 138 FILES_TO_LINK = [ | |
| 139 'refs', | |
| 140 ('logs', 'refs'), | |
| 141 'objects', | |
| 142 ('info', 'refs'), | |
| 143 ('info', 'exclude'), | |
| 144 'hooks', | |
| 145 'packed-refs', | |
| 146 'remotes', | |
| 147 'rr-cache', | |
| 148 'svn', | |
| 149 ] | |
| 150 FILES_TO_COPY = ['config', 'HEAD'] | |
| 151 | |
| 152 def _create_checkout(self): | |
| 153 """Creates a checkout to use for cherry-picking. | |
| 154 | |
| 155 This creates a checkout similarly to git-new-workdir. Most of the .git | |
| 156 directory is shared with the |self._parent_repo| using symlinks. This | |
| 157 differs from git-new-workdir in that the config is forked instead of shared. | |
| 158 This is so the new workdir can be a sparse checkout without affecting | |
| 159 |self._parent_repo|. | |
| 160 """ | |
| 161 self._workdir = tempfile.mkdtemp(prefix='drover_%s_' % self._branch) | |
| 162 logging.debug('Creating checkout in %s', self._workdir) | |
| 163 os.chdir(self._workdir) | |
| 164 parent_git_dir = os.path.join(self._parent_repo, '.git') | |
|
iannucci
2015/09/22 04:18:11
you can use `git rev-parse --git-dir` to get this
Sam McNally
2015/09/23 01:16:07
Done.
| |
| 165 git_dir = os.path.join(self._workdir, '.git') | |
| 166 os.mkdir(git_dir) | |
| 167 logging.debug('Creating symlinks') | |
| 168 self._process_files(parent_git_dir, git_dir, self.FILES_TO_LINK, os.symlink) | |
| 169 logging.debug('Creating copies') | |
| 170 self._process_files(parent_git_dir, git_dir, self.FILES_TO_COPY, | |
| 171 shutil.copy) | |
| 172 self._run_git_command(['config', 'core.sparsecheckout', 'true']) | |
| 173 with open(os.path.join(git_dir, 'info', 'sparse-checkout'), 'w') as f: | |
| 174 f.write('codereview.settings') | |
| 175 | |
| 176 branch_name = os.path.split(self._workdir)[-1] | |
| 177 self._run_git_command( | |
| 178 ['checkout', '-b', branch_name, 'branch-heads/%s' % self._branch], | |
|
iannucci
2015/09/22 04:18:11
absolute refs
Sam McNally
2015/09/23 01:16:07
Done.
| |
| 179 quiet=True) | |
| 180 self._branch_name = branch_name | |
|
iannucci
2015/09/22 04:18:11
Is there any way we can use the actual git workdir
Sam McNally
2015/09/23 01:16:07
Moved it from gclient-new-workdir.py. This has to
| |
| 181 | |
| 182 def _prepare_merge(self): | |
|
iannucci
2015/09/22 04:18:11
s/merge/cherry_pick/ everywhere (mentioned below)
Sam McNally
2015/09/23 01:16:07
Done.
| |
| 183 self._cherry_pick_in_progress = True | |
| 184 self._run_git_command(['cherry-pick', '-x', self._cl], | |
| 185 quiet=True, | |
| 186 error_message='Patch failed to apply') | |
| 187 self._cherry_pick_in_progress = False | |
|
iannucci
2015/09/22 04:18:11
fwiw, git tracks this bit of information in a file
Sam McNally
2015/09/23 01:16:07
Removed this.
| |
| 188 self._run_git_command(['reset', '--hard'], quiet=True) | |
| 189 | |
| 190 def _run_git_command(self, args, quiet=False, error_message=None): | |
| 191 """Runs a git command. | |
| 192 | |
| 193 Args: | |
| 194 args: A list of strings containing the args to pass to git. | |
| 195 quiet: A bool containing whether to redirect output to /dev/null. | |
| 196 error_message: A string containing the error message to report if the | |
| 197 command fails. | |
| 198 | |
| 199 Raises: | |
| 200 Error: The command failed to complete successfully. | |
| 201 """ | |
| 202 logging.debug('Running git %s', ' '.join('%s' % arg for arg in args)) | |
| 203 output = None | |
| 204 if quiet: | |
| 205 if self._dev_null_file is None: | |
| 206 self._dev_null_file = open(os.devnull, 'w') | |
|
iannucci
2015/09/22 04:18:11
I'd probably do this in __init__, since it's essen
Sam McNally
2015/09/23 01:16:07
Done.
| |
| 207 output = self._dev_null_file | |
| 208 | |
| 209 try: | |
| 210 subprocess.check_call( | |
| 211 ['git'] + args, | |
| 212 stdout=output, | |
| 213 stderr=output, | |
| 214 shell=False) | |
| 215 except (OSError, subprocess.CalledProcessError) as e: | |
| 216 if error_message: | |
| 217 raise Error(error_message) | |
| 218 else: | |
| 219 raise Error('Command %r failed: %s' % (' '.join(args), e)) | |
| 220 | |
| 221 | |
| 222 def merge_change(branch, cl, parent_repo, dry_run): | |
| 223 """Merges a change into a branch. | |
| 224 | |
| 225 Args: | |
| 226 branch: A string containing the release branch number to which to merge. | |
|
iannucci
2015/09/22 04:18:11
Does it work with 'mini' branches?
Sam McNally
2015/09/23 01:16:07
I'm not sure what 'mini' branches are.
| |
| 227 cl: A string containing the git hash of the patch to merge. | |
|
iannucci
2015/09/22 04:18:11
maybe 'revision'?
Is it required to be the full g
Sam McNally
2015/09/23 01:16:07
Done.
| |
| 228 parent_repo: A string containing the path to the parent repo to use for this | |
| 229 merge. | |
| 230 dry_run: A boolean containing whether to stop before uploading the merge cl. | |
| 231 | |
| 232 Raises: | |
| 233 Error: An error occurred while attempting to merge |cl| to |branch|. | |
| 234 """ | |
| 235 drover = _Drover(branch, cl, parent_repo, dry_run) | |
| 236 drover.run() | |
| 237 | |
| 238 | |
| 239 def main(): | |
| 240 parser = argparse.ArgumentParser( | |
| 241 description='Merge a change into a release branch.') | |
| 242 parser.add_argument('--branch', | |
| 243 type=str, | |
| 244 required=True, | |
| 245 metavar='<branch>', | |
| 246 help='the name of the branch to which to merge') | |
|
iannucci
2015/09/22 04:18:11
full name or just the bit after `refs/branch-heads
Sam McNally
2015/09/23 01:16:07
Just the bit after.
| |
| 247 parser.add_argument('--merge', | |
| 248 type=str, | |
| 249 required=True, | |
| 250 metavar='<change>', | |
| 251 help='the hash of the change to merge') | |
|
iannucci
2015/09/22 04:18:11
s/merge/cherry-pick/ since 'merge' means something
Sam McNally
2015/09/23 01:16:07
Done.
| |
| 252 parser.add_argument( | |
| 253 '--parent_checkout', | |
| 254 type=str, | |
| 255 default=os.path.abspath('.'), | |
| 256 metavar='<path_to_parent_checkout>', | |
| 257 help=('the path to the chromium checkout to use; ' | |
|
iannucci
2015/09/22 04:18:11
definitely want to explain what 'use' means here (
Sam McNally
2015/09/23 01:16:07
Done.
| |
| 258 'if unspecified, the current directory is used')) | |
| 259 parser.add_argument('--dry-run', | |
| 260 action='store_true', | |
| 261 default=False, | |
| 262 help=("Don't actually upload and land. " | |
| 263 'Just check that merging would succeed')) | |
|
iannucci
2015/09/22 04:18:11
mixed ' and " is weird... I see why you did it, bu
Sam McNally
2015/09/23 01:16:07
Done.
| |
| 264 parser.add_argument('-v', | |
| 265 '--verbose', | |
| 266 action='store_true', | |
| 267 default=False, | |
| 268 help='Show verbose logging') | |
| 269 options = parser.parse_args() | |
| 270 if options.verbose: | |
| 271 logging.getLogger().setLevel(logging.DEBUG) | |
| 272 try: | |
| 273 merge_change(options.branch, options.merge, options.parent_checkout, | |
| 274 options.dry_run) | |
| 275 except Error as e: | |
| 276 logging.error(e.message) | |
| 277 sys.exit(128) | |
| 278 | |
| 279 | |
| 280 if __name__ == '__main__': | |
| 281 main() | |
| OLD | NEW |