Fix gclient branch ref mangling and allow --force branch switches.

[email protected], [email protected]
BUG=410959

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

git-svn-id: svn://svn.chromium.org/chrome/trunk/tools/depot_tools@291883 0039d316-1c4b-4281-b951-d872f2087c98
diff --git a/gclient_scm.py b/gclient_scm.py
index 8236282..7bf559c 100644
--- a/gclient_scm.py
+++ b/gclient_scm.py
@@ -369,11 +369,14 @@
       verbose = ['--verbose']
       printed_path = True
 
-    if revision.startswith('refs/'):
-      rev_type = "branch"
-    elif revision.startswith(self.remote + '/'):
+    remote_ref = scm.GIT.RefToRemoteRef(revision, self.remote)
+    if remote_ref:
       # Rewrite remote refs to their local equivalents.
-      revision = 'refs/remotes/' + revision
+      revision = ''.join(remote_ref)
+      rev_type = "branch"
+    elif revision.startswith('refs/'):
+      # Local branch? We probably don't want to support, since DEPS should
+      # always specify branches as they are in the upstream repo.
       rev_type = "branch"
     else:
       # hash is also a tag, only make a distinction at checkout
@@ -393,10 +396,6 @@
       except subprocess2.CalledProcessError:
         self._DeleteOrMove(options.force)
         self._Clone(revision, url, options)
-      if deps_revision and deps_revision.startswith('branch-heads/'):
-        deps_branch = deps_revision.replace('branch-heads/', '')
-        self._Capture(['branch', deps_branch, deps_revision])
-        self._Checkout(options, deps_branch, quiet=True)
       if file_list is not None:
         files = self._Capture(['ls-files']).splitlines()
         file_list.extend([os.path.join(self.checkout_path, f) for f in files])
@@ -451,12 +450,16 @@
     # 2) current branch is tracking a remote branch with local committed
     #    changes, but the DEPS file switched to point to a hash
     #   - rebase those changes on top of the hash
-    # 3) current branch is tracking a remote branch w/or w/out changes,
-    #    no switch
+    # 3) current branch is tracking a remote branch w/or w/out changes, and
+    #    no DEPS switch
     #   - see if we can FF, if not, prompt the user for rebase, merge, or stop
-    # 4) current branch is tracking a remote branch, switches to a different
-    #    remote branch
-    #   - exit
+    # 4) current branch is tracking a remote branch, but DEPS switches to a
+    #    different remote branch, and
+    #   a) current branch has no local changes, and --force:
+    #      - checkout new branch
+    #   b) current branch has local changes, and --force and --reset:
+    #      - checkout new branch
+    #   c) otherwise exit
 
     # GetUpstreamBranch returns something like 'refs/remotes/origin/master' for
     # a tracking branch
@@ -534,17 +537,37 @@
                           newbase=revision, printed_path=printed_path,
                           merge=options.merge)
       printed_path = True
-    elif revision.replace('heads', 'remotes/' + self.remote) != upstream_branch:
+    elif remote_ref and ''.join(remote_ref) != upstream_branch:
       # case 4
-      new_base = revision.replace('heads', 'remotes/' + self.remote)
+      new_base = ''.join(remote_ref)
       if not printed_path:
         self.Print('_____ %s%s' % (self.relpath, rev_str), timestamp=False)
