Chromium Code Reviews| Index: git_drover.py |
| diff --git a/git_drover.py b/git_drover.py |
| new file mode 100755 |
| index 0000000000000000000000000000000000000000..21f0f7993956e1338a852d54f66ba6815ec38d32 |
| --- /dev/null |
| +++ b/git_drover.py |
| @@ -0,0 +1,281 @@ |
| +#!/usr/bin/env python |
| +# Copyright 2015 The Chromium Authors. All rights reserved. |
| +# Use of this source code is governed by a BSD-style license that can be |
| +# found in the LICENSE file. |
| +"""git drover: A tool for merging changes to release branches.""" |
| + |
| +import argparse |
| +import logging |
| +import os |
| +import shutil |
| +import subprocess |
| +import sys |
| +import tempfile |
| + |
| + |
| +class Error(Exception): |
| + pass |
| + |
| + |
| +class _Drover(object): |
| + |
| + def __init__(self, branch, cl, parent_repo, dry_run): |
| + self._branch = branch |
| + self._cl = cl |
| + self._parent_repo = os.path.abspath(parent_repo) |
| + self._dry_run = dry_run |
| + self._workdir = None |
| + self._branch_name = None |
| + self._dev_null_file = None |
| + self._cherry_pick_in_progress = False |
| + self._cwd = os.getcwd() |
| + |
| + def run(self): |
| + """Runs this Drover instance. |
| + |
| + Raises: |
| + Error: An error occurred while attempting to merge this change. |
| + """ |
| + try: |
| + self._run_internal() |
| + finally: |
| + self._cleanup() |
| + |
| + def _run_internal(self): |
| + self._check_inputs() |
| + if not self._confirm('Going to merge this to %s.' % self._branch): |
| + return |
| + self._create_checkout() |
| + self._prepare_merge() |
| + if self._dry_run: |
| + logging.info('--dry_run enabled; not landing.') |
| + return |
| + |
| + self._run_git_command(['cl', 'upload'], error_message='Upload failed') |
| + |
| + if not self._confirm('About to land on %s.' % self._branch): |
| + return |
| + self._run_git_command(['cl', 'land', '--bypass-hooks']) |
| + |
| + def _cleanup(self): |
| + if self._cherry_pick_in_progress: |
| + 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.
|
| + if self._branch_name: |
| + self._run_git_command(['checkout', '--detach'], quiet=True) |
| + self._run_git_command(['branch', '-D', self._branch_name], quiet=True) |
| + 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.
|
| + if self._workdir: |
| + logging.debug('Deleting %s', self._workdir) |
| + shutil.rmtree(self._workdir) |
| + if self._dev_null_file: |
| + self._dev_null_file.close() |
| + self._dev_null_file = None |
| + |
| + @staticmethod |
| + def _confirm(message): |
| + """Show a confirmation prompt with the given message. |
| + |
| + Returns: |
| + A bool representing whether the user wishes to continue. |
| + """ |
| + result = '' |
| + while result not in ('y', 'n'): |
| + try: |
| + result = raw_input('%s Continue (y/n)? ' % message) |
| + except EOFError: |
| + result = 'n' |
| + return result == 'y' |
| + |
| + def _check_inputs(self): |
| + """Check the input arguments and ensure the parent repo is up to date.""" |
| + |
| + if not os.path.isdir(self._parent_repo): |
| + raise Error('Invalid parent repo path %r' % self._parent_repo) |
| + if not hasattr(os, 'symlink'): |
| + raise Error('Symlink support is required') |
| + |
| + os.chdir(self._parent_repo) |
| + self._run_git_command(['--help'], |
| + error_message='Unable to run git', |
| + quiet=True) |
| + self._run_git_command(['status'], |
| + quiet=True, |
| + error_message='%r is not a valid git repo' % |
| + os.path.abspath(self._parent_repo)) |
| + self._run_git_command(['fetch', 'origin'], |
| + error_message='Failed to fetch origin') |
| + self._run_git_command( |
| + ['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/...
|
| + quiet=True, |
| + error_message='Branch %s not found' % self._branch) |
| + self._run_git_command(['rev-parse', self._cl], |
| + quiet=True, |
| + error_message='Revision "%s" not found' % self._cl) |
| + self._run_git_command(['show', '-s', self._cl]) |
| + |
| + @staticmethod |
| + def _process_files(source_dir, target_dir, filenames, operation): |
| + """Performs a given operation on a list of files. |
| + |
| + Args: |
| + source_dir: A string containing the source directory. |
| + source_dir: A string containing the target directory. |
| + filenames: A list of strings containing the files within |source_dir| to |
| + be processed. |
| + operation: A binary function to be called for each file in |filenames|, in |
| + the form operation(|source_dir|/file, |target_dir|/file). |
| + """ |
| + for filename in filenames: |
| + if not isinstance(filename, tuple): |
| + filename = (filename,) |
| + target = os.path.join(target_dir, *filename) |
| + source = os.path.join(source_dir, *filename) |
| + if not os.path.exists(os.path.dirname(target)): |
| + os.makedirs(os.path.dirname(target)) |
| + logging.debug('Cloning %r to %r', source, target) |
| + operation(source, target) |
| + |
| + FILES_TO_LINK = [ |
| + 'refs', |
| + ('logs', 'refs'), |
| + 'objects', |
| + ('info', 'refs'), |
| + ('info', 'exclude'), |
| + 'hooks', |
| + 'packed-refs', |
| + 'remotes', |
| + 'rr-cache', |
| + 'svn', |
| + ] |
| + FILES_TO_COPY = ['config', 'HEAD'] |
| + |
| + def _create_checkout(self): |
| + """Creates a checkout to use for cherry-picking. |
| + |
| + This creates a checkout similarly to git-new-workdir. Most of the .git |
| + directory is shared with the |self._parent_repo| using symlinks. This |
| + differs from git-new-workdir in that the config is forked instead of shared. |
| + This is so the new workdir can be a sparse checkout without affecting |
| + |self._parent_repo|. |
| + """ |
| + self._workdir = tempfile.mkdtemp(prefix='drover_%s_' % self._branch) |
| + logging.debug('Creating checkout in %s', self._workdir) |
| + os.chdir(self._workdir) |
| + 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.
|
| + git_dir = os.path.join(self._workdir, '.git') |
| + os.mkdir(git_dir) |
| + logging.debug('Creating symlinks') |
| + self._process_files(parent_git_dir, git_dir, self.FILES_TO_LINK, os.symlink) |
| + logging.debug('Creating copies') |
| + self._process_files(parent_git_dir, git_dir, self.FILES_TO_COPY, |
| + shutil.copy) |
| + self._run_git_command(['config', 'core.sparsecheckout', 'true']) |
| + with open(os.path.join(git_dir, 'info', 'sparse-checkout'), 'w') as f: |
| + f.write('codereview.settings') |
| + |
| + branch_name = os.path.split(self._workdir)[-1] |
| + self._run_git_command( |
| + ['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.
|
| + quiet=True) |
| + 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
|
| + |
| + 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.
|
| + self._cherry_pick_in_progress = True |
| + self._run_git_command(['cherry-pick', '-x', self._cl], |
| + quiet=True, |
| + error_message='Patch failed to apply') |
| + 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.
|
| + self._run_git_command(['reset', '--hard'], quiet=True) |
| + |
| + def _run_git_command(self, args, quiet=False, error_message=None): |
| + """Runs a git command. |
| + |
| + Args: |
| + args: A list of strings containing the args to pass to git. |
| + quiet: A bool containing whether to redirect output to /dev/null. |
| + error_message: A string containing the error message to report if the |
| + command fails. |
| + |
| + Raises: |
| + Error: The command failed to complete successfully. |
| + """ |
| + logging.debug('Running git %s', ' '.join('%s' % arg for arg in args)) |
| + output = None |
| + if quiet: |
| + if self._dev_null_file is None: |
| + 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.
|
| + output = self._dev_null_file |
| + |
| + try: |
| + subprocess.check_call( |
| + ['git'] + args, |
| + stdout=output, |
| + stderr=output, |
| + shell=False) |
| + except (OSError, subprocess.CalledProcessError) as e: |
| + if error_message: |
| + raise Error(error_message) |
| + else: |
| + raise Error('Command %r failed: %s' % (' '.join(args), e)) |
| + |
| + |
| +def merge_change(branch, cl, parent_repo, dry_run): |
| + """Merges a change into a branch. |
| + |
| + Args: |
| + 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.
|
| + 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.
|
| + parent_repo: A string containing the path to the parent repo to use for this |
| + merge. |
| + dry_run: A boolean containing whether to stop before uploading the merge cl. |
| + |
| + Raises: |
| + Error: An error occurred while attempting to merge |cl| to |branch|. |
| + """ |
| + drover = _Drover(branch, cl, parent_repo, dry_run) |
| + drover.run() |
| + |
| + |
| +def main(): |
| + parser = argparse.ArgumentParser( |
| + description='Merge a change into a release branch.') |
| + parser.add_argument('--branch', |
| + type=str, |
| + required=True, |
| + metavar='<branch>', |
| + 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.
|
| + parser.add_argument('--merge', |
| + type=str, |
| + required=True, |
| + metavar='<change>', |
| + 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.
|
| + parser.add_argument( |
| + '--parent_checkout', |
| + type=str, |
| + default=os.path.abspath('.'), |
| + metavar='<path_to_parent_checkout>', |
| + 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.
|
| + 'if unspecified, the current directory is used')) |
| + parser.add_argument('--dry-run', |
| + action='store_true', |
| + default=False, |
| + help=("Don't actually upload and land. " |
| + '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.
|
| + parser.add_argument('-v', |
| + '--verbose', |
| + action='store_true', |
| + default=False, |
| + help='Show verbose logging') |
| + options = parser.parse_args() |
| + if options.verbose: |
| + logging.getLogger().setLevel(logging.DEBUG) |
| + try: |
| + merge_change(options.branch, options.merge, options.parent_checkout, |
| + options.dry_run) |
| + except Error as e: |
| + logging.error(e.message) |
| + sys.exit(128) |
| + |
| + |
| +if __name__ == '__main__': |
| + main() |