Add git-migrate-default-branch

R=ehmaldonado@chromium.org

Change-Id: Ib43132afd914bc3ea66f8a634ce8ead9bf2d3b4b
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/tools/depot_tools/+/2496064
Commit-Queue: Josip Sokcevic <sokcevic@google.com>
Reviewed-by: Edward Lesmes <ehmaldonado@chromium.org>
diff --git a/git-migrate-default-branch b/git-migrate-default-branch
new file mode 100755
index 0000000..17f372c
--- /dev/null
+++ b/git-migrate-default-branch
@@ -0,0 +1,6 @@
+#!/usr/bin/env bash
+# Copyright 2020 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_runner.sh)"
diff --git a/git_migrate_default_branch.py b/git_migrate_default_branch.py
new file mode 100644
index 0000000..a605059
--- /dev/null
+++ b/git_migrate_default_branch.py
@@ -0,0 +1,93 @@
+#!/usr/bin/env vpython3
+# Copyright 2020 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.
+"""Migrate local repository onto new default branch."""
+
+import fix_encoding
+import gerrit_util
+import git_common
+import metrics
+import scm
+import sys
+import logging
+from six.moves import urllib
+
+
+def GetGerritProject(remote_url):
+  """Returns Gerrit project name based on remote git URL."""
+  if remote_url is None:
+    raise RuntimeError('can\'t detect Gerrit project.')
+  project = urllib.parse.urlparse(remote_url).path.strip('/')
+  if project.endswith('.git'):
+    project = project[:-len('.git')]
+  # *.googlesource.com hosts ensure that Git/Gerrit projects don't start with
+  # 'a/' prefix, because 'a/' prefix is used to force authentication in
+  # gitiles/git-over-https protocol. E.g.,
+  # https://chromium.googlesource.com/a/v8/v8 refers to the same repo/project
+  # as
+  # https://chromium.googlesource.com/v8/v8
+  if project.startswith('a/'):
+    project = project[len('a/'):]
+  return project
+
+
+def GetGerritHost(git_host):
+  parts = git_host.split('.')
+  parts[0] = parts[0] + '-review'
+  return '.'.join(parts)
+
+
+def main():
+  remote = git_common.run('remote')
+  # Use first remote as source of truth
+  remote = remote.split("\n")[0]
+  if not remote:
+    raise RuntimeError('Could not find any remote')
+  url = scm.GIT.GetConfig(git_common.repo_root(), 'remote.%s.url' % remote)
+  host = urllib.parse.urlparse(url).netloc
+  if not host:
+    raise RuntimeError('Could not find remote host')
+
+  project_head = gerrit_util.GetProjectHead(GetGerritHost(host),
+                                            GetGerritProject(url))
+  if project_head != 'refs/heads/main':
+    raise RuntimeError("The repository is not migrated yet.")
+
+  git_common.run('fetch', remote)
+
+  branches = git_common.get_branches_info(True)
+
+  if 'master' in branches:
+    logging.info("Migrating master branch...")
+    if 'main' in branches:
+      logging.info('You already have master and main branch, consider removing '
+                   'master manually:\n'
+                   ' $ git branch -d master\n')
+    else:
+      git_common.run('branch', '-m', 'master', 'main')
+    branches = git_common.get_branches_info(True)
+
+  for name in branches:
+    branch = branches[name]
+    if not branch:
+      continue
+
+    if 'master' in branch.upstream:
+      logging.info("Migrating %s branch..." % name)
+      new_upstream = branch.upstream.replace('master', 'main')
+      git_common.run('branch', '--set-upstream-to', new_upstream, name)
+      git_common.remove_merge_base(name)
+
+
+if __name__ == '__main__':
+  fix_encoding.fix_encoding()
+  logging.basicConfig(level=logging.INFO)
+  with metrics.collector.print_notice_and_exit():
+    try:
+      logging.info("Starting migration")
+      main()
+      logging.info("Migration completed")
+    except RuntimeError as e:
+      logging.error("Error %s" % str(e))
+      sys.exit(1)
diff --git a/tests/git_migrate_default_branch_test.py b/tests/git_migrate_default_branch_test.py
new file mode 100755
index 0000000..5fad836
--- /dev/null
+++ b/tests/git_migrate_default_branch_test.py
@@ -0,0 +1,96 @@
+#!/usr/bin/env vpython3
+# Copyright 2020 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.
+"""Unit tests for git_migrate_default_branch.py."""
+
+import collections
+import os
+import sys
+import unittest
+
+if sys.version_info.major == 2:
+  import mock
+else:
+  from unittest import mock
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+import git_migrate_default_branch
+
+
+class CMDFormatTestCase(unittest.TestCase):
+  def setUp(self):
+    self.addCleanup(mock.patch.stopall)
+
+  def test_no_remote(self):
+    def RunMock(*args):
+      if args[0] == 'remote':
+        return ''
+      self.fail('did not expect such run git command: %s' % args[0])
+
+    mock.patch('git_migrate_default_branch.git_common.run', RunMock).start()
+    with self.assertRaisesRegexp(RuntimeError, 'Could not find any remote'):
+      git_migrate_default_branch.main()
+
+  def test_migration_not_ready(self):
+    def RunMock(*args):
+      if args[0] == 'remote':
+        return 'origin\ngerrit'
+      raise Exception('Did not expect such run git command: %s' % args[0])
+
+    mock.patch('git_migrate_default_branch.git_common.run', RunMock).start()
+    mock.patch('git_migrate_default_branch.git_common.repo_root',
+               return_value='.').start()
+    mock.patch('git_migrate_default_branch.scm.GIT.GetConfig',
+               return_value='https://chromium.googlesource.com').start()
+    mock.patch('git_migrate_default_branch.gerrit_util.GetProjectHead',
+               return_value=None).start()
+    with self.assertRaisesRegexp(RuntimeError, 'not migrated yet'):
+      git_migrate_default_branch.main()
+
+  def test_migration_no_master(self):
+    def RunMock(*args):
+      if args[0] == 'remote':
+        return 'origin\ngerrit'
+      elif args[0] == 'fetch':
+        return
+      elif args[0] == 'branch':
+        return
+      raise Exception('Did not expect such run git command: %s' % args[0])
+
+    mock_runs = mock.patch('git_migrate_default_branch.git_common.run',
+                           side_effect=RunMock).start()
+    mock.patch('git_migrate_default_branch.git_common.repo_root',
+               return_value='.').start()
+    mock.patch('git_migrate_default_branch.scm.GIT.GetConfig',
+               return_value='https://chromium.googlesource.com').start()
+    mock.patch('git_migrate_default_branch.gerrit_util.GetProjectHead',
+               return_value='refs/heads/main').start()
+
+    BranchesInfo = collections.namedtuple('BranchesInfo',
+                                          'hash upstream commits behind')
+    branches = {
+        '': None,  # always returned
+        'master': BranchesInfo('0000', 'origin/master', '0', '0'),
+        'feature': BranchesInfo('0000', 'master', '0', '0'),
+        'another_feature': BranchesInfo('0000', 'feature', '0', '0'),
+        'remote_feature': BranchesInfo('0000', 'origin/master', '0', '0'),
+    }
+    mock.patch('git_migrate_default_branch.git_common.get_branches_info',
+               return_value=branches).start()
+    mock_merge_base = mock.patch(
+        'git_migrate_default_branch.git_common.remove_merge_base',
+        return_value=branches).start()
+
+    git_migrate_default_branch.main()
+    mock_merge_base.assert_any_call('feature')
+    mock_merge_base.assert_any_call('remote_feature')
+    mock_runs.assert_any_call('branch', '-m', 'master', 'main')
+    mock_runs.assert_any_call('branch', '--set-upstream-to', 'main', 'feature')
+    mock_runs.assert_any_call('branch', '--set-upstream-to', 'origin/main',
+                              'remote_feature')
+
+
+if __name__ == '__main__':
+  unittest.main()