-      switch_error = ("Switching upstream branch from %s to %s\n"
+      switch_error = ("Could not switch upstream branch from %s to %s\n"
                      % (upstream_branch, new_base) +
-                     "Please merge or rebase manually:\n" +
+                     "Please use --force or merge or rebase manually:\n" +
                      "cd %s; git rebase %s\n" % (self.checkout_path, new_base) +
                      "OR git checkout -b <some new branch> %s" % new_base)
-      raise gclient_utils.Error(switch_error)
+      force_switch = False
+      if options.force:
+        try:
+          self._CheckClean(rev_str)
+          # case 4a
+          force_switch = True
+        except gclient_utils.Error as e:
+          if options.reset:
+            # case 4b
+            force_switch = True
+          else:
+            switch_error = '%s\n%s' % (e.message, switch_error)
+      if force_switch:
+        self.Print("Switching upstream branch from %s to %s" %
+                   (upstream_branch, new_base))
+        switch_branch = 'gclient_' + remote_ref[1]
+        self._Capture(['branch', '-f', switch_branch, new_base])
+        self._Checkout(options, switch_branch, force=True, quiet=True)
+      else:
+        # case 4c
+        raise gclient_utils.Error(switch_error)
     else:
       # case 3 - the default case
       if files is not None:
@@ -870,7 +893,8 @@
       if template_dir:
         gclient_utils.rmtree(template_dir)
     self._UpdateBranchHeads(options, fetch=True)
-    self._Checkout(options, revision.replace('refs/heads/', ''), quiet=True)
+    remote_ref = scm.GIT.RefToRemoteRef(revision, self.remote)
+    self._Checkout(options, ''.join(remote_ref or revision), quiet=True)
     if self._GetCurrentBranch() is None:
       # Squelch git's very verbose detached HEAD warning and use our own
       self.Print(
diff --git a/scm.py b/scm.py
index eb5c524..9bc96bc 100644
--- a/scm.py
+++ b/scm.py
@@ -353,11 +353,34 @@
     return remote, upstream_branch
 
   @staticmethod
+  def RefToRemoteRef(ref, remote=None):
+    """Convert a checkout ref to the equivalent remote ref.
+
+    Returns:
+      A tuple of the remote ref's (common prefix, unique suffix), or None if it
+      doesn't appear to refer to a remote ref (e.g. it's a commit hash).
+    """
+    # TODO(mmoss): This is just a brute-force mapping based of the expected git
+    # config. It's a bit better than the even more brute-force replace('heads',
+    # ...), but could still be smarter (like maybe actually using values gleaned
+    # from the git config).
+    m = re.match('^(refs/(remotes/)?)?branch-heads/', ref or '')
+    if m:
+      return ('refs/remotes/branch-heads/', ref.replace(m.group(0), ''))
+    if remote:
+      m = re.match('^((refs/)?remotes/)?%s/|(refs/)?heads/' % remote, ref or '')
+      if m:
+        return ('refs/remotes/%s/' % remote, ref.replace(m.group(0), ''))
+    return None
+
+  @staticmethod
   def GetUpstreamBranch(cwd):
     """Gets the current branch's upstream branch."""
     remote, upstream_branch = GIT.FetchUpstreamTuple(cwd)
     if remote != '.' and upstream_branch:
-      upstream_branch = upstream_branch.replace('heads', 'remotes/' + remote)
+      remote_ref = GIT.RefToRemoteRef(upstream_branch, remote)
+      if remote_ref:
+        upstream_branch = ''.join(remote_ref)
     return upstream_branch
 
   @staticmethod
diff --git a/tests/gclient_scm_test.py b/tests/gclient_scm_test.py
index a14ae35..60d688d 100755
--- a/tests/gclient_scm_test.py
+++ b/tests/gclient_scm_test.py
@@ -1561,7 +1561,7 @@
 
     rmtree(origin_root_dir)
 
-  def testUpdateCloneOnDetachedBranch(self):
+  def testUpdateCloneOnFetchedRemoteBranch(self):
     if not self.enabled:
       return
     options = self.Options()
@@ -1593,7 +1593,7 @@
 
     rmtree(origin_root_dir)
 
-  def testUpdateCloneOnBranchHead(self):
+  def testUpdateCloneOnTrueRemoteBranch(self):
     if not self.enabled:
       return
     options = self.Options()
@@ -1618,9 +1618,17 @@
     self.assertEquals(file_list, expected_file_list)
     self.assertEquals(scm.revinfo(options, (), None),
                       '9a51244740b25fa2ded5252ca00a3178d3f665a9')
-    self.assertEquals(self.getCurrentBranch(), 'feature')
-    self.checkNotInStdout(
-      'Checked out refs/heads/feature to a detached HEAD')
+    # @refs/heads/feature is AKA @refs/remotes/origin/feature in the clone, so
+    # should be treated as such by gclient.
+    # TODO(mmoss): Though really, we should only allow DEPS to specify branches
+    # as they are known in the upstream repo, since the mapping into the local
+    # repo can be modified by users (or we might even want to change the gclient
+    # defaults at some point). But that will take more work to stop using
+    # refs/remotes/ everywhere that we do (and to stop assuming a DEPS ref will
+    # always resolve locally, like when passing them to show-ref or rev-list).
+    self.assertEquals(self.getCurrentBranch(), None)
+    self.checkInStdout(
+      'Checked out refs/remotes/origin/feature to a detached HEAD')
 
     rmtree(origin_root_dir)
 
diff --git a/tests/scm_unittest.py b/tests/scm_unittest.py
index 4e97c73..239740e 100755
--- a/tests/scm_unittest.py
+++ b/tests/scm_unittest.py
@@ -98,6 +98,7 @@
         'IsWorkTreeDirty',
         'MatchSvnGlob',
         'ParseGitSvnSha1',
+        'RefToRemoteRef',
         'ShortBranchName',
     ]
     # If this test fails, you should add the relevant test.
@@ -122,6 +123,50 @@
         'branches/*:refs/remotes/*',
         True), 'refs/remotes/bleeding_edge')
 
