[depot_tools] New "git cl upload" flag to traverse dependent branches and re-upload them.

Motivation:
The conversation in https://docs.google.com/document/d/1KZGFKZpOPvco81sYVRCzwlnjGctup71RAzY0MSb0ntc/edit?disco=AAAAAXU60E8

BUG=502257

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

git-svn-id: svn://svn.chromium.org/chrome/trunk/tools/depot_tools@295779 0039d316-1c4b-4281-b951-d872f2087c98
diff --git a/git_cl.py b/git_cl.py
index 50ba343..288e281 100755
--- a/git_cl.py
+++ b/git_cl.py
@@ -10,6 +10,7 @@
 from distutils.version import LooseVersion
 from multiprocessing.pool import ThreadPool
 import base64
+import collections
 import glob
 import httplib
 import json
@@ -1516,6 +1517,105 @@
       url = cl.GetIssueURL()
       yield (b, url, 'waiting' if url else 'error')
 
+
+def upload_branch_deps(cl, args):
+  """Uploads CLs of local branches that are dependents of the current branch.
+
+  If the local branch dependency tree looks like:
+  test1 -> test2.1 -> test3.1
+                   -> test3.2
+        -> test2.2 -> test3.3
+
+  and you run "git cl upload --dependencies" from test1 then "git cl upload" is
+  run on the dependent branches in this order:
+  test2.1, test3.1, test3.2, test2.2, test3.3
+
+  Note: This function does not rebase your local dependent branches. Use it when
+        you make a change to the parent branch that will not conflict with its
+        dependent branches, and you would like their dependencies updated in
+        Rietveld.
+  """
+  if git_common.is_dirty_git_tree('upload-branch-deps'):
+    return 1
+
+  root_branch = cl.GetBranch()
+  if root_branch is None:
+    DieWithError('Can\'t find dependent branches from detached HEAD state. '
+                 'Get on a branch!')
+  if not cl.GetIssue() or not cl.GetPatchset():
+    DieWithError('Current branch does not have an uploaded CL. We cannot set '
+                 'patchset dependencies without an uploaded CL.')
+
+  branches = RunGit(['for-each-ref',
+                     '--format=%(refname:short) %(upstream:short)',
+                     'refs/heads'])
+  if not branches:
+    print('No local branches found.')
+    return 0
+
+  # Create a dictionary of all local branches to the branches that are dependent
+  # on it.
+  tracked_to_dependents = collections.defaultdict(list)
+  for b in branches.splitlines():
+    tokens = b.split()
+    if len(tokens) == 2:
+      branch_name, tracked = tokens
+      tracked_to_dependents[tracked].append(branch_name)
+
+  print
+  print 'The dependent local branches of %s are:' % root_branch
+  dependents = []
+  def traverse_dependents_preorder(branch, padding=''):
+    dependents_to_process = tracked_to_dependents.get(branch, [])
+    padding += '  '
+    for dependent in dependents_to_process:
+      print '%s%s' % (padding, dependent)
+      dependents.append(dependent)
+      traverse_dependents_preorder(dependent, padding)
+  traverse_dependents_preorder(root_branch)
+  print
+
+  if not dependents:
+    print 'There are no dependent local branches for %s' % root_branch
+    return 0
+
+  print ('This command will checkout all dependent branches and run '
+         '"git cl upload".')
+  ask_for_data('[Press enter to continue or ctrl-C to quit]')
+
+  # Add a default patchset title to all upload calls.
+  args.extend(['-t', 'Updated patchset dependency'])
+  # Record all dependents that failed to upload.
+  failures = {}
+  # Go through all dependents, checkout the branch and upload.
+  try:
+    for dependent_branch in dependents:
+      print
+      print '--------------------------------------'
+      print 'Running "git cl upload" from %s:' % dependent_branch
+      RunGit(['checkout', '-q', dependent_branch])
+      print
+      try:
+        if CMDupload(OptionParser(), args) != 0:
+          print 'Upload failed for %s!' % dependent_branch
+          failures[dependent_branch] = 1
+      except:  # pylint: disable=W0702
+        failures[dependent_branch] = 1
+      print
+  finally:
+    # Swap back to the original root branch.
+    RunGit(['checkout', '-q', root_branch])
+
+  print
+  print 'Upload complete for dependent branches!'
+  for dependent_branch in dependents:
+    upload_status = 'failed' if failures.get(dependent_branch) else 'succeeded'
+    print '  %s : %s' % (dependent_branch, upload_status)
+  print
+
+  return 0
+
+
 def CMDstatus(parser, args):
   """Show status of changelists.
 
@@ -2197,7 +2297,11 @@
   parser.add_option('--cq-dry-run', dest='cq_dry_run', action='store_true',
                     help='Send the patchset to do a CQ dry run right after '
                          'upload.')
+  parser.add_option('--dependencies', action='store_true',
+                    help='Uploads CLs of all the local branches that depend on '
+                         'the current branch')
 
+  orig_args = args
   add_git_similarity(parser)
   auth.add_auth_options(parser)
   (options, args) = parser.parse_args(args)
@@ -2282,6 +2386,16 @@
           options.verbose,
           sys.stdout)
 
+    # Upload all dependencies if specified.
+    if options.dependencies:
+      print
+      print '--dependencies has been specified.'
+      print 'All dependent local branches will be re-uploaded.'
+      print
+      # Remove the dependencies flag from args so that we do not end up in a
+      # loop.
+      orig_args.remove('--dependencies')
+      upload_branch_deps(cl, orig_args)
   return ret
 
 
diff --git a/tests/git_cl_test.py b/tests/git_cl_test.py
index b19e45c..293523d 100755
--- a/tests/git_cl_test.py
+++ b/tests/git_cl_test.py
@@ -696,6 +696,54 @@
         squash=True,
         expected_upstream_ref='origin/master')
 
+  def test_upload_branch_deps(self):
+    def mock_run_git(*args, **_kwargs):
+      if args[0] == ['for-each-ref',
+                       '--format=%(refname:short) %(upstream:short)',
+                       'refs/heads']:
+        # Create a local branch dependency tree that looks like this:
+        # test1 -> test2 -> test3   -> test4 -> test5
+        #                -> test3.1
+        # test6 -> test0
+        branch_deps = [
+            'test2 test1',    # test1 -> test2
+            'test3 test2',    # test2 -> test3
+            'test3.1 test2',  # test2 -> test3.1
+            'test4 test3',    # test3 -> test4
+            'test5 test4',    # test4 -> test5
+            'test6 test0',    # test0 -> test6
+            'test7',          # test7
+        ]
+        return '\n'.join(branch_deps)
+    self.mock(git_cl, 'RunGit', mock_run_git)
+
+    class RecordCalls:
+      times_called = 0
+    record_calls = RecordCalls()
+    def mock_CMDupload(*args, **_kwargs):
+      record_calls.times_called += 1
+      return 0
+    self.mock(git_cl, 'CMDupload', mock_CMDupload)
+
+    self.calls = [
+        (('[Press enter to continue or ctrl-C to quit]',), ''),
+      ]
+
+    class MockChangelist():
+      def __init__(self):
+        pass
+      def GetBranch(self):
+        return 'test1'
+      def GetIssue(self):
+        return '123'
+      def GetPatchset(self):
+        return '1001'
+
+    ret = git_cl.upload_branch_deps(MockChangelist(), [])
+    # CMDupload should have been called 5 times because of 5 dependent branches.
+    self.assertEquals(5, record_calls.times_called)
+    self.assertEquals(0, ret)
+
   def test_config_gerrit_download_hook(self):
     self.mock(git_cl, 'FindCodereviewSettingsFile', CodereviewSettingsFileMock)
     def ParseCodereviewSettingsContent(content):