diff --git a/git-migrate-default-branch b/git-migrate-default-branch new file mode 100755 index 000000000..17f372c3e --- /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 000000000..a605059ba --- /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 000000000..5fad836ca --- /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()