+  def testRefToRemoteRefNoRemote(self):
+    refs = {
+        # local ref for upstream branch-head
+        'refs/remotes/branch-heads/1234': ('refs/remotes/branch-heads/',
+                                           '1234'),
+        # upstream ref for branch-head
+        'refs/branch-heads/1234': ('refs/remotes/branch-heads/', '1234'),
+        # could be either local or upstream ref, assumed to refer to
+        # upstream, but probably don't want to encourage refs like this.
+        'branch-heads/1234': ('refs/remotes/branch-heads/', '1234'),
+        # actively discouraging refs like this, should prepend with 'refs/'
+        'remotes/branch-heads/1234': None,
+        # might be non-"branch-heads" upstream branches, but can't resolve
+        # without knowing the remote.
+        'refs/heads/1234': None,
+        'heads/1234': None,
+        # underspecified, probably intended to refer to a local branch
+        '1234': None,
+        }
+    for k, v in refs.items():
+      r = scm.GIT.RefToRemoteRef(k)
+      self.assertEqual(r, v, msg='%s -> %s, expected %s' % (k, r, v))
+
+  def testRefToRemoteRefWithRemote(self):
+    remote = 'origin'
+    refs = {
+        # This shouldn't be any different from the NoRemote() version.
+        'refs/branch-heads/1234': ('refs/remotes/branch-heads/', '1234'),
+        # local refs for upstream branch
+        'refs/remotes/%s/foobar' % remote: ('refs/remotes/%s/' % remote,
+                                            'foobar'),
+        '%s/foobar' % remote: ('refs/remotes/%s/' % remote, 'foobar'),
+        # upstream ref for branch
+        'refs/heads/foobar': ('refs/remotes/%s/' % remote, 'foobar'),
+        # could be either local or upstream ref, assumed to refer to
+        # upstream, but probably don't want to encourage refs like this.
+        'heads/foobar': ('refs/remotes/%s/' % remote, 'foobar'),
+        # underspecified, probably intended to refer to a local branch
+        'foobar': None,
+        }
+    for k, v in refs.items():
+      r = scm.GIT.RefToRemoteRef(k, remote)
+      self.assertEqual(r, v, msg='%s -> %s, expected %s' % (k, r, v))
+
 
 class RealGitTest(fake_repos.FakeReposTestBase):
   def setUp(self):