diff --git a/git_cl.py b/git_cl.py index eae031d5ac..c6a9bb3009 100755 --- a/git_cl.py +++ b/git_cl.py @@ -155,6 +155,11 @@ assert len(_KNOWN_GERRIT_TO_SHORT_URLS) == len( set(_KNOWN_GERRIT_TO_SHORT_URLS.values())), 'must have unique values' +# Maximum number of branches in a stack that can be traversed and uploaded +# at once. Picked arbitrarily. +_MAX_STACKED_BRANCHES_UPLOAD = 20 + + class GitPushError(Exception): pass @@ -2251,7 +2256,10 @@ class Changelist(object): return 0 - def _GerritCommitMsgHookCheck(self, offer_removal): + @staticmethod + def _GerritCommitMsgHookCheck(offer_removal): + # type: (bool) -> None + """Checks for the gerrit's commit-msg hook and removes it if necessary.""" hook = os.path.join(settings.GetRoot(), '.git', 'hooks', 'commit-msg') if not os.path.exists(hook): return @@ -4514,6 +4522,9 @@ def CMDupload(parser, args): parser.add_option('--no-python2-post-upload-hooks', action='store_true', help='Only run post-upload hooks in Python 3.') + parser.add_option('--stacked-exp', + action='store_true', + help=optparse.SUPPRESS_HELP) orig_args = args (options, args) = parser.parse_args(args) @@ -4554,6 +4565,12 @@ def CMDupload(parser, args): # Load default for user, repo, squash=true, in this order. options.squash = settings.GetSquashGerritUploads() + if options.stacked_exp: + orig_args.remove('--stacked-exp') + + UploadAllSquashed(options, orig_args) + return 0 + cl = Changelist(branchref=options.target_branch) # Warm change details cache now to avoid RPCs later, reducing latency for # developers. @@ -4589,6 +4606,106 @@ def CMDupload(parser, args): return ret +def UploadAllSquashed(options, orig_args): + # type: (optparse.Values, Sequence[str]) -> Tuple[Sequence[Changelist], bool] + """Uploads the current and upstream branches (if necessary).""" + _cls, _cherry_pick_current = _UploadAllPrecheck(options, orig_args) + + # TODO(b/265929888): parse cls and create commits. + + +def _UploadAllPrecheck(options, orig_args): + # type: (optparse.Values, Sequence[str]) -> Tuple[Sequence[Changelist], bool] + """Checks the state of the tree and gives the user uploading options + + Returns: A tuple of the ordered list of changes that have new commits + since their last upload and a boolean of whether the user wants to + cherry-pick and upload the current branch instead of uploading all cls. + """ + branch_ref = None + cls = [] + must_upload_upstream = False + + Changelist._GerritCommitMsgHookCheck(offer_removal=not options.force) + + while True: + if len(cls) > _MAX_STACKED_BRANCHES_UPLOAD: + DieWithError( + 'More than %s branches in the stack have not been uploaded.\n' + 'Are your branches in a misconfigured state?\n' + 'If not, please upload some upstream changes first.' % + (_MAX_STACKED_BRANCHES_UPLOAD)) + + cl = Changelist(branchref=branch_ref) + cls.append(cl) + + origin, upstream_branch_ref = Changelist.FetchUpstreamTuple(cl.GetBranch()) + upstream_branch = scm.GIT.ShortBranchName(upstream_branch_ref) + branch_ref = upstream_branch_ref # set branch for next run. + + # Case 1: We've reached the beginning of the tree. + if origin != '.': + break + + upstream_last_upload = scm.GIT.GetBranchConfig(settings.GetRoot(), + upstream_branch, + LAST_UPLOAD_HASH_CONFIG_KEY) + + # Case 2: If any upstream branches have never been uploaded, + # the user MUST upload them. + if not upstream_last_upload: + must_upload_upstream = True + continue + + base_commit = cl.GetCommonAncestorWithUpstream() + + # Case 3: If upstream's last_upload == cl.base_commit we do + # not need to upload any more upstreams from this point on. + # (Even if there may be diverged branches higher up the tree) + if base_commit == upstream_last_upload: + break + + # Case 4: If upstream's last_upload < cl.base_commit we are + # uploading cl and upstream_cl. + # Continue up the tree to check other branch relations. + if scm.GIT.IsAncestor(None, upstream_last_upload, base_commit): + continue + + # Case 5: If cl.base_commit < upstream's last_upload the user + # must rebase before uploading. + if scm.GIT.IsAncestor(None, base_commit, upstream_last_upload): + DieWithError( + 'At least one branch in the stack has diverged from its upstream ' + 'branch and does not contain its upstream\'s last upload.\n' + 'Please rebase the stack with `git rebase-update` before uploading.') + + # The tree went through a rebase. LAST_UPLOAD_HASH_CONFIG_KEY no longer has + # any relation to commits in the tree. Continue up the tree until we hit + # the root. + + # We assume all cls in the stack have the same auth requirements and only + # check this once. + cls[0].EnsureAuthenticated(force=options.force) + + cherry_pick = False + if len(cls) > 1: + message = '' + if len(orig_args): + message = ('options %s will be used for all uploads.\n' % orig_args) + if must_upload_upstream: + confirm_or_exit('\n' + message + + 'There are upstream branches that must be uploaded.\n') + else: + answer = gclient_utils.AskForData( + '\n' + message + + 'Press enter to update branches %s.\nOr type `n` to upload only ' + '`%s` cherry-picked on %s\'s last upload:' % + ([cl.branch for cl in cls], cls[0].branch, cls[1].branch)) + if answer.lower() == 'n': + cherry_pick = True + return cls, cherry_pick + + @subcommand.usage('--description=') @metrics.collector.collect_metrics('git cl split') def CMDsplit(parser, args): diff --git a/tests/git_cl_test.py b/tests/git_cl_test.py index 6706225c20..03aa95e14a 100755 --- a/tests/git_cl_test.py +++ b/tests/git_cl_test.py @@ -1514,6 +1514,163 @@ class TestGitCl(unittest.TestCase): external_parent='newparent', ) + @mock.patch( + 'git_cl.Changelist._GerritCommitMsgHookCheck', lambda offer_removal: None) + @mock.patch('git_cl.Changelist.FetchUpstreamTuple') + @mock.patch('git_cl.Changelist.GetCommonAncestorWithUpstream') + @mock.patch('git_cl.Changelist.GetBranch') + @mock.patch('git_cl.Changelist.GetRemoteBranch') + @mock.patch('scm.GIT.IsAncestor') + @mock.patch('gclient_utils.AskForData') + def test_upload_all_precheck_long_chain(self, mockAskForData, mockIsAncestor, + mockGetRemoteBranch, mockGetBranch, + mockGetCommonAncestorWithUpstream, + mockFetchUpstreamTuple, *_mocks): + + mockGetRemoteBranch.return_value = ('origin', 'refs/remotes/origin/main') + mockGetBranch.side_effect = [ + 'current', 'upstream3', 'upstream2', 'upstream1', 'main' + ] + mockGetCommonAncestorWithUpstream.side_effect = [ + 'commit3.5', 'commit2.5', 'commit1.5', 'commit0.5' + ] + mockFetchUpstreamTuple.side_effect = [('.', 'refs/heads/upstream3'), + ('.', 'refs/heads/upstream2'), + ('.', 'refs/heads/upstream1'), + ('origin', 'refs/heads/main')] + + options = optparse.Values() + options.force = False + orig_args = ['--preserve-tryjobs', '--chicken'] + + # Case 2: upstream3 has never been uploaded. + # (so no LAST_UPLOAD_HASH_CONIFG_KEY) + + # Case 4: upstream2's last_upload is behind upstream3's base_commit + self.mockGit.config['branch.upstream1.%s' % + git_cl.LAST_UPLOAD_HASH_CONFIG_KEY] = 'commit2.3' + mockIsAncestor.side_effect = [True] + + # Case 3: upstream1's last_upload matches upstream2's base_commit + self.mockGit.config['branch.upstream1.%s' % + git_cl.LAST_UPLOAD_HASH_CONFIG_KEY] = 'commit1.5' + + cls, cherry_pick = git_cl._UploadAllPrecheck(options, orig_args) + self.assertFalse(cherry_pick) + mockAskForData.assert_called_once_with( + "\noptions ['--preserve-tryjobs', '--chicken'] will be used for all " + "uploads.\nThere are upstream branches that must be uploaded.\n" + "Press Enter to confirm, or Ctrl+C to abort") + self.assertEqual(len(cls), 4) + + @mock.patch( + 'git_cl.Changelist._GerritCommitMsgHookCheck', lambda offer_removal: None) + @mock.patch('git_cl.Changelist.FetchUpstreamTuple') + @mock.patch('git_cl.Changelist.GetCommonAncestorWithUpstream') + @mock.patch('git_cl.Changelist.GetBranch') + @mock.patch('git_cl.Changelist.GetRemoteBranch') + @mock.patch('scm.GIT.IsAncestor') + @mock.patch('gclient_utils.AskForData') + def test_upload_all_precheck_must_rebase(self, mockAskForData, mockIsAncestor, + mockGetRemoteBranch, mockGetBranch, + mockGetCommonAncestorWithUpstream, + mockFetchUpstreamTuple, *_mocks): + + options = optparse.Values() + options.force = False + orig_args = ['--preserve-tryjobs', '--chicken'] + + mockGetRemoteBranch.return_value = ('origin', 'refs/remotes/origin/main') + mockGetBranch.side_effect = [ + 'current', 'upstream3', 'upstream2', 'upstream1', 'main' + ] + mockGetCommonAncestorWithUpstream.side_effect = [ + 'commit3.5', 'commit2.5', 'commit1.5', 'commit0.5' + ] + mockFetchUpstreamTuple.side_effect = [('.', 'refs/heads/upstream3'), + ('.', 'refs/heads/upstream2'), + ('.', 'refs/heads/upstream1'), + ('origin', 'refs/heads/main')] + # Case 5: current's base_commit is behind upstream3's last_upload. + self.mockGit.config['branch.upstream3.%s' % + git_cl.LAST_UPLOAD_HASH_CONFIG_KEY] = 'commit3.7' + mockIsAncestor.side_effect = [False, True] + with self.assertRaises(SystemExitMock): + git_cl._UploadAllPrecheck(options, orig_args) + + @mock.patch( + 'git_cl.Changelist._GerritCommitMsgHookCheck', lambda offer_removal: None) + @mock.patch('git_cl.Changelist.FetchUpstreamTuple') + @mock.patch('git_cl.Changelist.GetCommonAncestorWithUpstream') + @mock.patch('git_cl.Changelist.GetBranch') + @mock.patch('git_cl.Changelist.GetRemoteBranch') + @mock.patch('scm.GIT.IsAncestor') + @mock.patch('gclient_utils.AskForData') + def test_upload_all_precheck_hit_main(self, mockAskForData, mockIsAncestor, + mockGetRemoteBranch, mockGetBranch, + mockGetCommonAncestorWithUpstream, + mockFetchUpstreamTuple, *_mocks): + + options = optparse.Values() + options.force = False + orig_args = ['--preserve-tryjobs', '--chicken'] + + mockGetRemoteBranch.return_value = ('origin', 'refs/remotes/origin/main') + mockGetBranch.return_value = ['current', 'upstream3', 'main'] + mockGetCommonAncestorWithUpstream.side_effect = ['commit3.5', 'commit0.5'] + mockFetchUpstreamTuple.side_effect = [('.', 'refs/heads/upstream3'), + ('origin', 'refs/heads/main')] + mockIsAncestor.return_value = True + + # Test user wants to cherry pick + mockAskForData.return_value = 'n' + + # Give upstream3 a last upload hash + self.mockGit.config['branch.upstream3.%s' % + git_cl.LAST_UPLOAD_HASH_CONFIG_KEY] = 'commit3.4' + + # Case 1: We hit the main branch + cls, cherry_pick = git_cl._UploadAllPrecheck(options, orig_args) + self.assertTrue(cherry_pick) + self.assertEqual(len(cls), 2) + + mockAskForData.assert_called_once_with( + "\noptions ['--preserve-tryjobs', '--chicken'] will be used for all " + "uploads.\n" + "Press enter to update branches [None, 'upstream3'].\n" + "Or type `n` to upload only `None` cherry-picked on upstream3's last " + "upload:") + + @mock.patch( + 'git_cl.Changelist._GerritCommitMsgHookCheck', lambda offer_removal: None) + @mock.patch('git_cl.Changelist.FetchUpstreamTuple') + @mock.patch('git_cl.Changelist.GetCommonAncestorWithUpstream') + @mock.patch('git_cl.Changelist.GetBranch') + @mock.patch('git_cl.Changelist.GetRemoteBranch') + @mock.patch('scm.GIT.IsAncestor') + @mock.patch('gclient_utils.AskForData') + def test_upload_all_precheck_one_change(self, mockAskForData, mockIsAncestor, + mockGetRemoteBranch, mockGetBranch, + mockGetCommonAncestorWithUpstream, + mockFetchUpstreamTuple, *_mocks): + + options = optparse.Values() + options.force = False + orig_args = ['--preserve-tryjobs', '--chicken'] + + mockGetRemoteBranch.return_value = ('origin', 'refs/remotes/origin/main') + mockGetBranch.return_value = ['current', 'main'] + mockGetCommonAncestorWithUpstream.side_effect = ['commit3.5'] + mockFetchUpstreamTuple.side_effect = [('', 'refs/heads/main')] + mockIsAncestor.return_value = True + + # Case 1: We hit the main branch + cls, cherry_pick = git_cl._UploadAllPrecheck(options, orig_args) + self.assertFalse(cherry_pick) + self.assertEqual(len(cls), 1) + + mockAskForData.assert_not_called() + @mock.patch('git_cl.RunGit') @mock.patch('git_cl.CMDupload') @mock.patch('sys.stdin', StringIO('\n'))