Add a git-drover.

This uses the same trick as git-new-workdir to reuse an existing git
checkout without interfering with it. However, this makes it only usable
on platforms where os.symlink exists.

BUG=404755

Review URL: https://codereview.chromium.org/1342383002

git-svn-id: svn://svn.chromium.org/chrome/trunk/tools/depot_tools@296920 0039d316-1c4b-4281-b951-d872f2087c98
diff --git a/gclient-new-workdir.py b/gclient-new-workdir.py
index 6c241c5..c469824 100755
--- a/gclient-new-workdir.py
+++ b/gclient-new-workdir.py
@@ -13,6 +13,8 @@
 import sys
 import textwrap
 
+import git_common
+
 
 def print_err(msg):
   print >> sys.stderr, msg
@@ -32,7 +34,7 @@
 
     <repository> should contain a .gclient file
     <new_workdir> must not exist
-    ''' % os.path.basename(sys.argv[0])
+    '''% os.path.basename(sys.argv[0])
 
   print_err(textwrap.dedent(usage_msg))
   sys.exit(1)
@@ -70,43 +72,11 @@
   for root, dirs, _ in os.walk(repository):
     if '.git' in dirs:
       workdir = root.replace(repository, new_workdir, 1)
-      make_workdir(os.path.join(root, '.git'),
-                   os.path.join(workdir, '.git'))
-
-
-def make_workdir(repository, new_workdir):
-  print('Creating: ' + new_workdir)
-  os.makedirs(new_workdir)
-
-  GIT_DIRECTORY_WHITELIST = [
-    'config',
-    'info',
-    'hooks',
-    'logs/refs',
-    'objects',
-    'packed-refs',
-    'refs',
-    'remotes',
-    'rr-cache',
-    'svn'
-  ]
-
-  for entry in GIT_DIRECTORY_WHITELIST:
-    make_symlink(repository, new_workdir, entry)
-
-  shutil.copy2(os.path.join(repository, 'HEAD'),
-               os.path.join(new_workdir, 'HEAD'))
-  subprocess.check_call(['git', 'checkout', '-f'],
-                        cwd=new_workdir.rstrip('.git'))
-
-
-def make_symlink(repository, new_workdir, link):
-  if not os.path.exists(os.path.join(repository, link)):
-    return
-  link_dir = os.path.dirname(os.path.join(new_workdir, link))
-  if not os.path.exists(link_dir):
-    os.makedirs(link_dir)
-  os.symlink(os.path.join(repository, link), os.path.join(new_workdir, link))
+      print('Creating: %s' % workdir)
+      git_common.make_workdir(os.path.join(root, '.git'),
+                              os.path.join(workdir, '.git'))
+      subprocess.check_call(['git', 'checkout', '-f'],
+                            cwd=new_workdir.rstrip('.git'))
 
 
 if __name__ == '__main__':
diff --git a/git-drover b/git-drover
new file mode 100755
index 0000000..ff4eba7
--- /dev/null
+++ b/git-drover
@@ -0,0 +1,6 @@
+#!/usr/bin/env bash
+# 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.
+
+. $(type -P python_git_runner.sh)
diff --git a/git_common.py b/git_common.py
index 9e0adf2..5a01c83 100644
--- a/git_common.py
+++ b/git_common.py
@@ -22,6 +22,7 @@
 import logging
 import os
 import re
+import shutil
 import signal
 import sys
 import tempfile
@@ -817,3 +818,38 @@
       missing_upstreams[info.upstream] = None
 
   return dict(info_map.items() + missing_upstreams.items())
+
+
+def make_workdir_common(repository, new_workdir, files_to_symlink,
+                        files_to_copy):
+  os.makedirs(new_workdir)
+  for entry in files_to_symlink:
+    clone_file(repository, new_workdir, entry, os.symlink)
+  for entry in files_to_copy:
+    clone_file(repository, new_workdir, entry, shutil.copy)
+
+
+def make_workdir(repository, new_workdir):
+  GIT_DIRECTORY_WHITELIST = [
+    'config',
+    'info',
+    'hooks',
+    'logs/refs',
+    'objects',
+    'packed-refs',
+    'refs',
+    'remotes',
+    'rr-cache',
+    'svn'
+  ]
+  make_workdir_common(repository, new_workdir, GIT_DIRECTORY_WHITELIST,
+                      ['HEAD'])
+
+
+def clone_file(repository, new_workdir, link, operation):
+  if not os.path.exists(os.path.join(repository, link)):
+    return
+  link_dir = os.path.dirname(os.path.join(new_workdir, link))
+  if not os.path.exists(link_dir):
+    os.makedirs(link_dir)
+  operation(os.path.join(repository, link), os.path.join(new_workdir, link))
diff --git a/git_drover.py b/git_drover.py
new file mode 100755
index 0000000..18756d8
--- /dev/null
+++ b/git_drover.py
@@ -0,0 +1,254 @@
+#!/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 functools
+import logging
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+
+import git_common
+
+
+class Error(Exception):
+  pass
+
+
+class _Drover(object):
+
+  def __init__(self, branch, revision, parent_repo, dry_run):
+    self._branch = branch
+    self._branch_ref = 'refs/remotes/branch-heads/%s' % branch
+    self._revision = revision
+    self._parent_repo = os.path.abspath(parent_repo)
+    self._dry_run = dry_run
+    self._workdir = None
+    self._branch_name = None
+    self._dev_null_file = open(os.devnull, 'w')
+
+  def run(self):
+    """Runs this Drover instance.
+
+    Raises:
+      Error: An error occurred while attempting to cherry-pick this change.
+    """
+    try:
+      self._run_internal()
+    finally:
+      self._cleanup()
+
+  def _run_internal(self):
+    self._check_inputs()
+    if not self._confirm('Going to cherry-pick\n"""\n%s"""\nto %s.' % (
+        self._run_git_command(['show', '-s', self._revision]), self._branch)):
+      return
+    self._create_checkout()
+    self._prepare_cherry_pick()
+    if self._dry_run:
+      logging.info('--dry_run enabled; not landing.')
+      return
+
+    self._run_git_command(['cl', 'upload'],
+                          error_message='Upload failed',
+                          interactive=True)
+
+    if not self._confirm('About to land on %s.' % self._branch):
+      return
+    self._run_git_command(['cl', 'land', '--bypass-hooks'], interactive=True)
+
+  def _cleanup(self):
+    if self._branch_name:
+      try:
+        self._run_git_command(['cherry-pick', '--abort'])
+      except Error:
+        pass
+      self._run_git_command(['checkout', '--detach'])
+      self._run_git_command(['branch', '-D', self._branch_name])
+    if self._workdir:
+      logging.debug('Deleting %s', self._workdir)
+      shutil.rmtree(self._workdir)
+    self._dev_null_file.close()
+
+  @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')
+
+    self._run_git_command(['--help'], error_message='Unable to run git')
+    self._run_git_command(['status'],
+                          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', '%s^{commit}' % self._branch_ref],
+        error_message='Branch %s not found' % self._branch_ref)
+    self._run_git_command(
+        ['rev-parse', '%s^{commit}' % self._revision],
+        error_message='Revision "%s" not found' % self._revision)
+
+  FILES_TO_LINK = [
+      'refs',
+      'logs/refs',
+      'info/refs',
+      'info/exclude',
+      'objects',
+      '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|.
+    """
+    parent_git_dir = os.path.abspath(self._run_git_command(
+        ['rev-parse', '--git-dir']).strip())
+    self._workdir = tempfile.mkdtemp(prefix='drover_%s_' % self._branch)
+    logging.debug('Creating checkout in %s', self._workdir)
+    git_dir = os.path.join(self._workdir, '.git')
+    git_common.make_workdir_common(parent_git_dir, git_dir, self.FILES_TO_LINK,
+                                   self.FILES_TO_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, self._branch_ref])
+    self._branch_name = branch_name
+
+  def _prepare_cherry_pick(self):
+    self._run_git_command(['cherry-pick', '-x', self._revision],
+                          error_message='Patch failed to apply')
+    self._run_git_command(['reset', '--hard'])
+
+  def _run_git_command(self, args, error_message=None, interactive=False):
+    """Runs a git command.
+
+    Args:
+      args: A list of strings containing the args to pass to git.
+      interactive:
+      error_message: A string containing the error message to report if the
+          command fails.
+
+    Raises:
+      Error: The command failed to complete successfully.
+    """
+    cwd = self._workdir if self._workdir else self._parent_repo
+    logging.debug('Running git %s (cwd %r)', ' '.join('%s' % arg
+                                                      for arg in args), cwd)
+
+    run = subprocess.check_call if interactive else subprocess.check_output
+
+    try:
+      return run(['git'] + args,
+                 shell=False,
+                 cwd=cwd,
+                 stderr=self._dev_null_file)
+    except (OSError, subprocess.CalledProcessError) as e:
+      if error_message:
+        raise Error(error_message)
+      else:
+        raise Error('Command %r failed: %s' % (' '.join(args), e))
+
+
+def cherry_pick_change(branch, revision, parent_repo, dry_run):
+  """Cherry-picks a change into a branch.
+
+  Args:
+    branch: A string containing the release branch number to which to
+        cherry-pick.
+    revision: A string containing the revision to cherry-pick. It can be any
+        string that git-rev-parse can identify as referring to a single
+        revision.
+    parent_repo: A string containing the path to the parent repo to use for this
+        cherry-pick.
+    dry_run: A boolean containing whether to stop before uploading the
+        cherry-pick cl.
+
+  Raises:
+    Error: An error occurred while attempting to cherry-pick |cl| to |branch|.
+  """
+  drover = _Drover(branch, revision, parent_repo, dry_run)
+  drover.run()
+
+
+def main():
+  parser = argparse.ArgumentParser(
+      description='Cherry-pick 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 cherry-pick; e.g. 1234')
+  parser.add_argument('--cherry-pick',
+                      type=str,
+                      required=True,
+                      metavar='<change>',
+                      help=('the change to cherry-pick; this can be any string '
+                            'that unambiguously refers to a revision'))
+  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 as the source for a '
+            'creating git-new-workdir workdir to use for cherry-picking; '
+            '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 cherry-picking would succeed"))
+  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:
+    cherry_pick_change(options.branch, options.cherry_pick,
+                       options.parent_checkout, options.dry_run)
+  except Error as e:
+    logging.error(e.message)
+    sys.exit(128)
+
+
+if __name__ == '__main__':
+  main()
diff --git a/man/html/git-drover.html b/man/html/git-drover.html
index fc5366d..3171d2a 100644
--- a/man/html/git-drover.html
+++ b/man/html/git-drover.html
@@ -755,7 +755,9 @@
 <h2 id="_synopsis">SYNOPSIS</h2>
 <div class="sectionbody">
 <div class="verseblock">
-<pre class="content"><em>git drover</em></pre>
+<pre class="content"><em>git drover</em> --branch &lt;branch&gt; --cherry-pick &lt;commit&gt;
+           [--parent_checkout &lt;path-to-existing-checkout&gt;]
+           [--verbose] [--dry-run]</pre>
 <div class="attribution">
 </div></div>
 </div>
@@ -763,8 +765,65 @@
 <div class="sect1">
 <h2 id="_description">DESCRIPTION</h2>
 <div class="sectionbody">
-<div class="paragraph"><p><code>git drover</code> is NOT IMPLEMENTED yet. See the EXAMPLE section for the equivalent
-sequence of commands to run.</p></div>
+<div class="paragraph"><p><code>git drover</code> applies a commit to a release branch. It creates a new workdir from
+an existing checkout to avoid downloading a new checkout without affecting the
+existing checkout. Creating a workdir requires symlinks so this does not work on
+Windows. See the EXAMPLE section for the equivalent sequence of commands to run.</p></div>
+<div class="paragraph"><p><code>git drover</code> does not support reverts. See the EXAMPLE section for the
+equivalent sequence of commands to run.</p></div>
+</div>
+</div>
+<div class="sect1">
+<h2 id="_options">OPTIONS</h2>
+<div class="sectionbody">
+<div class="dlist"><dl>
+<dt class="hdlist1">
+--branch &lt;branch&gt;
+</dt>
+<dd>
+<p>
+  The branch to cherry-pick the commit to.
+</p>
+</dd>
+<dt class="hdlist1">
+--cherry-pick &lt;commit&gt;
+</dt>
+<dd>
+<p>
+  The commit to cherry-pick.
+</p>
+</dd>
+<dt class="hdlist1">
+--parent_checkout
+</dt>
+<dd>
+<p>
+  The path to the chromium checkout to use as the source for a creating
+  git-new-workdir workdir to use for cherry-picking. If unspecified, the current
+  directory is used.
+</p>
+</dd>
+<dt class="hdlist1">
+-v
+</dt>
+<dt class="hdlist1">
+--verbose
+</dt>
+<dd>
+<p>
+  Enable verbose logging.
+</p>
+</dd>
+<dt class="hdlist1">
+--dry-run
+</dt>
+<dd>
+<p>
+  Skip landing the cherry-pick. Just ensure that the commit can be cherry-picked
+  into the branch.
+</p>
+</dd>
+</dl></div>
 </div>
 </div>
 <div class="sect1">
@@ -777,12 +836,9 @@
 at least once to fetch the branches.</p></div>
 <div class="sect3">
 <h4 id="_merge_example">Merge Example</h4>
-<div class="paragraph"><p></p></div><div class="listingblock"><div class="content"><pre><code># Make sure we have the most up-to-date branch sources.
-<span style="font-weight: bold; color: #ffffff">$ git fetch</span>
-
-# Here's a commit (from some.committer) that we want to 'drover'.
+<div class="paragraph"><p></p></div><div class="listingblock"><div class="content"><pre><code># Here's a commit (from some.committer) that we want to 'drover'.
 <span style="font-weight: bold; color: #ffffff">$ git log -n 1 --pretty=fuller</span>
-commit 4a00a0c3c1bb01f11b42cb70f3ad587026cec02b
+<span style="color: #e7e71c">commit 8b79b7b2f7e6e728f9a3c7b385c72efc7c47244a</span>
 Author:     some.committer &lt;[email protected]&gt;
 AuthorDate: Thu Apr 10 08:54:46 2014 +0000
 Commit:     some.committer &lt;[email protected]&gt;
@@ -790,37 +846,22 @@
 
     This change needs to go to branch 9999
 
-# Checkout the branch we want to 'drover' to.
-<span style="font-weight: bold; color: #ffffff">$ git checkout -b drover_9999 branch-heads/9999</span>
-Branch drover_9999 set up to track remote ref refs/branch-heads/9999.
-
 # Now do the 'drover'.
-# IMPORTANT!!! Do Not leave off the '-x' flag
-<span style="font-weight: bold; color: #ffffff">$ git cherry-pick -x 4a00a0c3c1bb01f11b42cb70f3ad587026cec02b</span>
-[drover_9999 19d3d0b] This change needs to go to branch 9999
- Author: some.committer &lt;[email protected]&gt;
- Date: Thu Apr 10 08:54:46 2014 +0000
- 1 file changed, 1 insertion(+)
- create mode 100644 modified_file
-
-# That took the code authored by some.committer and committed it to
-# the branch by the person who drovered it (i.e. you).
-<span style="font-weight: bold; color: #ffffff">$ git log -n 1 --pretty=fuller</span>
-commit 19d3d0b9d8f802df8e2fd563cbc919679d310ecd
-Author:     some.committer &lt;[email protected]&gt;
-AuthorDate: Thu Apr 10 08:54:46 2014 +0000
-Commit:     you &lt;[email protected]&gt;
-CommitDate: Thu Apr 10 09:11:36 2014 +0000
+<span style="font-weight: bold; color: #ffffff">$ git drover --branch 9999 --cherry-pick 8b79b7b2f7e6e728f9a3c7b385c72efc7c47244a</span>
+Going to cherry-pick
+"""
+<span style="color: #e7e71c">commit 8b79b7b2f7e6e728f9a3c7b385c72efc7c47244a</span>
+Author: some.committer &lt;[email protected]&gt;
+Date:   Thu Apr 10 08:54:46 2014 +0000
 
     This change needs to go to branch 9999
+"""
+to 9999. Continue (y/n)? y
 
-    (cherry picked from commit 4a00a0c3c1bb01f11b42cb70f3ad587026cec02b)
+# A cl is uploaded to rietveld, where it can be reviewed before landing.
 
-# Looks good. Ship it!
-<span style="font-weight: bold; color: #ffffff">$ git cl upload</span>
-# Wait for LGTM or TBR it.
-<span style="font-weight: bold; color: #ffffff">$ git cl land</span>
-# Or skip the LGTM/TBR and just 'git cl land --bypass-hooks'
+About to land on 9999. Continue (y/n)? y
+# The cherry-pick cl is landed on the branch 9999.
 </code></pre></div></div><p><div class="paragraph"></p></div>
 </div>
 <div class="sect3">
@@ -834,30 +875,78 @@
 
 # Here's the commit we want to revert.
 <span style="font-weight: bold; color: #ffffff">$ git log -n 1</span>
-commit 590b333cbc04d13da67b2a1c5282835d4f27e398
+<span style="color: #e7e71c">commit 33b0e9164d4564eb8a4b4e5b951bba6edeeecacb</span>
 Author: some.committer &lt;[email protected]&gt;
 Date:   Thu Apr 10 08:54:46 2014 +0000
 
     This change is horribly broken.
 
 # Now do the revert.
-<span style="font-weight: bold; color: #ffffff">$ git revert 590b333cbc04d13da67b2a1c5282835d4f27e398</span>
+<span style="font-weight: bold; color: #ffffff">$ git revert 33b0e9164d4564eb8a4b4e5b951bba6edeeecacb</span>
 
 # That reverted the change and committed the revert.
 <span style="font-weight: bold; color: #ffffff">$ git log -n 1</span>
-commit 6f541155a9adf98f4e7f94dd561d022fb022d43f
+<span style="color: #e7e71c">commit 8a2d2bb98b9cfc9260a9bc86da1eec2a43f43f8b</span>
 Author: you &lt;[email protected]&gt;
 Date:   Thu Apr 10 09:11:36 2014 +0000
 
     Revert "This change is horribly broken."
 
-    This reverts commit 590b333cbc04d13da67b2a1c5282835d4f27e398.
+    This reverts commit 33b0e9164d4564eb8a4b4e5b951bba6edeeecacb.
 
 # As with old drover, reverts are generally OK to commit without LGTM.
 <span style="font-weight: bold; color: #ffffff">$ git cl upload -r [email protected] --send-mail</span>
 <span style="font-weight: bold; color: #ffffff">$ git cl land --bypass-hooks</span>
 </code></pre></div></div><p><div class="paragraph"></p></div>
 </div>
+<div class="sect3">
+<h4 id="_manual_merge_example">Manual Merge Example</h4>
+<div class="paragraph"><p></p></div><div class="listingblock"><div class="content"><pre><code># Make sure we have the most up-to-date branch sources.
+<span style="font-weight: bold; color: #ffffff">$ git fetch</span>
+
+# Here's a commit (from some.committer) that we want to 'drover'.
+<span style="font-weight: bold; color: #ffffff">$ git log -n 1 --pretty=fuller</span>
+<span style="color: #e7e71c">commit 537f446fa3d5e41acab017bb0b082fbd0c9eb043</span>
+Author:     some.committer &lt;[email protected]&gt;
+AuthorDate: Thu Apr 10 08:54:46 2014 +0000
+Commit:     some.committer &lt;[email protected]&gt;
+CommitDate: Thu Apr 10 08:54:46 2014 +0000
+
+    This change needs to go to branch 9999
+
+# Checkout the branch we want to 'drover' to.
+<span style="font-weight: bold; color: #ffffff">$ git checkout -b drover_9999 branch-heads/9999</span>
+Branch drover_9999 set up to track remote ref refs/branch-heads/9999.
+
+# Now do the 'drover'.
+# IMPORTANT!!! Do Not leave off the '-x' flag
+<span style="font-weight: bold; color: #ffffff">$ git cherry-pick -x 537f446fa3d5e41acab017bb0b082fbd0c9eb043</span>
+[drover_9999 b468abc] This change needs to go to branch 9999
+ Author: some.committer &lt;[email protected]&gt;
+ Date: Thu Apr 10 08:54:46 2014 +0000
+ 1 file changed, 1 insertion(+)
+ create mode 100644 modified_file
+
+# That took the code authored by some.committer and committed it to
+# the branch by the person who drovered it (i.e. you).
+<span style="font-weight: bold; color: #ffffff">$ git log -n 1 --pretty=fuller</span>
+<span style="color: #e7e71c">commit b468abc42ddd4fd9aecc48c3eda172265306d2b4</span>
+Author:     some.committer &lt;[email protected]&gt;
+AuthorDate: Thu Apr 10 08:54:46 2014 +0000
+Commit:     you &lt;[email protected]&gt;
+CommitDate: Thu Apr 10 09:11:36 2014 +0000
+
+    This change needs to go to branch 9999
+
+    (cherry picked from commit 537f446fa3d5e41acab017bb0b082fbd0c9eb043)
+
+# Looks good. Ship it!
+<span style="font-weight: bold; color: #ffffff">$ git cl upload</span>
+# Wait for LGTM or TBR it.
+<span style="font-weight: bold; color: #ffffff">$ git cl land</span>
+# Or skip the LGTM/TBR and just 'git cl land --bypass-hooks'
+</code></pre></div></div><p><div class="paragraph"></p></div>
+</div>
 </div>
 </div>
 </div>
@@ -879,7 +968,7 @@
 <div id="footnotes"><hr /></div>
 <div id="footer">
 <div id="footer-text">
-Last updated 2014-09-09 13:42:13 PDT
+Last updated 2015-09-23 11:11:58 AEST
 </div>
 </div>
 </body>
diff --git a/man/man1/git-drover.1 b/man/man1/git-drover.1
index 17779cc..2041fa1 100644
--- a/man/man1/git-drover.1
+++ b/man/man1/git-drover.1
@@ -1,13 +1,13 @@
 '\" t
 .\"     Title: git-drover
 .\"    Author: [FIXME: author] [see http://docbook.sf.net/el/author]
-.\" Generator: DocBook XSL Stylesheets v1.76.1 <http://docbook.sf.net/>
-.\"      Date: 09/09/2014
+.\" Generator: DocBook XSL Stylesheets v1.78.1 <http://docbook.sf.net/>
+.\"      Date: 09/23/2015
 .\"    Manual: Chromium depot_tools Manual
-.\"    Source: depot_tools 6e7202b
+.\"    Source: depot_tools 4549a59
 .\"  Language: English
 .\"
-.TH "GIT\-DROVER" "1" "09/09/2014" "depot_tools 6e7202b" "Chromium depot_tools Manual"
+.TH "GIT\-DROVER" "1" "09/23/2015" "depot_tools 4549a59" "Chromium depot_tools Manual"
 .\" -----------------------------------------------------------------
 .\" * Define some portability stuff
 .\" -----------------------------------------------------------------
@@ -32,12 +32,42 @@
 .SH "SYNOPSIS"
 .sp
 .nf
-\fIgit drover\fR
+\fIgit drover\fR \-\-branch <branch> \-\-cherry\-pick <commit>
+           [\-\-parent_checkout <path\-to\-existing\-checkout>]
+           [\-\-verbose] [\-\-dry\-run]
 .fi
 .sp
 .SH "DESCRIPTION"
 .sp
-git drover is NOT IMPLEMENTED yet\&. See the EXAMPLE section for the equivalent sequence of commands to run\&.
+git drover applies a commit to a release branch\&. It creates a new workdir from an existing checkout to avoid downloading a new checkout without affecting the existing checkout\&. Creating a workdir requires symlinks so this does not work on Windows\&. See the EXAMPLE section for the equivalent sequence of commands to run\&.
+.sp
+git drover does not support reverts\&. See the EXAMPLE section for the equivalent sequence of commands to run\&.
+.SH "OPTIONS"
+.PP
+\-\-branch <branch>
+.RS 4
+The branch to cherry\-pick the commit to\&.
+.RE
+.PP
+\-\-cherry\-pick <commit>
+.RS 4
+The commit to cherry\-pick\&.
+.RE
+.PP
+\-\-parent_checkout
+.RS 4
+The path to the chromium checkout to use as the source for a creating git\-new\-workdir workdir to use for cherry\-picking\&. If unspecified, the current directory is used\&.
+.RE
+.PP
+\-v, \-\-verbose
+.RS 4
+Enable verbose logging\&.
+.RE
+.PP
+\-\-dry\-run
+.RS 4
+Skip landing the cherry\-pick\&. Just ensure that the commit can be cherry\-picked into the branch\&.
+.RE
 .SH "EXAMPLE"
 .SS "PREREQUISITES"
 .sp
@@ -57,12 +87,9 @@
 .RS 4
 .\}
 .nf
-# Make sure we have the most up\-to\-date branch sources\&.
-\fB$ git fetch\fR
-
 # Here\*(Aqs a commit (from some\&.committer) that we want to \*(Aqdrover\*(Aq\&.
 \fB$ git log \-n 1 \-\-pretty=fuller\fR
-commit 0421d3583f73220c8f88b1a96898fcd81222fe73
+commit b5a049e34297f22a4ea63567b32e3290bb3f244c
 Author:     some\&.committer <some\&.committer@chromium\&.org>
 AuthorDate: Thu Apr 10 08:54:46 2014 +0000
 Commit:     some\&.committer <some\&.committer@chromium\&.org>
@@ -70,37 +97,22 @@
 
     This change needs to go to branch 9999
 
-# Checkout the branch we want to \*(Aqdrover\*(Aq to\&.
-\fB$ git checkout \-b drover_9999 branch\-heads/9999\fR
-Branch drover_9999 set up to track remote ref refs/branch\-heads/9999\&.
-
 # Now do the \*(Aqdrover\*(Aq\&.
-# IMPORTANT!!! Do Not leave off the \*(Aq\-x\*(Aq flag
-\fB$ git cherry\-pick \-x 0421d3583f73220c8f88b1a96898fcd81222fe73\fR
-[drover_9999 5c0a17d] This change needs to go to branch 9999
- Author: some\&.committer <some\&.committer@chromium\&.org>
- Date: Thu Apr 10 08:54:46 2014 +0000
- 1 file changed, 1 insertion(+)
- create mode 100644 modified_file
-
-# That took the code authored by some\&.committer and committed it to
-# the branch by the person who drovered it (i\&.e\&. you)\&.
-\fB$ git log \-n 1 \-\-pretty=fuller\fR
-commit 5c0a17dd382cd098182ac9f486ccd6b86c28d96e
-Author:     some\&.committer <some\&.committer@chromium\&.org>
-AuthorDate: Thu Apr 10 08:54:46 2014 +0000
-Commit:     you <you@chromium\&.org>
-CommitDate: Thu Apr 10 09:11:36 2014 +0000
+\fB$ git drover \-\-branch 9999 \-\-cherry\-pick b5a049e34297f22a4ea63567b32e3290bb3f244c\fR
+Going to cherry\-pick
+"""
+commit b5a049e34297f22a4ea63567b32e3290bb3f244c
+Author: some\&.committer <some\&.committer@chromium\&.org>
+Date:   Thu Apr 10 08:54:46 2014 +0000
 
     This change needs to go to branch 9999
+"""
+to 9999\&. Continue (y/n)? y
 
-    (cherry picked from commit 0421d3583f73220c8f88b1a96898fcd81222fe73)
+# A cl is uploaded to rietveld, where it can be reviewed before landing\&.
 
-# Looks good\&. Ship it!
-\fB$ git cl upload\fR
-# Wait for LGTM or TBR it\&.
-\fB$ git cl land\fR
-# Or skip the LGTM/TBR and just \*(Aqgit cl land \-\-bypass\-hooks\*(Aq
+About to land on 9999\&. Continue (y/n)? y
+# The cherry\-pick cl is landed on the branch 9999\&.
 .fi
 .if n \{\
 .RE
@@ -131,24 +143,24 @@
 
 # Here\*(Aqs the commit we want to revert\&.
 \fB$ git log \-n 1\fR
-commit 28bb44fa7f9d5e19b73a670ae923d3a96dec250a
+commit 215689406a8ca5813412becc6258509be903db59
 Author: some\&.committer <some\&.committer@chromium\&.org>
 Date:   Thu Apr 10 08:54:46 2014 +0000
 
     This change is horribly broken\&.
 
 # Now do the revert\&.
-\fB$ git revert 28bb44fa7f9d5e19b73a670ae923d3a96dec250a\fR
+\fB$ git revert 215689406a8ca5813412becc6258509be903db59\fR
 
 # That reverted the change and committed the revert\&.
 \fB$ git log \-n 1\fR
-commit 4618467de1407aa159624015c8c8461ec35fbaf1
+commit 1efaf0e8b1c6c6afadfb37e15023b52b960ac2fd
 Author: you <you@chromium\&.org>
 Date:   Thu Apr 10 09:11:36 2014 +0000
 
     Revert "This change is horribly broken\&."
 
-    This reverts commit 28bb44fa7f9d5e19b73a670ae923d3a96dec250a\&.
+    This reverts commit 215689406a8ca5813412becc6258509be903db59\&.
 
 # As with old drover, reverts are generally OK to commit without LGTM\&.
 \fB$ git cl upload \-r some\&.committer@chromium\&.org \-\-send\-mail\fR
@@ -159,6 +171,71 @@
 .\}
 .sp
 .RE
+.sp
+.it 1 an-trap
+.nr an-no-space-flag 1
+.nr an-break-flag 1
+.br
+.ps +1
+\fBManual Merge Example\fR
+.RS 4
+.sp
+
+.sp
+.if n \{\
+.RS 4
+.\}
+.nf
+# Make sure we have the most up\-to\-date branch sources\&.
+\fB$ git fetch\fR
+
+# Here\*(Aqs a commit (from some\&.committer) that we want to \*(Aqdrover\*(Aq\&.
+\fB$ git log \-n 1 \-\-pretty=fuller\fR
+commit 640f962733bfd2b9c44539a0d65952643750957e
+Author:     some\&.committer <some\&.committer@chromium\&.org>
+AuthorDate: Thu Apr 10 08:54:46 2014 +0000
+Commit:     some\&.committer <some\&.committer@chromium\&.org>
+CommitDate: Thu Apr 10 08:54:46 2014 +0000
+
+    This change needs to go to branch 9999
+
+# Checkout the branch we want to \*(Aqdrover\*(Aq to\&.
+\fB$ git checkout \-b drover_9999 branch\-heads/9999\fR
+Branch drover_9999 set up to track remote ref refs/branch\-heads/9999\&.
+
+# Now do the \*(Aqdrover\*(Aq\&.
+# IMPORTANT!!! Do Not leave off the \*(Aq\-x\*(Aq flag
+\fB$ git cherry\-pick \-x 640f962733bfd2b9c44539a0d65952643750957e\fR
+[drover_9999 5f1ae97] This change needs to go to branch 9999
+ Author: some\&.committer <some\&.committer@chromium\&.org>
+ Date: Thu Apr 10 08:54:46 2014 +0000
+ 1 file changed, 1 insertion(+)
+ create mode 100644 modified_file
+
+# That took the code authored by some\&.committer and committed it to
+# the branch by the person who drovered it (i\&.e\&. you)\&.
+\fB$ git log \-n 1 \-\-pretty=fuller\fR
+commit 5f1ae978a8d05c16d8ed812163b7aa927f028bf9
+Author:     some\&.committer <some\&.committer@chromium\&.org>
+AuthorDate: Thu Apr 10 08:54:46 2014 +0000
+Commit:     you <you@chromium\&.org>
+CommitDate: Thu Apr 10 09:11:36 2014 +0000
+
+    This change needs to go to branch 9999
+
+    (cherry picked from commit 640f962733bfd2b9c44539a0d65952643750957e)
+
+# Looks good\&. Ship it!
+\fB$ git cl upload\fR
+# Wait for LGTM or TBR it\&.
+\fB$ git cl land\fR
+# Or skip the LGTM/TBR and just \*(Aqgit cl land \-\-bypass\-hooks\*(Aq
+.fi
+.if n \{\
+.RE
+.\}
+.sp
+.RE
 .SH "SEE ALSO"
 .sp
 \fBgit-cherry-pick\fR(1), \fBgit-revert\fR(1)
diff --git a/man/src/common_demo_functions.sh b/man/src/common_demo_functions.sh
index 8f85ad1..7501f5a 100755
--- a/man/src/common_demo_functions.sh
+++ b/man/src/common_demo_functions.sh
@@ -50,6 +50,11 @@
   echo "###COMMENT### $@"
 }
 
+# run a command and print its output without printing the command itself
+output() {
+  "$@"
+}
+
 # run a silent command
 silent() {
   if [[ $DEBUG ]]
diff --git a/man/src/git-drover.demo.1.sh b/man/src/git-drover.demo.1.sh
index 29a8cb0..9b12e38 100755
--- a/man/src/git-drover.demo.1.sh
+++ b/man/src/git-drover.demo.1.sh
@@ -3,25 +3,20 @@
 
 drover_c "This change needs to go to branch 9999"
 
-echo "# Make sure we have the most up-to-date branch sources."
-run git fetch
-echo
 echo "# Here's a commit (from some.committer) that we want to 'drover'."
 run git log -n 1 --pretty=fuller
 echo
-echo "# Checkout the branch we want to 'drover' to."
-run git checkout -b drover_9999 branch-heads/9999
-echo
 echo "# Now do the 'drover'."
-echo "# IMPORTANT!!! Do Not leave off the '-x' flag"
-run git cherry-pick -x $(git show-ref -s pick_commit)
+pcommand git drover --branch 9999 \
+  --cherry-pick $(git show-ref -s pick_commit)
+
+echo "Going to cherry-pick"
+echo '"""'
+output git log -n 1
+echo '"""'
+echo "to 9999. Continue (y/n)? y"
 echo
-echo "# That took the code authored by some.committer and committed it to"
-echo "# the branch by the person who drovered it (i.e. you)."
-run git log -n 1 --pretty=fuller
+echo "# A cl is uploaded to rietveld, where it can be reviewed before landing."
 echo
-echo "# Looks good. Ship it!"
-pcommand git cl upload
-echo "# Wait for LGTM or TBR it."
-run git cl land
-echo "# Or skip the LGTM/TBR and just 'git cl land --bypass-hooks'"
+echo "About to land on 9999. Continue (y/n)? y"
+echo "# The cherry-pick cl is landed on the branch 9999."
diff --git a/man/src/git-drover.demo.3.sh b/man/src/git-drover.demo.3.sh
new file mode 100755
index 0000000..29a8cb0
--- /dev/null
+++ b/man/src/git-drover.demo.3.sh
@@ -0,0 +1,27 @@
+#!/usr/bin/env bash
+. git-drover.demo.common.sh
+
+drover_c "This change needs to go to branch 9999"
+
+echo "# Make sure we have the most up-to-date branch sources."
+run git fetch
+echo
+echo "# Here's a commit (from some.committer) that we want to 'drover'."
+run git log -n 1 --pretty=fuller
+echo
+echo "# Checkout the branch we want to 'drover' to."
+run git checkout -b drover_9999 branch-heads/9999
+echo
+echo "# Now do the 'drover'."
+echo "# IMPORTANT!!! Do Not leave off the '-x' flag"
+run git cherry-pick -x $(git show-ref -s pick_commit)
+echo
+echo "# That took the code authored by some.committer and committed it to"
+echo "# the branch by the person who drovered it (i.e. you)."
+run git log -n 1 --pretty=fuller
+echo
+echo "# Looks good. Ship it!"
+pcommand git cl upload
+echo "# Wait for LGTM or TBR it."
+run git cl land
+echo "# Or skip the LGTM/TBR and just 'git cl land --bypass-hooks'"
diff --git a/man/src/git-drover.txt b/man/src/git-drover.txt
index 2dde582..98eb92d 100644
--- a/man/src/git-drover.txt
+++ b/man/src/git-drover.txt
@@ -9,13 +9,41 @@
 SYNOPSIS
 --------
 [verse]
-'git drover'
+'git drover' --branch <branch> --cherry-pick <commit>
+           [--parent_checkout <path-to-existing-checkout>]
+           [--verbose] [--dry-run]
 
 DESCRIPTION
 -----------
 
-`git drover` is NOT IMPLEMENTED yet. See the EXAMPLE section for the equivalent
-sequence of commands to run.
+`git drover` applies a commit to a release branch. It creates a new workdir from
+an existing checkout to avoid downloading a new checkout without affecting the
+existing checkout. Creating a workdir requires symlinks so this does not work on
+Windows. See the EXAMPLE section for the equivalent sequence of commands to run.
+
+`git drover` does not support reverts. See the EXAMPLE section for the
+equivalent sequence of commands to run.
+
+OPTIONS
+-------
+--branch <branch>::
+  The branch to cherry-pick the commit to.
+
+--cherry-pick <commit>::
+  The commit to cherry-pick.
+
+--parent_checkout::
+  The path to the chromium checkout to use as the source for a creating
+  git-new-workdir workdir to use for cherry-picking. If unspecified, the current
+  directory is used.
+
+-v::
+--verbose::
+  Enable verbose logging.
+
+--dry-run::
+  Skip landing the cherry-pick. Just ensure that the commit can be cherry-picked
+  into the branch.
 
 EXAMPLE
 -------
@@ -39,6 +67,10 @@
 ^^^^^^^^^^^^^^
 demo:2[]
 
+Manual Merge Example
+^^^^^^^^^^^^^^^^^^^^
+demo:3[]
+
 SEE ALSO
 --------
 linkgit:git-cherry-pick[1], linkgit:git-revert[1]
diff --git a/tests/git_common_test.py b/tests/git_common_test.py
index e7ec3b4..ef411ce 100755
--- a/tests/git_common_test.py
+++ b/tests/git_common_test.py
@@ -8,6 +8,7 @@
 import binascii
 import collections
 import os
+import shutil
 import signal
 import sys
 import tempfile
@@ -743,6 +744,35 @@
     self.repo.run(inner)
 
 
+class GitMakeWorkdir(git_test_utils.GitRepoReadOnlyTestBase, GitCommonTestBase):
+  def setUp(self):
+    self._tempdir = tempfile.mkdtemp()
+
+  def tearDown(self):
+    shutil.rmtree(self._tempdir)
+
+  REPO_SCHEMA = """
+  A
+  """
+
+  def testMakeWorkdir(self):
+    if not hasattr(os, 'symlink'):
+      return
+
+    workdir = os.path.join(self._tempdir, 'workdir')
+    self.gc.make_workdir(os.path.join(self.repo.repo_path, '.git'),
+                         os.path.join(workdir, '.git'))
+    EXPECTED_LINKS = [
+        'config', 'info', 'hooks', 'logs/refs', 'objects', 'refs',
+    ]
+    for path in EXPECTED_LINKS:
+      self.assertTrue(os.path.islink(os.path.join(workdir, '.git', path)))
+      self.assertEqual(os.path.realpath(os.path.join(workdir, '.git', path)),
+                       os.path.join(self.repo.repo_path, '.git', path))
+    self.assertFalse(os.path.islink(os.path.join(workdir, '.git', 'HEAD')))
+
+
+
 if __name__ == '__main__':
   sys.exit(coverage_utils.covered_main(
     os.path.join(DEPOT_TOOLS_ROOT, 'git_common.py')))
diff --git a/tests/git_drover_test.py b/tests/git_drover_test.py
new file mode 100755
index 0000000..c9f1f8f
--- /dev/null
+++ b/tests/git_drover_test.py
@@ -0,0 +1,203 @@
+#!/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.
+"""Tests for git_drover."""
+
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+import unittest
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from testing_support import auto_stub
+import git_drover
+
+
+class GitDroverTest(auto_stub.TestCase):
+
+  def setUp(self):
+    super(GitDroverTest, self).setUp()
+    self._temp_directory = tempfile.mkdtemp()
+    self._parent_repo = os.path.join(self._temp_directory, 'parent_repo')
+    self._target_repo = os.path.join(self._temp_directory, 'drover_branch_123')
+    os.makedirs(os.path.join(self._parent_repo, '.git'))
+    with open(os.path.join(self._parent_repo, '.git', 'config'), 'w') as f:
+      f.write('config')
+    with open(os.path.join(self._parent_repo, '.git', 'HEAD'), 'w') as f:
+      f.write('HEAD')
+    os.mkdir(os.path.join(self._parent_repo, '.git', 'info'))
+    with open(os.path.join(self._parent_repo, '.git', 'info', 'refs'),
+              'w') as f:
+      f.write('refs')
+    self.mock(tempfile, 'mkdtemp', self._mkdtemp)
+    self.mock(__builtins__, 'raw_input', self._get_input)
+    self.mock(subprocess, 'check_call', self._check_call)
+    self.mock(subprocess, 'check_output', self._check_call)
+    self._commands = []
+    self._input = []
+    self._fail_on_command = None
+    self._has_os_symlink = hasattr(os, 'symlink')
+    if not self._has_os_symlink:
+      os.symlink = lambda source, dest: None
+
+    self.REPO_CHECK_COMMANDS = [
+        (['git', '--help'], self._parent_repo),
+        (['git', 'status'], self._parent_repo),
+        (['git', 'fetch', 'origin'], self._parent_repo),
+        (['git', 'rev-parse', 'refs/remotes/branch-heads/branch^{commit}'],
+         self._parent_repo),
+        (['git', 'rev-parse', 'cl^{commit}'], self._parent_repo),
+        (['git', 'show', '-s', 'cl'], self._parent_repo),
+    ]
+    self.LOCAL_REPO_COMMANDS = [
+        (['git', 'rev-parse', '--git-dir'], self._parent_repo),
+        (['git', 'config', 'core.sparsecheckout', 'true'], self._target_repo),
+        (['git', 'checkout', '-b', 'drover_branch_123',
+          'refs/remotes/branch-heads/branch'], self._target_repo),
+        (['git', 'cherry-pick', '-x', 'cl'], self._target_repo),
+        (['git', 'reset', '--hard'], self._target_repo),
+    ]
+    self.UPLOAD_COMMAND = [(['git', 'cl', 'upload'], self._target_repo)]
+    self.LAND_COMMAND = [
+        (['git', 'cl', 'land', '--bypass-hooks'], self._target_repo),
+    ]
+    self.BRANCH_CLEANUP_COMMANDS = [
+        (['git', 'cherry-pick', '--abort'], self._target_repo),
+        (['git', 'checkout', '--detach'], self._target_repo),
+        (['git', 'branch', '-D', 'drover_branch_123'], self._target_repo),
+    ]
+
+  def tearDown(self):
+    shutil.rmtree(self._temp_directory)
+    if not self._has_os_symlink:
+      del os.symlink
+    super(GitDroverTest, self).tearDown()
+
+  def _mkdtemp(self, prefix='tmp'):
+    self.assertEqual('drover_branch_', prefix)
+    os.mkdir(self._target_repo)
+    return self._target_repo
+
+  def _get_input(self, message):
+    result = self._input.pop(0)
+    if result == 'EOF':
+      raise EOFError
+    return result
+
+  def _check_call(self, args, stderr=None, stdout=None, shell='', cwd=None):
+    self.assertFalse(shell)
+    self._commands.append((args, cwd))
+    if (self._fail_on_command is not None and
+        self._fail_on_command == len(self._commands)):
+      raise subprocess.CalledProcessError(1, args[0])
+    if args == ['git', 'rev-parse', '--git-dir']:
+      return os.path.join(self._parent_repo, '.git')
+
+  def testSuccess(self):
+    self._input = ['y', 'y']
+    git_drover.cherry_pick_change('branch', 'cl', self._parent_repo, False)
+    self.assertEqual(
+        self.REPO_CHECK_COMMANDS + self.LOCAL_REPO_COMMANDS +
+        self.UPLOAD_COMMAND + self.LAND_COMMAND + self.BRANCH_CLEANUP_COMMANDS,
+        self._commands)
+    self.assertFalse(os.path.exists(self._target_repo))
+    self.assertFalse(self._input)
+
+  def testDryRun(self):
+    self._input = ['y']
+    git_drover.cherry_pick_change('branch', 'cl', self._parent_repo, True)
+    self.assertEqual(
+        self.REPO_CHECK_COMMANDS + self.LOCAL_REPO_COMMANDS +
+        self.BRANCH_CLEANUP_COMMANDS, self._commands)
+    self.assertFalse(os.path.exists(self._target_repo))
+    self.assertFalse(self._input)
+
+  def testCancelEarly(self):
+    self._input = ['n']
+    git_drover.cherry_pick_change('branch', 'cl', self._parent_repo, False)
+    self.assertEqual(self.REPO_CHECK_COMMANDS, self._commands)
+    self.assertFalse(os.path.exists(self._target_repo))
+    self.assertFalse(self._input)
+
+  def testEOFOnConfirm(self):
+    self._input = ['EOF']
+    git_drover.cherry_pick_change('branch', 'cl', self._parent_repo, False)
+    self.assertEqual(self.REPO_CHECK_COMMANDS, self._commands)
+    self.assertFalse(os.path.exists(self._target_repo))
+    self.assertFalse(self._input)
+
+  def testCancelLate(self):
+    self._input = ['y', 'n']
+    git_drover.cherry_pick_change('branch', 'cl', self._parent_repo, False)
+    self.assertEqual(self.REPO_CHECK_COMMANDS + self.LOCAL_REPO_COMMANDS +
+                     self.UPLOAD_COMMAND + self.BRANCH_CLEANUP_COMMANDS,
+                     self._commands)
+    self.assertFalse(os.path.exists(self._target_repo))
+    self.assertFalse(self._input)
+
+  def testFailDuringCheck(self):
+    self._input = []
+    self._fail_on_command = 1
+    self.assertRaises(git_drover.Error, git_drover.cherry_pick_change, 'branch',
+                      'cl', self._parent_repo, False)
+    self.assertEqual(self.REPO_CHECK_COMMANDS[:1], self._commands)
+    self.assertFalse(os.path.exists(self._target_repo))
+    self.assertFalse(self._input)
+
+  def testFailDuringBranchCreation(self):
+    self._input = ['y']
+    self._fail_on_command = 8
+    self.assertRaises(git_drover.Error, git_drover.cherry_pick_change, 'branch',
+                      'cl', self._parent_repo, False)
+    self.assertEqual(self.REPO_CHECK_COMMANDS + self.LOCAL_REPO_COMMANDS[:2],
+                     self._commands)
+    self.assertFalse(os.path.exists(self._target_repo))
+    self.assertFalse(self._input)
+
+  def testFailDuringCherryPick(self):
+    self._input = ['y']
+    self._fail_on_command = 10
+    self.assertRaises(git_drover.Error, git_drover.cherry_pick_change, 'branch',
+                      'cl', self._parent_repo, False)
+    self.assertEqual(
+        self.REPO_CHECK_COMMANDS + self.LOCAL_REPO_COMMANDS[:4] +
+        self.BRANCH_CLEANUP_COMMANDS, self._commands)
+    self.assertFalse(os.path.exists(self._target_repo))
+    self.assertFalse(self._input)
+
+  def testFailAfterCherryPick(self):
+    self._input = ['y']
+    self._fail_on_command = 11
+    self.assertRaises(git_drover.Error, git_drover.cherry_pick_change, 'branch',
+                      'cl', self._parent_repo, False)
+    self.assertEqual(self.REPO_CHECK_COMMANDS + self.LOCAL_REPO_COMMANDS +
+                     self.BRANCH_CLEANUP_COMMANDS, self._commands)
+    self.assertFalse(os.path.exists(self._target_repo))
+    self.assertFalse(self._input)
+
+  def testFailOnUpload(self):
+    self._input = ['y']
+    self._fail_on_command = 12
+    self.assertRaises(git_drover.Error, git_drover.cherry_pick_change, 'branch',
+                      'cl', self._parent_repo, False)
+    self.assertEqual(self.REPO_CHECK_COMMANDS + self.LOCAL_REPO_COMMANDS +
+                     self.UPLOAD_COMMAND + self.BRANCH_CLEANUP_COMMANDS,
+                     self._commands)
+    self.assertFalse(os.path.exists(self._target_repo))
+    self.assertFalse(self._input)
+
+  def testInvalidParentRepoDirectory(self):
+    self.assertRaises(
+        git_drover.Error, git_drover.cherry_pick_change, 'branch', 'cl',
+        os.path.join(self._parent_repo, 'fake'), False)
+    self.assertFalse(self._commands)
+    self.assertFalse(os.path.exists(self._target_repo))
+    self.assertFalse(self._input)
+
+
+if __name__ == '__main__':
+  unittest.main()