#!/usr/bin/env vpython3
# coding=utf-8
# Copyright 2012 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Unit tests for git_cl.py."""

import codecs
import datetime
import io
import json
import logging
import multiprocessing
import optparse
import os
import shutil
import sys
import tempfile
import threading
import unittest

from unittest import mock

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

import scm_mock

import metrics
import metrics_utils

# We have to disable monitoring before importing git_cl.
metrics_utils.COLLECT_METRICS = False

import auth  # noqa: E402
import contextlib  # noqa: E402
import gclient_utils  # noqa: E402
import gerrit_util  # noqa: E402
import git_cl  # noqa: E402
import git_common  # noqa: E402
import git_footers  # noqa: E402
import git_new_branch  # noqa: E402
import owners_client  # noqa: E402
import scm  # noqa: E402
import subprocess2  # noqa: E402


def callError(code=1, cmd="", cwd="", stdout=b"", stderr=b""):
    return subprocess2.CalledProcessError(code, cmd, cwd, stdout, stderr)


CERR1 = callError(1)


def getAccountDetailsMock(host, account_id="self", *, authenticator=None):
    if account_id == "self":
        return {
            "_account_id": 123456,
            "avatars": [],
            "email": "getAccountDetailsMock@example.com",
            "name": "GetAccountDetails(self)",
            "status": "OOO",
        }
    return None


class TemporaryFileMock(object):
    def __init__(self):
        self.suffix = 0

    @contextlib.contextmanager
    def __call__(self):
        self.suffix += 1
        yield "/tmp/fake-temp" + str(self.suffix)


class ChangelistMock(object):
    # A class variable so we can access it when we don't have access to the
    # instance that's being set.
    desc = ""

    def __init__(self, gerrit_change=None, use_python3=False, **kwargs):
        self._gerrit_change = gerrit_change
        self._use_python3 = use_python3

    def GetIssue(self):
        return 1

    def FetchDescription(self):
        return ChangelistMock.desc

    def UpdateDescription(self, desc, force=False):
        ChangelistMock.desc = desc

    def GetGerritChange(self, patchset=None, **kwargs):
        del patchset
        return self._gerrit_change

    def GetRemoteBranch(self):
        return ("origin", "refs/remotes/origin/main")


class WatchlistsMock(object):
    def __init__(self, _):
        pass

    @staticmethod
    def GetWatchersForPaths(_):
        return ["joe@example.com"]


class CodereviewSettingsFileMock(object):
    def __init__(self):
        pass

    def read(self):
        return (
            "CODE_REVIEW_SERVER: gerrit.chromium.org\n" + "GERRIT_HOST: True\n"
        )


class AuthenticatorMock(object):
    def __init__(self, *_args):
        pass

    def has_cached_credentials(self):
        return True

    def authorize(self, http):
        return http


def CookiesAuthenticatorMockFactory(hosts_with_creds=None, same_auth=False):
    """Use to mock Gerrit/Git credentials from ~/.gitcookies.

    Usage:
        >>> self.mock(git_cl.gerrit_util, "CookiesAuthenticator",
                    CookiesAuthenticatorMockFactory({'host': ('user', 'pass')})

    OR
        >>> self.mock(git_cl.gerrit_util, "CookiesAuthenticator",
                    CookiesAuthenticatorMockFactory(
                        same_auth=('user', 'pass'))
    """

    class CookiesAuthenticatorMock(git_cl.gerrit_util.CookiesAuthenticator):
        def __init__(self):
            # Intentionally not calling super() because it reads actual cookie
            # files.
            pass

        @classmethod
        def get_gitcookies_path(cls):
            return os.path.join("~", ".gitcookies")

        def _get_auth_for_host(self, host):
            if same_auth:
                return same_auth
            return (hosts_with_creds or {}).get(host)

    return CookiesAuthenticatorMock


class MockChangelistWithBranchAndIssue:
    def __init__(self, branch, issue):
        self.branch = branch
        self.issue = issue

    def GetBranch(self):
        return self.branch

    def GetIssue(self):
        return self.issue


class SystemExitMock(Exception):
    pass


class ParserErrorMock(Exception):
    pass


class TestGitClBasic(unittest.TestCase):
    def setUp(self):
        mock.patch("sys.exit", side_effect=SystemExitMock).start()
        mock.patch("sys.stdout", io.StringIO()).start()
        mock.patch("sys.stderr", io.StringIO()).start()
        self.addCleanup(mock.patch.stopall)

    def test_die_with_error(self):
        with self.assertRaises(SystemExitMock):
            git_cl.DieWithError("foo", git_cl.ChangeDescription("lorem ipsum"))
        self.assertEqual(sys.stderr.getvalue(), "foo\n")
        self.assertTrue("saving CL description" in sys.stdout.getvalue())
        self.assertTrue("Content of CL description" in sys.stdout.getvalue())
        self.assertTrue("lorem ipsum" in sys.stdout.getvalue())
        sys.exit.assert_called_once_with(1)

    def test_die_with_error_no_desc(self):
        with self.assertRaises(SystemExitMock):
            git_cl.DieWithError("foo")
        self.assertEqual(sys.stderr.getvalue(), "foo\n")
        self.assertEqual(sys.stdout.getvalue(), "")
        sys.exit.assert_called_once_with(1)

    def test_cherry_pick_line_regex(self):
        pattern = git_cl.ChangeDescription.CHERRY_PICK_LINE
        self.assertRegex(f"(cherry picked from commit {'a' * 40})", pattern)
        self.assertRegex(f"(cherry picked from commit {'a' * 64})", pattern)
        self.assertNotRegex(f"(cherry picked from commit {'a' * 50})", pattern)
        self.assertNotRegex("(cherry picked from commit zzzz)", pattern)

    @mock.patch("git_cl.subcommand.CommandDispatcher.execute")
    def test_main_outside_git_repo(self, mock_execute):
        mock_execute.side_effect = subprocess2.CalledProcessError(
            128,
            ["git", "rev-parse", "--show-cdup"],
            ".",
            b"",
            b"fatal: not a git repository (or any of the parent directories): .git",
        )

        with self.assertRaises(SystemExitMock):
            git_cl.main(["issue"])

        self.assertEqual(
            sys.stderr.getvalue(),
            "fatal: not a git repository (or any of the parent directories): .git\n",
        )
        sys.exit.assert_called_once_with(1)

    @mock.patch("git_cl.subcommand.CommandDispatcher.execute")
    @mock.patch("scm.GIT.Capture")
    def test_main_untracked_cache_bug(self, mock_capture, mock_execute):
        mock_execute.side_effect = subprocess2.CalledProcessError(
            128,
            ["git", "status"],
            ".",
            b"",
            b"BUG: dir.c:4033: untracked_cache_invalidate_trimmed_path given zero length path",
        )
        mock_capture.return_value = os.path.abspath("/path/to/.git")

        with self.assertRaises(SystemExitMock):
            git_cl.main(["status"])

        lock_path = os.path.join(os.path.abspath("/path/to/.git"), "index.lock")
        index_path = os.path.join(os.path.abspath("/path/to/.git"), "index")
        expected_msg = (
            f"Git encountered a bug with the untracked cache.\n"
            f"To fix this, please rebuild your git index.\n"
            f"\n"
            f"Make sure no Git process is running first, then run:\n"
            f"  rm -f {lock_path}\n"
            f"  rm -f {index_path}\n"
            f"  git reset\n"
            f"\n"
            f"Note: This will unstage any staged changes.\n"
            f"\n"
            f"(Optional) To prevent this from happening again, run:\n"
            f"  git update-index --no-untracked-cache\n"
            f"  git config core.untrackedCache false\n"
        )
        self.assertEqual(sys.stderr.getvalue(), expected_msg)
        sys.exit.assert_called_once_with(1)

    @mock.patch("git_cl.subcommand.CommandDispatcher.execute")
    @mock.patch("scm.GIT.Capture")
    @mock.patch("git_cl.FindGitDir")
    def test_main_untracked_cache_bug_find_git_dir(
        self, mock_find, mock_capture, mock_execute
    ):
        mock_execute.side_effect = subprocess2.CalledProcessError(
            128,
            ["git", "status"],
            ".",
            b"",
            b"BUG: dir.c:4033: untracked_cache_invalidate_trimmed_path given zero length path",
        )
        mock_capture.side_effect = OSError("git failed")
        mock_find.return_value = os.path.abspath("/path/to/found/.git")

        with self.assertRaises(SystemExitMock):
            git_cl.main(["status"])

        lock_path = os.path.join(
            os.path.abspath("/path/to/found/.git"), "index.lock"
        )
        index_path = os.path.join(
            os.path.abspath("/path/to/found/.git"), "index"
        )
        expected_msg = (
            f"Git encountered a bug with the untracked cache.\n"
            f"To fix this, please rebuild your git index.\n"
            f"\n"
            f"Make sure no Git process is running first, then run:\n"
            f"  rm -f {lock_path}\n"
            f"  rm -f {index_path}\n"
            f"  git reset\n"
            f"\n"
            f"Note: This will unstage any staged changes.\n"
            f"\n"
            f"(Optional) To prevent this from happening again, run:\n"
            f"  git update-index --no-untracked-cache\n"
            f"  git config core.untrackedCache false\n"
        )
        self.assertEqual(sys.stderr.getvalue(), expected_msg)
        sys.exit.assert_called_once_with(1)

    @mock.patch("git_cl.subcommand.CommandDispatcher.execute")
    @mock.patch("scm.GIT.Capture")
    @mock.patch("git_cl.FindGitDir")
    def test_main_untracked_cache_bug_fallback(
        self, mock_find, mock_capture, mock_execute
    ):
        mock_execute.side_effect = subprocess2.CalledProcessError(
            128,
            ["git", "status"],
            ".",
            b"",
            b"BUG: dir.c:4033: untracked_cache_invalidate_trimmed_path given zero length path",
        )
        mock_capture.side_effect = OSError("git failed")
        mock_find.return_value = None

        with self.assertRaises(SystemExitMock):
            git_cl.main(["status"])

        lock_path = "$(git rev-parse --git-dir)/index.lock"
        index_path = "$(git rev-parse --git-dir)/index"
        expected_msg = (
            f"Git encountered a bug with the untracked cache.\n"
            f"To fix this, please rebuild your git index.\n"
            f"\n"
            f"Make sure no Git process is running first, then run:\n"
            f"  rm -f {lock_path}\n"
            f"  rm -f {index_path}\n"
            f"  git reset\n"
            f"\n"
            f"Note: This will unstage any staged changes.\n"
            f"\n"
            f"(Optional) To prevent this from happening again, run:\n"
            f"  git update-index --no-untracked-cache\n"
            f"  git config core.untrackedCache false\n"
        )
        self.assertEqual(sys.stderr.getvalue(), expected_msg)
        sys.exit.assert_called_once_with(1)

    def test_fetch_description(self):
        cl = git_cl.Changelist(issue=1, codereview_host="host")
        cl.description = "x"
        self.assertEqual(cl.FetchDescription(), "x")

    @mock.patch("git_cl.Changelist.EnsureAuthenticated")
    @mock.patch("git_cl.Changelist.GetStatus", lambda cl: cl.status)
    def test_get_cl_statuses(self, *_mocks):
        statuses = [
            "closed",
            "commit",
            "dry-run",
            "lgtm",
            "reply",
            "unsent",
            "waiting",
        ]
        changes = []
        for status in statuses:
            cl = git_cl.Changelist()
            cl.status = status
            changes.append(cl)

        actual = set(git_cl.get_cl_statuses(changes, True))
        self.assertEqual(set(zip(changes, statuses)), actual)

    def test_upload_to_non_default_branch_no_retry(self):
        m = mock.patch(
            "git_cl.Changelist._CMDUploadChange",
            side_effect=[git_cl.GitPushError(), None],
        ).start()
        mock.patch(
            "git_cl.Changelist.GetRemoteBranch", return_value=("foo", "bar")
        ).start()
        mock.patch(
            "git_cl.Changelist.GetGerritProject", return_value="foo"
        ).start()
        mock.patch(
            "git_cl.gerrit_util.GetProjectHead", return_value="refs/heads/main"
        ).start()

        cl = git_cl.Changelist()
        options = optparse.Values()
        options.target_branch = "refs/heads/bar"
        with self.assertRaises(SystemExitMock):
            cl.CMDUploadChange(
                options, [], "foo", git_cl.ChangeDescription("bar")
            )

        # ensure upload is called once
        self.assertEqual(len(m.mock_calls), 1)
        sys.exit.assert_called_once_with(1)
        # option not set as retry didn't happen
        self.assertFalse(hasattr(options, "force"))
        self.assertFalse(hasattr(options, "edit_description"))

    def test_upload_to_meta_config_branch_no_retry(self):
        m = mock.patch(
            "git_cl.Changelist._CMDUploadChange",
            side_effect=[git_cl.GitPushError(), None],
        ).start()
        mock.patch(
            "git_cl.Changelist.GetRemoteBranch", return_value=("foo", "bar")
        ).start()
        mock.patch(
            "git_cl.Changelist.GetGerritProject", return_value="foo"
        ).start()
        mock.patch(
            "git_cl.gerrit_util.GetProjectHead", return_value="refs/heads/main"
        ).start()

        cl = git_cl.Changelist()
        options = optparse.Values()
        options.target_branch = "refs/meta/config"
        with self.assertRaises(SystemExitMock):
            cl.CMDUploadChange(
                options, [], "foo", git_cl.ChangeDescription("bar")
            )

        # ensure upload is called once
        self.assertEqual(len(m.mock_calls), 1)
        sys.exit.assert_called_once_with(1)
        # option not set as retry didn't happen
        self.assertFalse(hasattr(options, "force"))
        self.assertFalse(hasattr(options, "edit_description"))

    def test_upload_to_old_default_still_active(self):
        m = mock.patch(
            "git_cl.Changelist._CMDUploadChange",
            side_effect=[git_cl.GitPushError(), None],
        ).start()
        mock.patch(
            "git_cl.Changelist.GetRemoteBranch",
            return_value=("foo", git_cl.DEFAULT_OLD_BRANCH),
        ).start()
        mock.patch(
            "git_cl.Changelist.GetGerritProject", return_value="foo"
        ).start()
        mock.patch(
            "git_cl.gerrit_util.GetProjectHead", return_value="refs/heads/main"
        ).start()

        cl = git_cl.Changelist()
        options = optparse.Values()
        options.target_branch = "refs/heads/main"
        with self.assertRaises(SystemExitMock):
            cl.CMDUploadChange(
                options, [], "foo", git_cl.ChangeDescription("bar")
            )

        # ensure upload is called once
        self.assertEqual(len(m.mock_calls), 1)
        sys.exit.assert_called_once_with(1)
        # option not set as retry didn't happen
        self.assertFalse(hasattr(options, "force"))
        self.assertFalse(hasattr(options, "edit_description"))

    def test_upload_with_message_file_no_editor(self):
        m = mock.patch(
            "git_cl.ChangeDescription.prompt", return_value=None
        ).start()
        mock.patch(
            "git_cl.Changelist.GetRemoteBranch",
            return_value=("foo", git_cl.DEFAULT_NEW_BRANCH),
        ).start()
        mock.patch(
            "git_cl.GetTargetRef", return_value="refs/heads/main"
        ).start()
        mock.patch(
            "git_cl.Changelist._GerritCommitMsgHookCheck",
            lambda offer_removal: None,
        ).start()
        mock.patch("git_cl.Changelist.GetIssue", return_value=None).start()
        mock.patch(
            "git_cl.Changelist.GetBranch", side_effect=SystemExitMock
        ).start()
        mock.patch("git_cl.GenerateGerritChangeId", return_value=None).start()
        mock.patch("git_cl.RunGit").start()

        cl = git_cl.Changelist()
        options = optparse.Values()
        options.target_branch = "refs/heads/main"
        options.squash = True
        options.edit_description = False
        options.force = False
        options.preserve_tryjobs = False
        options.message_file = "message.txt"
        options.commit_description = None

        with self.assertRaises(SystemExitMock):
            cl.CMDUploadChange(
                options, [], "foo", git_cl.ChangeDescription("bar")
            )
        self.assertEqual(len(m.mock_calls), 0)

        options.message_file = None
        with self.assertRaises(SystemExitMock):
            cl.CMDUploadChange(
                options, [], "foo", git_cl.ChangeDescription("bar")
            )
        self.assertEqual(len(m.mock_calls), 1)

    def test_get_cl_statuses_no_changes(self):
        self.assertEqual([], list(git_cl.get_cl_statuses([], True)))

    @mock.patch("git_cl.Changelist.EnsureAuthenticated")
    @mock.patch("multiprocessing.pool.ThreadPool")
    def test_get_cl_statuses_timeout(self, *_mocks):
        changes = [git_cl.Changelist() for _ in range(2)]
        pool = multiprocessing.pool.ThreadPool()
        it = pool.imap_unordered.return_value.__iter__ = mock.Mock()
        it.return_value.next.side_effect = [
            (changes[0], "lgtm"),
            multiprocessing.TimeoutError,
        ]

        actual = list(git_cl.get_cl_statuses(changes, True))
        self.assertEqual([(changes[0], "lgtm"), (changes[1], "error")], actual)

    @mock.patch("git_cl.Changelist.GetIssueURL")
    def test_get_cl_statuses_not_finegrained(self, _mock):
        changes = [git_cl.Changelist() for _ in range(2)]
        urls = ["some-url", None]
        git_cl.Changelist.GetIssueURL.side_effect = urls

        actual = set(git_cl.get_cl_statuses(changes, False))
        self.assertEqual(
            set([(changes[0], "waiting"), (changes[1], "error")]), actual
        )

    def test_get_issue_url(self):
        cl = git_cl.Changelist(issue=123)
        cl._gerrit_server = "https://example.com"
        self.assertEqual(cl.GetIssueURL(), "https://example.com/123")
        self.assertEqual(cl.GetIssueURL(short=True), "https://example.com/123")

        cl = git_cl.Changelist(issue=123)
        cl._gerrit_server = "https://chromium-review.googlesource.com"
        self.assertEqual(
            cl.GetIssueURL(), "https://chromium-review.googlesource.com/123"
        )
        self.assertEqual(cl.GetIssueURL(short=True), "https://crrev.com/c/123")

    def test_set_preserve_tryjobs(self):
        d = git_cl.ChangeDescription("Simple.")
        d.set_preserve_tryjobs()
        self.assertEqual(
            d.description.splitlines(),
            [
                "Simple.",
                "",
                "Cq-Do-Not-Cancel-Tryjobs: true",
            ],
        )
        before = d.description
        d.set_preserve_tryjobs()
        self.assertEqual(before, d.description)

        d = git_cl.ChangeDescription(
            "\n".join(
                [
                    "One is enough",
                    "",
                    "Cq-Do-Not-Cancel-Tryjobs: dups not encouraged, but don't hurt",
                    "Change-Id: Ideadbeef",
                ]
            )
        )
        d.set_preserve_tryjobs()
        self.assertEqual(
            d.description.splitlines(),
            [
                "One is enough",
                "",
                "Cq-Do-Not-Cancel-Tryjobs: dups not encouraged, but don't hurt",
                "Change-Id: Ideadbeef",
                "Cq-Do-Not-Cancel-Tryjobs: true",
            ],
        )

    @mock.patch("sys.stdout", io.StringIO())
    def test_ensure_change_id(self):
        d = git_cl.ChangeDescription("Simple.\n\nChange-Id: Iold_change_id")
        d.ensure_change_id("Inew_change_id")
        self.assertEqual(
            d.description.splitlines(),
            [
                "Simple.",
                "",
                "Change-Id: Inew_change_id",
            ],
        )
        self.assertIn(
            "WARNING: Change-Id has been set to Inew_change_id. Use `git cl issue 0` if you want to set a new one.",
            sys.stdout.getvalue(),
        )

    def test_get_bug_line_values(self):
        f = lambda p, bugs: list(git_cl._get_bug_line_values(p, bugs))  # noqa: E731
        self.assertEqual(f("", ""), [])
        self.assertEqual(f("", "123,v8:456"), ["123", "v8:456"])
        # Prefix that ends with colon.
        self.assertEqual(f("v8:", "456"), ["v8:456"])
        self.assertEqual(
            f("v8:", "chromium:123,456"), ["v8:456", "chromium:123"]
        )
        # Prefix that ends without colon.
        self.assertEqual(f("v8", "456"), ["v8:456"])
        self.assertEqual(
            f("v8", "chromium:123,456"), ["v8:456", "chromium:123"]
        )
        # Not nice, but not worth carying.
        self.assertEqual(
            f("v8:", "chromium:123,456,v8:123"),
            ["v8:456", "chromium:123", "v8:123"],
        )
        self.assertEqual(
            f("v8", "chromium:123,456,v8:123"),
            ["v8:456", "chromium:123", "v8:123"],
        )

    @mock.patch("gerrit_util.GetAccountDetails")
    def test_valid_accounts(self, mockGetAccountDetails):
        mock_per_account = {
            "u1": None,  # 404, doesn't exist.
            "u2": {
                "_account_id": 123124,
                "avatars": [],
                "email": "u2@example.com",
                "name": "User Number 2",
                "status": "OOO",
            },
            "u3": git_cl.gerrit_util.GerritError(500, "retries didn't help :("),
        }

        def GetAccountDetailsMock(_, account):
            # Poor-man's mock library's side_effect.
            v = mock_per_account.pop(account)
            if isinstance(v, Exception):
                raise v
            return v

        mockGetAccountDetails.side_effect = GetAccountDetailsMock
        actual = git_cl.gerrit_util.ValidAccounts(
            "host", ["u1", "u2", "u3"], max_threads=1
        )
        self.assertEqual(
            actual,
            {
                "u2": {
                    "_account_id": 123124,
                    "avatars": [],
                    "email": "u2@example.com",
                    "name": "User Number 2",
                    "status": "OOO",
                },
            },
        )

    def test_get_is_gerrit(self):
        """Test Settings.GetIsGerrit with various gerrit.host values."""
        # Test empty value returns False
        settings = git_cl.Settings()
        with mock.patch.object(settings, "_GetConfig", return_value=""):
            self.assertFalse(settings.GetIsGerrit())

        # Test 'false' (case-insensitive) returns False
        for val in ["false", "False", "FALSE"]:
            settings = git_cl.Settings()
            with mock.patch.object(settings, "_GetConfig", return_value=val):
                self.assertFalse(settings.GetIsGerrit())

        # Test valid hostname returns True
        settings = git_cl.Settings()
        with mock.patch.object(
            settings,
            "_GetConfig",
            return_value="chromium-review.googlesource.com",
        ):
            self.assertTrue(settings.GetIsGerrit())

        # Test 'true' string returns True
        settings = git_cl.Settings()
        with mock.patch.object(settings, "_GetConfig", return_value="true"):
            self.assertTrue(settings.GetIsGerrit())


class TestParseIssueURL(unittest.TestCase):
    def _test(self, arg, issue=None, patchset=None, hostname=None, fail=False):
        parsed = git_cl.ParseIssueNumberArgument(arg)
        self.assertIsNotNone(parsed)
        if fail:
            self.assertFalse(parsed.valid)
            return
        self.assertTrue(parsed.valid)
        self.assertEqual(parsed.issue, issue)
        self.assertEqual(parsed.patchset, patchset)
        self.assertEqual(parsed.hostname, hostname)

    def test_basic(self):
        self._test("123", 123)
        self._test("", fail=True)
        self._test("abc", fail=True)
        self._test("123/1", fail=True)
        self._test("123a", fail=True)
        self._test("ssh://chrome-review.source.com/#/c/123/4/", fail=True)
        self._test("ssh://chrome-review.source.com/c/123/1/", fail=True)

    def test_gerrit_url(self):
        self._test(
            "https://codereview.source.com/123",
            123,
            None,
            "codereview.source.com",
        )
        self._test(
            "http://chrome-review.source.com/c/123",
            123,
            None,
            "chrome-review.source.com",
        )
        self._test(
            "https://chrome-review.source.com/c/123/",
            123,
            None,
            "chrome-review.source.com",
        )
        self._test(
            "https://chrome-review.source.com/c/123/4",
            123,
            4,
            "chrome-review.source.com",
        )
        self._test(
            "https://chrome-review.source.com/#/c/123/4",
            123,
            4,
            "chrome-review.source.com",
        )
        self._test(
            "https://chrome-review.source.com/c/123/4",
            123,
            4,
            "chrome-review.source.com",
        )
        self._test(
            "https://chrome-review.source.com/123",
            123,
            None,
            "chrome-review.source.com",
        )
        self._test(
            "https://chrome-review.source.com/123/4",
            123,
            4,
            "chrome-review.source.com",
        )

        self._test("https://chrome-review.source.com/bad/123/4", fail=True)
        self._test(
            "https://chrome-review.source.com/c/123/1/whatisthis", fail=True
        )
        self._test("https://chrome-review.source.com/c/abc/", fail=True)

    def test_short_urls(self):
        self._test(
            "https://crrev.com/c/2151934",
            2151934,
            None,
            "chromium-review.googlesource.com",
        )

    def test_missing_scheme(self):
        self._test(
            "codereview.source.com/123", 123, None, "codereview.source.com"
        )
        self._test(
            "crrev.com/c/2151934",
            2151934,
            None,
            "chromium-review.googlesource.com",
        )

    def test_corp_urls(self):
        self._test(
            "https://chromium-review.git.corp.google.com/c/123/4",
            123,
            4,
            "chromium-review.googlesource.com",
        )
        self._test(
            "chromium-review.git.corp.google.com/123",
            123,
            None,
            "chromium-review.googlesource.com",
        )


class GitCookiesCheckerTest(unittest.TestCase):
    def setUp(self):
        super(GitCookiesCheckerTest, self).setUp()
        self.c = git_cl._GitCookiesChecker()
        self.c._all_hosts = []
        mock.patch("sys.stdout", io.StringIO()).start()
        self.addCleanup(mock.patch.stopall)

    def mock_hosts_creds(self, subhost_identity_pairs):
        def ensure_googlesource(h):
            if not h.endswith(git_cl._GOOGLESOURCE):
                assert not h.endswith(".")
                return h + "." + git_cl._GOOGLESOURCE
            return h

        self.c._all_hosts = [
            (ensure_googlesource(h), i, ".gitcookies")
            for h, i in subhost_identity_pairs
        ]

    def test_identity_parsing(self):
        self.assertEqual(
            self.c._parse_identity("ldap.google.com"), ("ldap", "google.com")
        )
        self.assertEqual(
            self.c._parse_identity("git-ldap.example.com"),
            ("ldap", "example.com"),
        )
        # Specical case because we know there are no subdomains in chromium.org.
        self.assertEqual(
            self.c._parse_identity("git-note.period.chromium.org"),
            ("note.period", "chromium.org"),
        )
        # Pathological: ".period." can be either username OR domain, more likely
        # domain.
        self.assertEqual(
            self.c._parse_identity("git-note.period.example.com"),
            ("note", "period.example.com"),
        )

    def test_analysis_nothing(self):
        self.c._all_hosts = []
        self.assertFalse(self.c.has_generic_host())
        self.assertEqual(set(), self.c.get_conflicting_hosts())
        self.assertEqual(set(), self.c.get_duplicated_hosts())
        self.assertEqual(set(), self.c.get_partially_configured_hosts())

    def test_analysis(self):
        self.mock_hosts_creds(
            [
                (".googlesource.com", "git-example.chromium.org"),
                ("chromium", "git-example.google.com"),
                ("chromium-review", "git-example.google.com"),
                ("chrome-internal", "git-example.chromium.org"),
                ("chrome-internal-review", "git-example.chromium.org"),
                ("conflict", "git-example.google.com"),
                ("conflict-review", "git-example.chromium.org"),
                ("dup", "git-example.google.com"),
                ("dup", "git-example.google.com"),
                ("dup-review", "git-example.google.com"),
                ("partial", "git-example.google.com"),
                ("gpartial-review", "git-example.google.com"),
            ]
        )
        self.assertTrue(self.c.has_generic_host())
        self.assertEqual(
            set(["conflict.googlesource.com"]), self.c.get_conflicting_hosts()
        )
        self.assertEqual(
            set(["dup.googlesource.com"]), self.c.get_duplicated_hosts()
        )
        self.assertEqual(
            set(
                ["partial.googlesource.com", "gpartial-review.googlesource.com"]
            ),
            self.c.get_partially_configured_hosts(),
        )

    def test_report_no_problems(self):
        self.test_analysis_nothing()
        self.assertFalse(self.c.find_and_report_problems())
        self.assertEqual(sys.stdout.getvalue(), "")

    @mock.patch(
        "git_cl.gerrit_util.CookiesAuthenticator.get_gitcookies_path",
        return_value=os.path.join("~", ".gitcookies"),
    )
    def test_report(self, *_mocks):
        self.test_analysis()
        self.assertTrue(self.c.find_and_report_problems())
        with open(
            os.path.join(
                os.path.dirname(__file__), "git_cl_creds_check_report.txt"
            )
        ) as f:
            expected = f.read() % {
                "sep": os.sep,
            }

        def by_line(text):
            return [l.rstrip() for l in text.rstrip().splitlines()]  # noqa: E741

        self.maxDiff = 10000
        self.assertEqual(
            by_line(sys.stdout.getvalue().strip()), by_line(expected)
        )


class TestGitCl(unittest.TestCase):
    def setUp(self):
        super(TestGitCl, self).setUp()
        self.calls = []
        self._calls_done = []

        oldEnv = dict(os.environ)

        def _resetEnv():
            os.environ.clear()
            os.environ.update(oldEnv)

        self.addCleanup(_resetEnv)
        for var in (
            "GEMINI_CLI",
            "CLAUDECODE",
            "ANTIGRAVITY_AGENT",
            "CODEX_SANDBOX",
            "CURSOR_AGENT",
            "AI_AGENT",
        ):
            os.environ.pop(var, None)

        self.failed = False
        mock.patch("sys.stdout", io.StringIO()).start()
        mock.patch(
            "git_cl.time_time", lambda: self._mocked_call("time.time")
        ).start()
        mock.patch(
            "git_cl.metrics.collector.add_repeated",
            lambda *a: self._mocked_call("add_repeated", *a),
        ).start()
        mock.patch("subprocess2.call", self._mocked_call).start()
        mock.patch("subprocess2.check_call", self._mocked_call).start()
        mock.patch("subprocess2.check_output", self._mocked_call).start()
        mock.patch(
            "subprocess2.communicate",
            lambda *a, **_k: ([self._mocked_call(*a), ""], 0),
        ).start()
        mock.patch(
            "git_cl.gclient_utils.CheckCallAndFilter", self._mocked_call
        ).start()
        mock.patch("git_common.is_dirty_git_tree", lambda x: False).start()
        mock.patch(
            "git_cl_core.FindCodereviewSettingsFile", return_value=""
        ).start()
        mock.patch(
            "git_cl_core.SaveDescriptionBackup",
            lambda _: self._mocked_call("SaveDescriptionBackup"),
        ).start()
        mock.patch(
            "git_cl.SaveDescriptionBackup",
            lambda _: self._mocked_call("SaveDescriptionBackup"),
        ).start()
        mock.patch(
            "git_cl.write_json", lambda *a: self._mocked_call("write_json", *a)
        ).start()
        mock.patch(
            "git_cl.Changelist.RunHook",
            return_value={"more_cc": ["test-more-cc@chromium.org"]},
        ).start()
        mock.patch("git_cl.watchlists.Watchlists", WatchlistsMock).start()
        mock.patch("git_cl.auth.Authenticator", AuthenticatorMock).start()
        mock.patch("git_cl.subprocess2.call", return_value=0).start()
        git_cl.Changelist._DETAIL_CACHE.clear()
        git_cl.Changelist._ASYNC_DETAIL_THREADS.clear()
        self.addCleanup(git_cl.Changelist._DETAIL_CACHE.clear)
        self.addCleanup(git_cl.Changelist._ASYNC_DETAIL_THREADS.clear)
        git_cl._get_superproject_push_option.cache_clear()
        self.addCleanup(git_cl._get_superproject_push_option.cache_clear)
        mock.patch("gerrit_util.GetChangeDetail").start()
        mock.patch(
            "git_cl.gerrit_util.GetChangeComments",
            lambda *a: self._mocked_call("GetChangeComments", *a),
        ).start()
        mock.patch(
            "git_cl.gerrit_util.AddReviewers",
            lambda *a: self._mocked_call("AddReviewers", *a),
        ).start()
        mock.patch(
            "git_cl.gerrit_util.SetReview",
            lambda h, i, msg=None, labels=None, notify=None, ready=None, automatic_attention_set_update=None, project=None: (
                self._mocked_call(
                    "SetReview",
                    h,
                    i,
                    msg,
                    labels,
                    notify,
                    ready,
                    automatic_attention_set_update,
                    project,
                )
            ),
        ).start()
        mock.patch(
            "git_cl.gerrit_util.CreateDraft",
            lambda h, i, revision="current", body=None: self._mocked_call(
                "CreateDraft", h, i, revision, body
            ),
        ).start()
        mock.patch(
            "git_cl.gerrit_util.LuciContextAuthenticator.is_applicable",
            return_value=False,
        ).start()
        mock.patch(
            "git_cl.gerrit_util.GceAuthenticator.is_applicable",
            return_value=False,
        ).start()
        mock.patch(
            "git_cl.gerrit_util.ValidAccounts",
            lambda *a: self._mocked_call("ValidAccounts", *a),
        ).start()
        mock.patch("sys.exit", side_effect=SystemExitMock).start()
        mock.patch("git_cl.Settings.GetRoot", return_value="").start()
        mock.patch("git_cl.Settings.GetGitDir", return_value=".git").start()
        scm_mock.GIT(self)
        mock.patch("scm.GIT.ResolveCommit", return_value="hash").start()
        mock.patch("scm.GIT.IsValidRevision", return_value=True).start()
        mock.patch(
            "scm.GIT.FetchUpstreamTuple",
            return_value=("origin", "refs/heads/main"),
        ).start()
        mock.patch(
            "scm.GIT.CaptureStatus", return_value=[("M", "foo.txt")]
        ).start()
        # It's important to reset settings to not have inter-tests interference.
        git_cl.settings = git_cl.Settings()
        self.addCleanup(mock.patch.stopall)
        gerrit_util._Authenticator._resolved = None

    def tearDown(self):
        try:
            if not self.failed:
                self.assertEqual([], self.calls)
        except AssertionError:
            calls = "".join("  %s\n" % str(call) for call in self.calls[:5])
            if len(self.calls) > 5:
                calls += " ...\n"
            self.fail(
                "\n"
                "There are un-consumed calls after this test has finished:\n"
                + calls
            )
        finally:
            super(TestGitCl, self).tearDown()

    def _mocked_call(self, *args, **_kwargs):
        self.assertTrue(
            self.calls,
            "@%d  Expected: <Missing>   Actual: %r"
            % (len(self._calls_done), args),
        )
        top = self.calls.pop(0)
        expected_args, result = top

        # Also logs otherwise it could get caught in a try/finally and be hard
        # to diagnose.
        if expected_args != args:
            N = 5
            prior_calls = "\n  ".join(
                "@%d: %r" % (len(self._calls_done) - N + i, c[0])
                for i, c in enumerate(self._calls_done[-N:])
            )
            following_calls = "\n  ".join(
                "@%d: %r" % (len(self._calls_done) + i + 1, c[0])
                for i, c in enumerate(self.calls[:N])
            )
            extended_msg = (
                "A few prior calls:\n  %s\n\n"
                "This (expected):\n  @%d: %r\n"
                "This (actual):\n  @%d: %r\n\n"
                "A few following expected calls:\n  %s"
                % (
                    prior_calls,
                    len(self._calls_done),
                    expected_args,
                    len(self._calls_done),
                    args,
                    following_calls,
                )
            )

            self.failed = True
            self.fail(
                "@%d\n"
                "  Expected: %r\n"
                "  Actual:   %r\n"
                "\n"
                "%s"
                % (len(self._calls_done), expected_args, args, extended_msg)
            )

        self._calls_done.append(top)
        if isinstance(result, Exception):
            raise result
        # stdout from git commands is supposed to be a bytestream. Convert it
        # here instead of converting all test output in this file to bytes.
        if args[0][0] == "git" and not isinstance(result, bytes):
            result = result.encode("utf-8")
        return result

    @mock.patch("sys.stdin", io.StringIO("blah\nye\n"))
    @mock.patch("sys.stdout", io.StringIO())
    def test_ask_for_explicit_yes_true(self):
        self.assertTrue(git_cl.ask_for_explicit_yes("prompt"))
        self.assertEqual(
            "prompt [Yes/No]: Please, type yes or no: ", sys.stdout.getvalue()
        )

    def test_LoadCodereviewSettingsFromFile_gerrit(self):
        codereview_file = io.StringIO("GERRIT_HOST: true")
        self.calls = []
        self.assertIsNone(
            git_cl.LoadCodereviewSettingsFromFile(codereview_file)
        )

    def test_LoadCodereviewSettingsFromFile_format_js(self):
        codereview_file = io.StringIO("FORMAT_JS: True")
        self.calls = []
        git_cl.LoadCodereviewSettingsFromFile(codereview_file)
        self.assertEqual("True", scm.GIT.GetConfig("", "cl.format-js"))

    @classmethod
    def _gerrit_base_calls(
        cls,
        issue=None,
        fetched_description=None,
        fetched_status=None,
        other_cl_owner=None,
        custom_cl_base=None,
        short_hostname="chromium",
        change_id=None,
        default_branch="main",
        reset_issue=False,
    ):
        calls = [
            (
                (["os.path.isfile", ".gitmodules"],),
                "True",
            ),
        ]
        if custom_cl_base:
            ancestor_revision = custom_cl_base
        else:
            # Determine ancestor_revision to be merge base.
            ancestor_revision = "origin/" + default_branch

        if issue:
            # TODO: if tests don't provide a `change_id` the default used here
            # will cause the TRACES_README_FORMAT mock (which uses the test
            # provided `change_id` to fail.
            gerrit_util.GetChangeDetail.return_value = {
                "owner": {"email": (other_cl_owner or "owner@example.com")},
                "change_id": (change_id or "123456789"),
                "current_revision": "sha1_of_current_revision",
                "revisions": {
                    "sha1_of_current_revision": {
                        "commit": {"message": fetched_description},
                    }
                },
                "status": fetched_status or "NEW",
            }

            if fetched_status == "ABANDONED":
                return calls
            if fetched_status == "MERGED":
                calls.append(
                    (
                        (
                            "ask_for_data",
                            "Change https://chromium-review.googlesource.com/%s has been "
                            "submitted, new uploads are not allowed. Would you like to start "
                            "a new change (Y/n)?" % issue,
                        ),
                        "y" if reset_issue else "n",
                    )
                )
                if not reset_issue:
                    return calls
                # Part of SetIssue call.
                calls.append(((["git", "log", "-1", "--format=%B"],), ""))
            if other_cl_owner:
                calls += [
                    (
                        (
                            "ask_for_data",
                            "Press Enter to upload, or Ctrl+C to abort",
                        ),
                        "",
                    ),
                ]

        calls += [
            (
                (
                    ["git", "rev-list", "--count"]
                    + (
                        [f"{custom_cl_base}..HEAD"]
                        if custom_cl_base
                        else [f"{ancestor_revision}..HEAD"]
                    ),
                ),
                "3",
            ),
        ]

        calls += [
            (
                (
                    [
                        "git",
                        "diff",
                        "--no-ext-diff",
                        "--stat",
                        "-l100000",
                        "-C50",
                    ]
                    + (
                        [custom_cl_base]
                        if custom_cl_base
                        else [ancestor_revision, "HEAD"]
                    ),
                ),
                "+dat",
            ),
        ]
        return calls

    def _gerrit_upload_calls(
        self,
        description,
        reviewers,
        squash,
        squash_mode="default",
        title=None,
        notify=False,
        post_amend_description=None,
        issue=None,
        cc=None,
        custom_cl_base=None,
        short_hostname="chromium",
        labels=None,
        change_id=None,
        final_description=None,
        gitcookies_exists=True,
        force=False,
        edit_description=None,
        default_branch="main",
        ref_to_push="abcdef0123456789",
        external_parent=None,
        push_opts=None,
        trace=False,
    ):
        if post_amend_description is None:
            post_amend_description = description
        cc = cc or []

        calls = []

        if squash_mode in ("override_squash", "override_nosquash"):
            scm.GIT.SetConfig(
                "",
                "gerrit.override-squash-uploads",
                "true" if squash_mode == "override_squash" else "false",
            )

        if not git_footers.get_footer_change_id(description) and not squash:
            calls += [
                (("DownloadGerritHook", False), ""),
            ]
        if squash:
            if not issue and not force:
                calls += [
                    ((["RunEditor"],), description),
                ]
            # user wants to edit description
            if edit_description:
                calls += [
                    ((["RunEditor"],), edit_description),
                ]

            if external_parent:
                parent = external_parent
            else:
                if custom_cl_base is None:
                    parent = "origin/" + default_branch
                    git_common.get_or_create_merge_base.return_value = parent
                else:
                    calls += [
                        (
                            (
                                [
                                    "git",
                                    "merge-base",
                                    "--is-ancestor",
                                    custom_cl_base,
                                    "refs/remotes/origin/" + default_branch,
                                ],
                            ),
                            callError(1),
                        ),  # Means not ancenstor.
                        (
                            (
                                "ask_for_data",
                                "Do you take responsibility for cleaning up potential mess "
                                "resulting from proceeding with upload? Press Enter to upload, "
                                "or Ctrl+C to abort",
                            ),
                            "",
                        ),
                    ]
                    parent = custom_cl_base

            calls += [
                (
                    (
                        ["git", "rev-parse", "HEAD:"],
                    ),  # `HEAD:` means HEAD's tree hash.
                    "0123456789abcdef",
                ),
                ((["FileWrite", "/tmp/fake-temp1", description],), None),
                (
                    (
                        [
                            "git",
                            "commit-tree",
                            "0123456789abcdef",
                            "-p",
                            parent,
                            "-F",
                            "/tmp/fake-temp1",
                        ],
                    ),
                    ref_to_push,
                ),
            ]
        else:
            ref_to_push = "HEAD"
            parent = "origin/refs/heads/" + default_branch

        calls += [
            (("SaveDescriptionBackup",), None),
            (
                (["git", "rev-list", parent + ".." + ref_to_push],),
                "1hashPerLine\n",
            ),
        ]

        metrics_arguments = []
        ref_suffix_list = []
        if notify:
            ref_suffix_list.append("ready,notify=ALL")
            metrics_arguments += ["ready", "notify=ALL"]
        elif not issue and squash:
            ref_suffix_list.append("wip")
            metrics_arguments.append("wip")

        # If issue is given, then description is fetched from Gerrit instead.
        if not title and squash_mode != "override_nosquash":
            if issue is None:
                if squash:
                    title = "Initial upload"
            else:
                calls += [
                    ((["git", "show", "-s", "--format=%s", "HEAD", "--"],), ""),
                    (("ask_for_data", "Title for patchset []: "), "User input"),
                ]
                title = "User input"
        if title:
            ref_suffix_list.append(
                "m=" + gerrit_util.PercentEncodeForGitRef(title)
            )
            metrics_arguments.append("m")

        for k, v in sorted((labels or {}).items()):
            ref_suffix_list.append("l=%s+%d" % (k, v))
            metrics_arguments.append("l=%s+%d" % (k, v))

        if short_hostname == "chromium":
            # All reviewers and ccs get into ref_suffix.
            for r in sorted(reviewers):
                ref_suffix_list.append("r=%s" % r)
                metrics_arguments.append("r")
            if issue is None:
                cc += ["test-more-cc@chromium.org", "joe@example.com"]
            for c in sorted(cc):
                ref_suffix_list.append("cc=%s" % c)
                metrics_arguments.append("cc")
            reviewers, cc = [], []
        else:
            # TODO(crbug/877717): remove this case.
            calls += [
                (
                    (
                        "ValidAccounts",
                        "%s-review.googlesource.com" % short_hostname,
                        sorted(reviewers)
                        + ["joe@example.com", "test-more-cc@chromium.org"]
                        + cc,
                    ),
                    {
                        e: {"email": e}
                        for e in (reviewers + ["joe@example.com"] + cc)
                    },
                )
            ]
            for r in sorted(reviewers):
                if r != "bad-account-or-email":
                    ref_suffix_list.append("r=%s" % r)
                    metrics_arguments.append("r")
                    reviewers.remove(r)
            if issue is None:
                cc += ["joe@example.com"]
            for c in sorted(cc):
                ref_suffix_list.append("cc=%s" % c)
                metrics_arguments.append("cc")
                if c in cc:
                    cc.remove(c)

        ref_suffix = ""
        if ref_suffix_list:
            ref_suffix = "%" + ",".join(ref_suffix_list)
        calls += [
            (
                ("time.time",),
                1000,
            ),
            (
                (
                    [
                        "git",
                        "push",
                        "https://%s.googlesource.com/my/repo" % short_hostname,
                        ref_to_push
                        + ":refs/for/refs/heads/"
                        + default_branch
                        + ref_suffix,
                    ]
                    + (push_opts if push_opts else []),
                ),
                (
                    (
                        "remote:\n"
                        "remote: Processing changes: (\\)\n"
                        "remote: Processing changes: (|)\n"
                        "remote: Processing changes: (/)\n"
                        "remote: Processing changes: (-)\n"
                        "remote: Processing changes: new: 1 (/)\n"
                        "remote: Processing changes: new: 1, done\n"
                        "remote:\n"
                        "remote: New Changes:\n"
                        "remote:   "
                        "https://%s-review.googlesource.com/#/c/my/repo/+/123456"
                        " XXX\n"
                        "remote:\n"
                        "To https://%s.googlesource.com/my/repo\n"
                        " * [new branch]      hhhh -> refs/for/refs/heads/%s\n"
                    )
                    % (short_hostname, short_hostname, default_branch)
                ),
            ),
            (
                ("time.time",),
                2000,
            ),
            (
                (
                    "add_repeated",
                    "sub_commands",
                    {
                        "execution_time": 1000,
                        "command": "git push",
                        "exit_code": 0,
                        "arguments": sorted(metrics_arguments),
                    },
                ),
                None,
            ),
        ]

        final_description = final_description or post_amend_description.strip()

        if trace or os.environ.get("GIT_CL_TRACE") == "1":
            trace_name = os.path.join("TRACES_DIR", "20170316T200041.000000")

            # Trace-related calls
            calls += [
                # Write a description with context for the current trace.
                (
                    (
                        [
                            "FileWrite",
                            trace_name + "-README",
                            "%(date)s\n"
                            "%(short_hostname)s-review.googlesource.com\n"
                            "%(change_id)s\n"
                            "%(title)s\n"
                            "%(description)s\n"
                            "1000\n"
                            "0\n"
                            "%(trace_name)s"
                            % {
                                "date": "2017-03-16T20:00:41.000000",
                                "short_hostname": short_hostname,
                                "change_id": change_id,
                                "description": final_description,
                                "title": title or "<untitled>",
                                "trace_name": trace_name,
                            },
                        ],
                    ),
                    None,
                ),
                # Read traces and shorten git hashes.
                (
                    (
                        [
                            "os.path.isfile",
                            os.path.join("TEMP_DIR", "trace-packet"),
                        ],
                    ),
                    True,
                ),
                (
                    (["FileRead", os.path.join("TEMP_DIR", "trace-packet")],),
                    (
                        "git-hash: 0123456789012345678901234567890123456789\n"
                        "git-hash: abcdeabcdeabcdeabcdeabcdeabcdeabcdeabcde\n"
                    ),
                ),
                (
                    (
                        [
                            "FileWrite",
                            os.path.join("TEMP_DIR", "trace-packet"),
                            "git-hash: 012345\ngit-hash: abcdea\n",
                        ],
                    ),
                    None,
                ),
                # Make zip file for the git traces.
                (
                    (
                        [
                            "make_archive",
                            trace_name + "-traces",
                            "zip",
                            "TEMP_DIR",
                        ],
                    ),
                    None,
                ),
                # Collect git config and gitcookies.
                #
                # We accept ANY for the git-config file because it's just reflecting
                # our mocked git config in scm.GIT anyway.
                (
                    (
                        [
                            "FileWrite",
                            os.path.join("TEMP_DIR", "git-config"),
                            mock.ANY,
                        ],
                    ),
                    None,
                ),
                (
                    (["os.path.isfile", os.path.join("~", ".gitcookies")],),
                    gitcookies_exists,
                ),
            ]
            if gitcookies_exists:
                calls += [
                    (
                        (["FileRead", os.path.join("~", ".gitcookies")],),
                        "gitcookies 1/SECRET",
                    ),
                    (
                        (
                            [
                                "FileWrite",
                                os.path.join(
                                    "TEMP_DIR",
                                    "CookiesAuthenticatorMock.debug_summary_state",
                                ),
                                "gitcookies REDACTED",
                            ],
                        ),
                        None,
                    ),
                ]
            else:
                calls += [
                    (
                        (
                            [
                                "FileWrite",
                                os.path.join(
                                    "TEMP_DIR",
                                    "CookiesAuthenticatorMock.debug_summary_state",
                                ),
                                "",
                            ],
                        ),
                        None,
                    ),
                ]
            calls += [
                # Make zip file for the git config and gitcookies.
                (
                    (
                        [
                            "make_archive",
                            trace_name + "-git-info",
                            "zip",
                            "TEMP_DIR",
                        ],
                    ),
                    None,
                ),
            ]

        # TODO(crbug/877717): this should never be used.
        if squash and short_hostname != "chromium":
            calls += [
                (
                    (
                        "AddReviewers",
                        "chromium-review.googlesource.com",
                        "my%2Frepo~123456",
                        sorted(reviewers),
                        cc + ["test-more-cc@chromium.org"],
                        notify,
                    ),
                    "",
                ),
            ]
        return calls

    def _run_gerrit_upload_test(
        self,
        upload_args,
        description,
        reviewers=None,
        squash=True,
        squash_mode=None,
        title=None,
        notify=False,
        post_amend_description=None,
        issue=None,
        patchset=None,
        cc=None,
        fetched_status=None,
        other_cl_owner=None,
        custom_cl_base=None,
        short_hostname="chromium",
        labels=None,
        change_id=None,
        final_description=None,
        gitcookies_exists=True,
        force=False,
        log_description=None,
        edit_description=None,
        fetched_description=None,
        default_branch="main",
        ref_to_push="abcdef0123456789",
        external_parent=None,
        push_opts=None,
        reset_issue=False,
        trace=False,
    ):
        """Generic gerrit upload test framework."""
        if squash_mode is None:
            if "--no-squash" in upload_args:
                squash_mode = "nosquash"
            elif "--squash" in upload_args:
                squash_mode = "squash"
            else:
                squash_mode = "default"

        reviewers = reviewers or []
        cc = cc or []
        mock.patch(
            "git_cl.gerrit_util.CookiesAuthenticator",
            CookiesAuthenticatorMockFactory(
                same_auth=("git-owner.example.com", "pass")
            ),
        ).start()
        mock.patch(
            "git_cl.Changelist._GerritCommitMsgHookCheck",
            lambda offer_removal: None,
        ).start()
        mock.patch(
            "git_cl.Changelist.GetMostRecentPatchset",
            lambda _, update: patchset,
        ).start()
        mock.patch(
            "git_cl.gclient_utils.RunEditor",
            lambda *_, **__: self._mocked_call(["RunEditor"]),
        ).start()
        mock.patch(
            "git_cl.DownloadGerritHook",
            lambda force: self._mocked_call("DownloadGerritHook", force),
        ).start()
        mock.patch(
            "git_cl.gclient_utils.FileRead",
            lambda path: self._mocked_call(["FileRead", path]),
        ).start()
        mock.patch(
            "git_cl.gclient_utils.FileWrite",
            lambda path, contents: self._mocked_call(
                ["FileWrite", path, contents]
            ),
        ).start()
        mock.patch(
            "git_cl.datetime_now",
            lambda: datetime.datetime(2017, 3, 16, 20, 0, 41, 0),
        ).start()
        mock.patch("git_cl.tempfile.mkdtemp", lambda: "TEMP_DIR").start()
        mock.patch("git_cl.TRACES_DIR", "TRACES_DIR").start()
        mock.patch(
            "git_cl.TRACES_README_FORMAT",
            "%(now)s\n"
            "%(gerrit_host)s\n"
            "%(change_id)s\n"
            "%(title)s\n"
            "%(description)s\n"
            "%(execution_time)s\n"
            "%(exit_code)s\n"
            "%(trace_name)s",
        ).start()
        mock.patch(
            "git_cl.shutil.make_archive",
            lambda *args: self._mocked_call(["make_archive"] + list(args)),
        ).start()
        mock.patch(
            "os.path.isfile",
            lambda path: self._mocked_call(["os.path.isfile", path]),
        ).start()
        mock.patch(
            "git_cl._create_description_from_log",
            return_value=log_description or description,
        ).start()
        mock.patch(
            "git_cl.Changelist._AddChangeIdToCommitMessage",
            return_value=post_amend_description or description,
        ).start()
        mock.patch(
            "git_cl.GenerateGerritChangeId", return_value=change_id
        ).start()
        mock.patch(
            "git_common.get_or_create_merge_base",
            return_value="origin/" + default_branch,
        ).start()
        mock.patch(
            "gerrit_util.GetAccountDetails", getAccountDetailsMock
        ).start()
        mock.patch(
            "gclient_utils.AskForData",
            lambda prompt: self._mocked_call("ask_for_data", prompt),
        ).start()

        scm.GIT.SetConfig("", "gerrit.host", "true")
        scm.GIT.SetConfig(
            "", "branch.main.gerritissue", (str(issue) if issue else None)
        )
        scm.GIT.SetConfig(
            "",
            "remote.origin.url",
            f"https://{short_hostname}.googlesource.com/my/repo",
        )
        scm.GIT.SetConfig("", "user.email", "owner@example.com")

        if squash_mode == "override_nosquash":
            if issue:
                mock.patch(
                    "gerrit_util.GetChange", return_value={"_number": issue}
                ).start()
            else:
                mock.patch("gerrit_util.GetChange", return_value={}).start()

        self.calls = self._gerrit_base_calls(
            issue=issue,
            fetched_description=fetched_description or description,
            fetched_status=fetched_status,
            other_cl_owner=other_cl_owner,
            custom_cl_base=custom_cl_base,
            short_hostname=short_hostname,
            change_id=change_id,
            default_branch=default_branch,
            reset_issue=reset_issue,
        )

        if fetched_status == "ABANDONED" or (
            fetched_status == "MERGED" and not reset_issue
        ):
            pass  # readability
        else:
            if fetched_status == "MERGED" and reset_issue:
                fetched_status = "NEW"
                issue = None
            mock.patch(
                "gclient_utils.temporary_file", TemporaryFileMock()
            ).start()
            mock.patch("os.remove", return_value=True).start()
            self.calls += self._gerrit_upload_calls(
                description,
                reviewers,
                squash,
                squash_mode=squash_mode,
                title=title,
                notify=notify,
                post_amend_description=post_amend_description,
                issue=issue,
                cc=cc,
                custom_cl_base=custom_cl_base,
                short_hostname=short_hostname,
                labels=labels,
                change_id=change_id,
                final_description=final_description,
                gitcookies_exists=gitcookies_exists,
                force=force,
                edit_description=edit_description,
                default_branch=default_branch,
                ref_to_push=ref_to_push,
                external_parent=external_parent,
                push_opts=push_opts,
                trace=trace,
            )
        # Uncomment when debugging.
        # print('\n'.join(map(lambda x: '%2i: %s' % x, enumerate(self.calls))))
        git_cl.main(["upload"] + upload_args)
        if squash:
            self.assertIssueAndPatchset(patchset=str((patchset or 0) + 1))
            self.assertEqual(
                ref_to_push,
                scm.GIT.GetBranchConfig(
                    "", "main", git_cl.GERRIT_SQUASH_HASH_CONFIG_KEY
                ),
            )

    @mock.patch(
        "git_cl.Changelist.GetGerritHost",
        return_value="chromium-review.googlesource.com",
    )
    @mock.patch(
        "git_cl.Changelist.GetRemoteBranch",
        return_value=("origin", "refs/remotes/origin/main"),
    )
    @mock.patch("git_cl.Changelist.PostUploadUpdates")
    @mock.patch("git_cl.Changelist._RunGitPushWithTraces")
    @mock.patch("git_cl._UploadAllPrecheck")
    @mock.patch("git_cl.Changelist.PrepareCherryPickSquashedCommit")
    def test_upload_all_squashed_cherry_pick(
        self,
        mockCherryPickCommit,
        mockUploadAllPrecheck,
        mockRunGitPush,
        mockPostUploadUpdates,
        *_mocks,
    ):
        # Set up
        cls = [
            git_cl.Changelist(
                branchref="refs/heads/current-branch", issue="12345"
            ),
            git_cl.Changelist(branchref="refs/heads/upstream-branch"),
        ]
        mockUploadAllPrecheck.return_value = (cls, True)

        upstream_gerrit_commit = "upstream-commit"
        scm.GIT.SetConfig(
            "",
            "branch.upstream-branch.gerritsquashhash",
            upstream_gerrit_commit,
        )

        reviewers = []
        ccs = []
        commit_to_push = "commit-to-push"
        new_last_upload = "new-last-upload"
        change_desc = git_cl.ChangeDescription(
            "stonks/nChange-Id:ec15e81197380"
        )
        prev_patchset = 2
        new_upload = git_cl._NewUpload(
            reviewers,
            ccs,
            commit_to_push,
            new_last_upload,
            upstream_gerrit_commit,
            change_desc,
            prev_patchset,
        )
        mockCherryPickCommit.return_value = new_upload

        options = optparse.Values()
        options.send_mail = options.private = False
        options.squash = True
        options.title = None
        options.message = "honk stonk"
        options.topic = "circus"
        options.enable_auto_submit = False
        options.enable_owners_override = False
        options.set_bot_commit = False
        options.cq_dry_run = False
        options.use_commit_queue = False
        options.hashtags = ["cow"]
        options.target_branch = None
        options.push_options = ["uploadvalidator~skip"]
        orig_args = []

        mockRunGitPush.return_value = (
            "remote:   https://chromium-review."
            "googlesource.com/c/chromium/circus/clown/+/1234 stonks"
        )

        # Call
        git_cl.UploadAllSquashed(options, orig_args)

        # Asserts
        mockCherryPickCommit.assert_called_once_with(
            options, upstream_gerrit_commit
        )
        expected_refspec = (
            "commit-to-push:refs/for/refs/heads/main%"
            "m=honk_stonk,topic=circus,hashtag=cow"
        )
        expected_refspec_opts = ["m=honk_stonk", "topic=circus", "hashtag=cow"]
        mockRunGitPush.assert_called_once_with(
            expected_refspec,
            expected_refspec_opts,
            mock.ANY,
            options.push_options,
        )
        mockPostUploadUpdates.assert_called_once_with(
            options, new_upload, "1234", update_reviewers=False
        )

    @mock.patch(
        "git_cl.Changelist.GetGerritHost",
        return_value="chromium-review.googlesource.com",
    )
    @mock.patch(
        "git_cl.Changelist.GetRemoteBranch",
        return_value=("origin", "refs/remotes/origin/main"),
    )
    @mock.patch(
        "git_cl.Changelist.GetCommonAncestorWithUpstream",
        side_effect=["current-upstream-ancestor", "next-upstream-ancestor"],
    )
    @mock.patch("git_cl.Changelist.PostUploadUpdates")
    @mock.patch("git_cl.Changelist._RunGitPushWithTraces")
    @mock.patch("git_cl._UploadAllPrecheck")
    @mock.patch("git_cl.Changelist.PrepareSquashedCommit")
    def test_upload_all_squashed(
        self,
        mockSquashedCommit,
        mockUploadAllPrecheck,
        mockRunGitPush,
        mockPostUploadUpdates,
        *_mocks,
    ):
        # Set up
        cls = [
            git_cl.Changelist(
                branchref="refs/heads/current-branch", issue="12345"
            ),
            git_cl.Changelist(branchref="refs/heads/upstream-branch"),
        ]
        mockUploadAllPrecheck.return_value = (cls, False)

        reviewers = []
        ccs = []

        current_commit_to_push = "commit-to-push"
        current_new_last_upload = "new-last-upload"
        change_desc = git_cl.ChangeDescription(
            "stonks/nChange-Id:ec15e81197380"
        )
        prev_patchset = 2
        new_upload_current = git_cl._NewUpload(
            reviewers,
            ccs,
            current_commit_to_push,
            current_new_last_upload,
            "next-upstream-ancestor",
            change_desc,
            prev_patchset,
        )

        upstream_desc = git_cl.ChangeDescription("kwak")
        upstream_parent = "origin-commit"
        upstream_new_last_upload = "upstrea-last-upload"
        upstream_commit_to_push = "upstream_push_commit"
        new_upload_upstream = git_cl._NewUpload(
            reviewers,
            ccs,
            upstream_commit_to_push,
            upstream_new_last_upload,
            upstream_parent,
            upstream_desc,
            prev_patchset,
        )
        mockSquashedCommit.side_effect = [
            new_upload_upstream,
            new_upload_current,
        ]

        options = optparse.Values()
        options.send_mail = options.private = False
        options.squash = True
        options.title = None
        options.message = "honk stonk"
        options.topic = "circus"
        options.enable_auto_submit = False
        options.enable_owners_override = False
        options.set_bot_commit = False
        options.cq_dry_run = False
        options.use_commit_queue = False
        options.hashtags = ["cow"]
        options.target_branch = None
        options.push_options = ["uploadvalidator~skip"]
        orig_args = []

        mockRunGitPush.return_value = (
            "remote:   https://chromium-review."
            "googlesource.com/c/chromium/circus/clown/+/1233 kwak"
            "\n"
            "remote:   https://chromium-review."
            "googlesource.com/c/chromium/circus/clown/+/1234 stonks"
        )

        self.calls = [
            ((["git", "checkout", "-q", "--detach", "upstream-branch"],), ""),
            ((["git", "checkout", "-q", "--detach", "current-branch"],), ""),
            ((["git", "checkout", "-q", "main"],), ""),
        ]

        # Call
        git_cl.UploadAllSquashed(options, orig_args)

        # Asserts
        self.maxDiff = None
        self.assertEqual(
            mockSquashedCommit.mock_calls,
            [
                mock.call(
                    options,
                    "current-upstream-ancestor",
                    "current-upstream-ancestor",
                    end_commit="next-upstream-ancestor",
                ),
                mock.call(
                    options,
                    upstream_commit_to_push,
                    "next-upstream-ancestor",
                    end_commit=None,
                ),
            ],
        )

        expected_refspec = (
            "commit-to-push:refs/for/refs/heads/main%topic=circus,hashtag=cow"
        )
        expected_refspec_opts = ["topic=circus", "hashtag=cow"]
        mockRunGitPush.assert_called_once_with(
            expected_refspec,
            expected_refspec_opts,
            mock.ANY,
            options.push_options,
        )

        self.assertEqual(
            mockPostUploadUpdates.mock_calls,
            [
                mock.call(
                    options,
                    new_upload_upstream,
                    "1233",
                    update_reviewers=False,
                ),
                mock.call(
                    options,
                    new_upload_current,
                    "1234",
                    update_reviewers=False,
                ),
            ],
        )

    @mock.patch(
        "concurrent.futures.ThreadPoolExecutor",
        wraps=git_cl.concurrent.futures.ThreadPoolExecutor,
    )
    @mock.patch("git_cl.gerrit_util.AddReviewers")
    @mock.patch("git_cl.Changelist._GerritChangeIdentifier")
    @mock.patch(
        "git_cl.Changelist.GetGerritHost",
        return_value="chromium-review.googlesource.com",
    )
    @mock.patch(
        "git_cl.Changelist.GetRemoteBranch",
        return_value=("origin", "refs/remotes/origin/main"),
    )
    @mock.patch(
        "git_cl.Changelist.GetCommonAncestorWithUpstream",
        side_effect=["current-upstream-ancestor", "next-upstream-ancestor"],
    )
    @mock.patch("git_cl.Changelist.PostUploadUpdates")
    @mock.patch("git_cl.Changelist._RunGitPushWithTraces")
    @mock.patch("git_cl._UploadAllPrecheck")
    @mock.patch("git_cl.Changelist.PrepareSquashedCommit")
    def test_upload_all_squashed_concurrent_reviewer_updates(
        self,
        mockSquashedCommit,
        mockUploadAllPrecheck,
        mockRunGitPush,
        mockPostUploadUpdates,
        mockGetCommonAncestor,
        mockGetRemoteBranch,
        mockGetGerritHost,
        mockGerritChangeIdentifier,
        mockAddReviewers,
        mockExecutor,
    ):
        cls = [
            git_cl.Changelist(
                branchref="refs/heads/current-branch", issue="12345"
            ),
            git_cl.Changelist(branchref="refs/heads/upstream-branch"),
        ]
        mockUploadAllPrecheck.return_value = (cls, False)

        change_desc = git_cl.ChangeDescription(
            "stonks\nChange-Id: ec15e81197380"
        )
        new_upload_current = git_cl._NewUpload(
            ["r1@google.com"],
            ["c1@google.com"],
            "commit-to-push",
            "new-last-upload",
            "next-upstream-ancestor",
            change_desc,
            2,
        )
        new_upload_upstream = git_cl._NewUpload(
            ["r2@google.com"],
            ["c2@google.com"],
            "upstream_push_commit",
            "upstrea-last-upload",
            "origin-commit",
            change_desc,
            2,
        )
        mockSquashedCommit.side_effect = [
            new_upload_upstream,
            new_upload_current,
        ]
        mockRunGitPush.return_value = (
            "remote: https://chromium-review.googlesource.com/1233\n"
            "remote: https://chromium-review.googlesource.com/1234\n"
        )
        mockGerritChangeIdentifier.side_effect = [
            "project~1233",
            "project~1234",
        ]
        options = optparse.Values()
        options.send_mail = options.private = False
        options.squash = True
        options.title = None
        options.message = None
        options.topic = None
        options.enable_auto_submit = False
        options.enable_owners_override = False
        options.set_bot_commit = False
        options.cq_dry_run = False
        options.use_commit_queue = False
        options.hashtags = []
        options.target_branch = None
        options.push_options = []

        self.calls = [
            ((["git", "checkout", "-q", "--detach", "upstream-branch"],), ""),
            ((["git", "checkout", "-q", "--detach", "current-branch"],), ""),
            ((["git", "checkout", "-q", "main"],), ""),
        ]

        git_cl.UploadAllSquashed(options, [])
        mockExecutor.assert_called_once_with(max_workers=2)
        self.assertEqual(2, mockPostUploadUpdates.call_count)
        self.assertEqual(2, mockAddReviewers.call_count)

    @mock.patch(
        "concurrent.futures.ThreadPoolExecutor",
        wraps=git_cl.concurrent.futures.ThreadPoolExecutor,
    )
    @mock.patch(
        "git_cl.Changelist.GetGerritHost",
        return_value="chromium-review.googlesource.com",
    )
    @mock.patch(
        "git_cl.Changelist.GetRemoteBranch",
        return_value=("origin", "refs/remotes/origin/main"),
    )
    @mock.patch(
        "git_cl.Changelist.GetCommonAncestorWithUpstream",
        side_effect=["current-upstream-ancestor", "next-upstream-ancestor"],
    )
    @mock.patch("git_cl.Changelist.PostUploadUpdates")
    @mock.patch("git_cl.Changelist._RunGitPushWithTraces")
    @mock.patch("git_cl._UploadAllPrecheck")
    @mock.patch("git_cl.Changelist.PrepareSquashedCommit")
    def test_upload_all_squashed_no_reviewers_synchronous(
        self,
        mockSquashedCommit,
        mockUploadAllPrecheck,
        mockRunGitPush,
        mockPostUploadUpdates,
        mockGetCommonAncestor,
        mockGetRemoteBranch,
        mockGetGerritHost,
        mockExecutor,
    ):
        cls = [
            git_cl.Changelist(
                branchref="refs/heads/current-branch", issue="12345"
            ),
            git_cl.Changelist(branchref="refs/heads/upstream-branch"),
        ]
        mockUploadAllPrecheck.return_value = (cls, False)

        change_desc = git_cl.ChangeDescription(
            "stonks\nChange-Id: ec15e81197380"
        )
        new_upload_current = git_cl._NewUpload(
            [],
            [],
            "commit-to-push",
            "new-last-upload",
            "next-upstream-ancestor",
            change_desc,
            2,
        )
        new_upload_upstream = git_cl._NewUpload(
            [],
            [],
            "upstream_push_commit",
            "upstrea-last-upload",
            "origin-commit",
            change_desc,
            2,
        )
        mockSquashedCommit.side_effect = [
            new_upload_upstream,
            new_upload_current,
        ]
        mockRunGitPush.return_value = (
            "remote: https://chromium-review.googlesource.com/1233\n"
            "remote: https://chromium-review.googlesource.com/1234\n"
        )
        options = optparse.Values()
        options.send_mail = options.private = False
        options.squash = True
        options.title = None
        options.message = None
        options.topic = None
        options.enable_auto_submit = False
        options.enable_owners_override = False
        options.set_bot_commit = False
        options.cq_dry_run = False
        options.use_commit_queue = False
        options.hashtags = []
        options.target_branch = None
        options.push_options = []

        self.calls = [
            ((["git", "checkout", "-q", "--detach", "upstream-branch"],), ""),
            ((["git", "checkout", "-q", "--detach", "current-branch"],), ""),
            ((["git", "checkout", "-q", "main"],), ""),
        ]

        git_cl.UploadAllSquashed(options, [])
        mockExecutor.assert_not_called()
        self.assertEqual(2, mockPostUploadUpdates.call_count)

    @mock.patch(
        "concurrent.futures.ThreadPoolExecutor",
        wraps=git_cl.concurrent.futures.ThreadPoolExecutor,
    )
    @mock.patch("git_cl.gerrit_util.AddReviewers")
    @mock.patch("git_cl.Changelist._GerritChangeIdentifier")
    @mock.patch(
        "git_cl.Changelist.GetGerritHost",
        return_value="chromium-review.googlesource.com",
    )
    @mock.patch(
        "git_cl.Changelist.GetRemoteBranch",
        return_value=("origin", "refs/remotes/origin/main"),
    )
    @mock.patch(
        "git_cl.Changelist.GetCommonAncestorWithUpstream",
        return_value="current-upstream-ancestor",
    )
    @mock.patch("git_cl.Changelist.PostUploadUpdates")
    @mock.patch("git_cl.Changelist._RunGitPushWithTraces")
    @mock.patch("git_cl._UploadAllPrecheck")
    @mock.patch("git_cl.Changelist.PrepareSquashedCommit")
    def test_upload_all_squashed_single_cl_reviewer_update(
        self,
        mockSquashedCommit,
        mockUploadAllPrecheck,
        mockRunGitPush,
        mockPostUploadUpdates,
        mockGetCommonAncestor,
        mockGetRemoteBranch,
        mockGetGerritHost,
        mockGerritChangeIdentifier,
        mockAddReviewers,
        mockExecutor,
    ):
        cls = [
            git_cl.Changelist(
                branchref="refs/heads/current-branch", issue="12345"
            ),
        ]
        mockUploadAllPrecheck.return_value = (cls, False)

        change_desc = git_cl.ChangeDescription(
            "stonks\nChange-Id: ec15e81197380"
        )
        new_upload_current = git_cl._NewUpload(
            ["r1@google.com"],
            ["c1@google.com"],
            "commit-to-push",
            "new-last-upload",
            "next-upstream-ancestor",
            change_desc,
            2,
        )
        mockSquashedCommit.side_effect = [new_upload_current]
        mockRunGitPush.return_value = (
            "remote: https://chromium-review.googlesource.com/1234\n"
        )
        mockGerritChangeIdentifier.return_value = "project~1234"
        options = optparse.Values()
        options.send_mail = options.private = False
        options.squash = True
        options.title = None
        options.message = "stonks"
        options.topic = None
        options.enable_auto_submit = False
        options.enable_owners_override = False
        options.set_bot_commit = False
        options.cq_dry_run = False
        options.use_commit_queue = False
        options.hashtags = []
        options.target_branch = None
        options.push_options = []

        self.calls = [
            ((["git", "checkout", "-q", "--detach", "current-branch"],), ""),
            ((["git", "checkout", "-q", "main"],), ""),
        ]

        git_cl.UploadAllSquashed(options, [])
        mockExecutor.assert_not_called()
        mockPostUploadUpdates.assert_called_once_with(
            options, new_upload_current, "1234", update_reviewers=False
        )
        mockAddReviewers.assert_called_once_with(
            "chromium-review.googlesource.com",
            "project~1234",
            reviewers=["r1@google.com"],
            ccs=["c1@google.com"],
            notify=False,
        )

    @mock.patch(
        "git_cl.Changelist.GetGerritHost",
        return_value="chromium-review.googlesource.com",
    )
    @mock.patch(
        "git_cl.Changelist.GetRemoteBranch",
        return_value=("origin", "refs/remotes/origin/main"),
    )
    @mock.patch(
        "git_cl.Changelist.GetCommonAncestorWithUpstream",
        side_effect=["current-upstream-ancestor", "next-upstream-ancestor"],
    )
    @mock.patch("git_cl.Changelist.PostUploadUpdates")
    @mock.patch("git_cl.Changelist._RunGitPushWithTraces")
    @mock.patch("git_cl._UploadAllPrecheck")
    @mock.patch("git_cl.Changelist.PrepareSquashedCommit")
    def test_upload_all_squashed_no_space(
        self,
        mockSquashedCommit,
        mockUploadAllPrecheck,
        mockRunGitPush,
        mockPostUploadUpdates,
        *_mocks,
    ):
        # Set up
        cls = [
            git_cl.Changelist(
                branchref="refs/heads/current-branch", issue="12345"
            ),
            git_cl.Changelist(branchref="refs/heads/upstream-branch"),
        ]
        mockUploadAllPrecheck.return_value = (cls, False)

        reviewers = []
        ccs = []

        current_commit_to_push = "commit-to-push"
        current_new_last_upload = "new-last-upload"
        change_desc = git_cl.ChangeDescription(
            "Initial description\nChange-Id:ec15e81197380"
        )
        prev_patchset = 2
        new_upload_current = git_cl._NewUpload(
            reviewers,
            ccs,
            current_commit_to_push,
            current_new_last_upload,
            "next-upstream-ancestor",
            change_desc,
            prev_patchset,
        )

        upstream_desc = git_cl.ChangeDescription("kwak")
        upstream_parent = "origin-commit"
        upstream_new_last_upload = "upstrea-last-upload"
        upstream_commit_to_push = "upstream_push_commit"
        new_upload_upstream = git_cl._NewUpload(
            reviewers,
            ccs,
            upstream_commit_to_push,
            upstream_new_last_upload,
            upstream_parent,
            upstream_desc,
            prev_patchset,
        )
        mockSquashedCommit.side_effect = [
            new_upload_upstream,
            new_upload_current,
        ]

        options = optparse.Values()
        options.send_mail = options.private = False
        options.squash = True
        options.title = None
        options.message = "Initial upload"
        options.topic = "main-topic"
        options.enable_auto_submit = False
        options.enable_owners_override = False
        options.set_bot_commit = False
        options.cq_dry_run = False
        options.use_commit_queue = False
        options.hashtags = ["cow"]
        options.target_branch = None
        options.push_options = ["uploadvalidator~skip"]
        orig_args = []

        # NOTICE: No space after the issue numbers and ANSI escape sequences
        mockRunGitPush.return_value = (
            "\x1b[Kremote:   https://chromium-review."
            "googlesource.com/c/chromium/depot_tools/+/1233"
            "\n"
            "\x1b[Kremote:   https://chromium-review."
            "googlesource.com/c/chromium/depot_tools/+/1234"
        )

        self.calls = [
            ((["git", "checkout", "-q", "--detach", "upstream-branch"],), ""),
            ((["git", "checkout", "-q", "--detach", "current-branch"],), ""),
            ((["git", "checkout", "-q", "main"],), ""),
        ]

        # Call
        git_cl.UploadAllSquashed(options, orig_args)

        # Asserts
        self.assertEqual(
            mockPostUploadUpdates.mock_calls,
            [
                mock.call(
                    options,
                    new_upload_upstream,
                    "1233",
                    update_reviewers=False,
                ),
                mock.call(
                    options,
                    new_upload_current,
                    "1234",
                    update_reviewers=False,
                ),
            ],
        )

    @mock.patch("sys.stderr", io.StringIO())
    @mock.patch(
        "git_cl.Changelist.GetGerritHost",
        return_value="chromium-review.googlesource.com",
    )
    @mock.patch(
        "git_cl.Changelist.GetRemoteBranch",
        return_value=("origin", "refs/remotes/origin/main"),
    )
    @mock.patch(
        "git_cl.Changelist.GetCommonAncestorWithUpstream",
        side_effect=["current-upstream-ancestor", "next-upstream-ancestor"],
    )
    @mock.patch("git_cl.Changelist.PostUploadUpdates")
    @mock.patch("git_cl.Changelist._RunGitPushWithTraces")
    @mock.patch("git_cl._UploadAllPrecheck")
    @mock.patch("git_cl.Changelist.PrepareSquashedCommit")
    def test_upload_all_squashed_count_mismatch(
        self,
        mockSquashedCommit,
        mockUploadAllPrecheck,
        mockRunGitPush,
        mockPostUploadUpdates,
        *_mocks,
    ):
        # Set up
        cls = [
            git_cl.Changelist(
                branchref="refs/heads/current-branch", issue="12345"
            ),
            git_cl.Changelist(branchref="refs/heads/upstream-branch"),
        ]
        mockUploadAllPrecheck.return_value = (cls, False)

        reviewers = []
        ccs = []

        current_commit_to_push = "commit-to-push"
        current_new_last_upload = "new-last-upload"
        change_desc = git_cl.ChangeDescription(
            "Initial description\nChange-Id:ec15e81197380"
        )
        prev_patchset = 2
        new_upload_current = git_cl._NewUpload(
            reviewers,
            ccs,
            current_commit_to_push,
            current_new_last_upload,
            "next-upstream-ancestor",
            change_desc,
            prev_patchset,
        )

        upstream_desc = git_cl.ChangeDescription("kwak")
        upstream_parent = "origin-commit"
        upstream_new_last_upload = "upstrea-last-upload"
        upstream_commit_to_push = "upstream_push_commit"
        new_upload_upstream = git_cl._NewUpload(
            reviewers,
            ccs,
            upstream_commit_to_push,
            upstream_new_last_upload,
            upstream_parent,
            upstream_desc,
            prev_patchset,
        )
        mockSquashedCommit.side_effect = [
            new_upload_upstream,
            new_upload_current,
        ]

        options = optparse.Values()
        options.send_mail = options.private = False
        options.squash = True
        options.title = None
        options.message = "Initial upload"
        options.topic = "main-topic"
        options.enable_auto_submit = False
        options.enable_owners_override = False
        options.set_bot_commit = False
        options.cq_dry_run = False
        options.use_commit_queue = False
        options.hashtags = ["cow"]
        options.target_branch = None
        options.push_options = ["uploadvalidator~skip"]
        orig_args = []

        # Only one issue returned, but two expected, and it has ANSI sequences
        mockRunGitPush.return_value = (
            "\x1b[Kremote:   https://chromium-review."
            "googlesource.com/c/chromium/depot_tools/+/1233"
        )

        self.calls = [
            ((["git", "checkout", "-q", "--detach", "upstream-branch"],), ""),
            ((["git", "checkout", "-q", "--detach", "current-branch"],), ""),
            ((["git", "checkout", "-q", "main"],), ""),
        ]

        # Call
        with self.assertRaises(SystemExitMock):
            git_cl.UploadAllSquashed(options, orig_args)
        self.assertIn(
            "Created|Updated 1 issues on Gerrit, but 2 expected.",
            sys.stderr.getvalue(),
        )
        self.assertIn(
            "Detected change numbers: ['1233']", sys.stderr.getvalue()
        )

    @mock.patch(
        "git_cl.Changelist.GetGerritHost",
        return_value="chromium-review.googlesource.com",
    )
    @mock.patch(
        "git_cl.Changelist.GetRemoteBranch",
        return_value=("origin", "refs/remotes/origin/main"),
    )
    @mock.patch(
        "git_cl.Changelist.GetCommonAncestorWithUpstream",
        return_value="current-upstream-ancestor",
    )
    @mock.patch("git_cl.Changelist._UpdateWithExternalChanges")
    @mock.patch("git_cl.Changelist.PostUploadUpdates")
    @mock.patch("git_cl.Changelist._RunGitPushWithTraces")
    @mock.patch("git_cl._UploadAllPrecheck")
    @mock.patch("git_cl.Changelist.PrepareSquashedCommit")
    def test_upload_all_squashed_external_changes(
        self,
        mockSquashedCommit,
        mockUploadAllPrecheck,
        mockRunGitPush,
        mockPostUploadUpdates,
        mockExternalChanges,
        *_mocks,
    ):
        options = optparse.Values()
        options.send_mail = options.private = False
        options.squash = True
        options.title = None
        options.topic = "circus"
        options.message = "honk stonk"
        options.enable_auto_submit = False
        options.enable_owners_override = False
        options.set_bot_commit = False
        options.cq_dry_run = False
        options.use_commit_queue = False
        options.hashtags = ["cow"]
        options.target_branch = None
        options.push_options = ["uploadvalidator~skip"]
        orig_args = []

        cls = [
            git_cl.Changelist(
                branchref="refs/heads/current-branch", issue="12345"
            )
        ]
        mockUploadAllPrecheck.return_value = (cls, False)
        reviewers = []
        ccs = []

        # Test case: user wants to pull in external changes.
        mockExternalChanges.reset_mock()
        mockExternalChanges.return_value = None

        current_commit_to_push = "commit-to-push"
        current_new_last_upload = "new-last-upload"
        change_desc = git_cl.ChangeDescription(
            "stonks/nChange-Id:ec15e81197380"
        )
        prev_patchset = 2
        new_upload_current = git_cl._NewUpload(
            reviewers,
            ccs,
            current_commit_to_push,
            current_new_last_upload,
            "next-upstream-ancestor",
            change_desc,
            prev_patchset,
        )
        mockSquashedCommit.return_value = new_upload_current

        mockRunGitPush.return_value = (
            "remote:   https://chromium-review."
            "googlesource.com/c/chromium/circus/clown/+/1233 kwak"
        )

        # Test case: user wants to pull in external changes.
        mockExternalChanges.reset_mock()
        mockExternalChanges.return_value = "external-commit"

        self.calls = [
            ((["git", "checkout", "-q", "--detach", "current-branch"],), ""),
            ((["git", "checkout", "-q", "main"],), ""),
        ]

        # Call
        git_cl.UploadAllSquashed(options, orig_args)

        # Asserts
        self.assertEqual(
            mockSquashedCommit.mock_calls,
            [
                mock.call(
                    options,
                    "external-commit",
                    "external-commit",
                    end_commit=None,
                )
            ],
        )

        expected_refspec = (
            "commit-to-push:refs/for/refs/heads/main%"
            "m=honk_stonk,topic=circus,hashtag=cow"
        )
        expected_refspec_opts = ["m=honk_stonk", "topic=circus", "hashtag=cow"]
        mockRunGitPush.assert_called_once_with(
            expected_refspec,
            expected_refspec_opts,
            mock.ANY,
            options.push_options,
        )

        self.assertEqual(
            mockPostUploadUpdates.mock_calls,
            [
                mock.call(
                    options, new_upload_current, "1233", update_reviewers=False
                )
            ],
        )

        # Test case: user does not want external changes or there are none.
        mockSquashedCommit.reset_mock()
        mockExternalChanges.return_value = None

        self.calls = [
            ((["git", "checkout", "-q", "--detach", "current-branch"],), ""),
            ((["git", "checkout", "-q", "main"],), ""),
        ]

        # Call
        git_cl.UploadAllSquashed(options, orig_args)

        # Asserts
        self.assertEqual(
            mockSquashedCommit.mock_calls,
            [
                mock.call(
                    options,
                    "current-upstream-ancestor",
                    "current-upstream-ancestor",
                    end_commit=None,
                )
            ],
        )

    @mock.patch(
        "git_cl.Changelist.GetCommonAncestorWithUpstream",
        side_effect=["current-upstream-ancestor", "next-upstream-ancestor"],
    )
    @mock.patch("git_cl._UploadAllPrecheck")
    @mock.patch("git_cl.Changelist.PrepareSquashedCommit")
    def test_upload_all_squashed_restores_branch_on_exception(
        self, mockSquashedCommit, mockUploadAllPrecheck, *_mocks
    ):
        cls = [
            git_cl.Changelist(branchref="refs/heads/current-branch"),
            git_cl.Changelist(branchref="refs/heads/upstream-branch"),
        ]
        mockUploadAllPrecheck.return_value = (cls, False)
        mockSquashedCommit.side_effect = RuntimeError("Presubmit hook failure")

        options = optparse.Values()
        options.squash = True

        self.calls = [
            ((["git", "checkout", "-q", "--detach", "upstream-branch"],), ""),
            ((["git", "checkout", "-q", "main"],), ""),
        ]

        with self.assertRaises(RuntimeError):
            git_cl.UploadAllSquashed(options, [])

    @mock.patch(
        "git_cl.Changelist._GerritCommitMsgHookCheck",
        lambda offer_removal: None,
    )
    @mock.patch("git_cl.RunGit")
    @mock.patch("git_cl.RunGitSilent")
    @mock.patch("git_cl.Changelist._GitGetBranchConfigValue")
    @mock.patch("git_cl.Changelist.FetchUpstreamTuple")
    @mock.patch("git_cl.Changelist.GetCommonAncestorWithUpstream")
    @mock.patch("scm.GIT.GetBranchRef")
    @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,
        mockGetBranchRef,
        mockGetCommonAncestorWithUpstream,
        mockFetchUpstreamTuple,
        mockGitGetBranchConfigValue,
        mockRunGitSilent,
        mockRunGit,
        *_mocks,
    ):

        mockGetRemoteBranch.return_value = (
            "origin",
            "refs/remotes/origin/main",
        )
        branches = [
            "current",
            "upstream3",
            "blank3",
            "blank2",
            "upstream2",
            "blank1",
            "upstream1",
            "origin/main",
        ]
        mockGetBranchRef.side_effect = (
            ["refs/heads/current"]  # detached HEAD check
            + ["refs/heads/%s" % b for b in branches]
        )
        mockGetCommonAncestorWithUpstream.side_effect = [
            "commit3.5",
            "commit3.5",
            "commit3.5",
            "commit2.5",
            "commit1.5",
            "commit1.5",
            "commit0.5",
        ]
        mockFetchUpstreamTuple.side_effect = [
            (".", "refs/heads/upstream3"),
            (".", "refs/heads/blank3"),
            (".", "refs/heads/blank2"),
            (".", "refs/heads/upstream2"),
            (".", "refs/heads/blank1"),
            (".", "refs/heads/upstream1"),
            ("origin", "refs/heads/origin/main"),
        ]

        # end commits
        mockRunGit.side_effect = [
            "commit4",
            "commit3.5",
            "commit3.5",
            "commit2",
            "commit1.5",
            "commit1",
            "commit0.5",
        ]
        mockRunGitSilent.side_effect = ["80", "81", "0", "0", "82", "0", "83"]

        # Get gerrit squash hash. We only check this for branches that have a
        # diff. Set to None to trigger `must_upload_upstream`.
        mockGitGetBranchConfigValue.return_value = None

        options = optparse.Values()
        options.force = False
        options.cherry_pick_stacked = 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
        key = f"branch.upstream2.{git_cl.LAST_UPLOAD_HASH_CONFIG_KEY}"
        scm.GIT.SetConfig("", key, "commit2.3")
        mockIsAncestor.side_effect = [True]

        # Case 3: upstream1's last_upload matches upstream2's base_commit
        key = f"branch.upstream1.{git_cl.LAST_UPLOAD_HASH_CONFIG_KEY}"
        scm.GIT.SetConfig("", 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.\nAt least one parent branch in `current, upstream3, "
            "upstream2` has never been uploaded and must be uploaded before/with "
            "`upstream3`.\nPress Enter to confirm, or Ctrl+C to abort"
        )
        self.assertEqual(len(cls), 3)

    @mock.patch(
        "git_cl.Changelist._GerritCommitMsgHookCheck",
        lambda offer_removal: None,
    )
    @mock.patch("git_cl.RunGit")
    @mock.patch("git_cl.RunGitSilent")
    @mock.patch("git_cl.Changelist._GitGetBranchConfigValue")
    @mock.patch("git_cl.Changelist.FetchUpstreamTuple")
    @mock.patch("git_cl.Changelist.GetCommonAncestorWithUpstream")
    @mock.patch("scm.GIT.GetBranchRef")
    @mock.patch("git_cl.Changelist.GetRemoteBranch")
    @mock.patch("scm.GIT.IsAncestor")
    @mock.patch("gclient_utils.AskForData")
    def test_upload_all_precheck_options_must_upload(
        self,
        mockAskForData,
        mockIsAncestor,
        mockGetRemoteBranch,
        mockGetBranchRef,
        mockGetCommonAncestorWithUpstream,
        mockFetchUpstreamTuple,
        mockGitGetBranchConfigValue,
        mockRunGitSilent,
        mockRunGit,
        *_mocks,
    ):

        mockGetRemoteBranch.return_value = (
            "origin",
            "refs/remotes/origin/main",
        )
        branches = ["current", "upstream3", "main"]
        mockGetBranchRef.side_effect = (
            ["refs/heads/current"]  # detached HEAD check
            + ["refs/heads/%s" % b for b in branches]
        )

        mockGetCommonAncestorWithUpstream.side_effect = [
            "commit3.5",
            "commit0.5",
        ]
        mockFetchUpstreamTuple.side_effect = [
            (".", "refs/heads/upstream3"),
            ("origin", "refs/heads/main"),
        ]
        mockIsAncestor.return_value = True

        # end commits
        mockRunGit.return_value = "any-commit"
        mockRunGitSilent.return_value = "42"

        # Get gerrit squash hash. We only check this for branches that have a
        # diff.
        mockGitGetBranchConfigValue.return_value = None

        # Test case: User wants to cherry pick, but all branches must be
        # uploaded.
        options = optparse.Values()
        options.force = True
        options.cherry_pick_stacked = True
        orig_args = []
        with self.assertRaises(SystemExitMock):
            git_cl._UploadAllPrecheck(options, orig_args)

        # Test case: User does not require cherry picking
        options.cherry_pick_stacked = False
        # reset side_effects
        mockGetBranchRef.side_effect = (
            ["refs/heads/current"]  # detached HEAD check
            + ["refs/heads/%s" % b for b in branches]
        )
        mockGetCommonAncestorWithUpstream.side_effect = [
            "commit3.5",
            "commit0.5",
        ]
        mockFetchUpstreamTuple.side_effect = [
            (".", "refs/heads/upstream3"),
            ("origin", "refs/heads/main"),
        ]

        cls, cherry_pick = git_cl._UploadAllPrecheck(options, orig_args)
        self.assertFalse(cherry_pick)
        self.assertEqual(len(cls), 2)
        mockAskForData.assert_not_called()

        # Test case: User does not require cherry picking and not in force mode.
        options.force = False
        # reset side_effects
        mockGetBranchRef.side_effect = (
            ["refs/heads/current"]  # detached HEAD check
            + ["refs/heads/%s" % b for b in branches]
        )
        mockGetCommonAncestorWithUpstream.side_effect = [
            "commit3.5",
            "commit0.5",
        ]
        mockFetchUpstreamTuple.side_effect = [
            (".", "refs/heads/upstream3"),
            ("origin", "refs/heads/main"),
        ]

        cls, cherry_pick = git_cl._UploadAllPrecheck(options, orig_args)
        self.assertFalse(cherry_pick)
        self.assertEqual(len(cls), 2)
        mockAskForData.assert_called_once()

    @mock.patch(
        "git_cl.Changelist._GerritCommitMsgHookCheck",
        lambda offer_removal: None,
    )
    @mock.patch("git_cl.RunGit")
    @mock.patch("git_cl.RunGitSilent")
    @mock.patch("git_cl.Changelist._GitGetBranchConfigValue")
    @mock.patch("git_cl.Changelist.FetchUpstreamTuple")
    @mock.patch("git_cl.Changelist.GetCommonAncestorWithUpstream")
    @mock.patch("scm.GIT.GetBranchRef")
    @mock.patch("scm.GIT.IsAncestor")
    @mock.patch("gclient_utils.AskForData")
    def test_upload_all_precheck_must_rebase(
        self,
        mockAskForData,
        mockIsAncestor,
        mockGetBranchRef,
        mockGetCommonAncestorWithUpstream,
        mockFetchUpstreamTuple,
        mockGitGetBranchConfigValue,
        mockRunGitSilent,
        mockRunGit,
        *_mocks,
    ):
        branches = ["current", "upstream3"]
        mockGetBranchRef.side_effect = ["refs/heads/%s" % b for b in branches]
        mockGetCommonAncestorWithUpstream.return_value = "commit3.5"

        mockFetchUpstreamTuple.return_value = (".", "refs/heads/upstream3")

        # end commits
        mockRunGit.return_value = "commit4"
        mockRunGitSilent.return_value = "42"

        # Get gerrit squash hash. We only check this for branches that have a
        # diff. Set to None to trigger `must_upload_upstream`.
        mockGitGetBranchConfigValue.return_value = None

        # Case 5: current's base_commit is behind upstream3's last_upload.
        key = f"branch.upstream3.{git_cl.LAST_UPLOAD_HASH_CONFIG_KEY}"
        scm.GIT.SetConfig("", key, "commit3.7")
        mockIsAncestor.side_effect = [False, True]
        with self.assertRaises(SystemExitMock):
            options = optparse.Values()
            options.force = False
            options.cherry_pick_stacked = False
            git_cl._UploadAllPrecheck(options, [])

    @mock.patch(
        "git_cl.Changelist._GerritCommitMsgHookCheck",
        lambda offer_removal: None,
    )
    @mock.patch("git_cl.RunGit")
    @mock.patch("git_cl.RunGitSilent")
    @mock.patch("git_cl.Changelist._GitGetBranchConfigValue")
    @mock.patch("git_cl.Changelist.FetchUpstreamTuple")
    @mock.patch("git_cl.Changelist.GetCommonAncestorWithUpstream")
    @mock.patch("scm.GIT.GetBranchRef")
    @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,
        mockGetBranchRef,
        mockGetCommonAncestorWithUpstream,
        mockFetchUpstreamTuple,
        mockGitGetBranchConfigValue,
        mockRunGitSilent,
        mockRunGit,
        *_mocks,
    ):

        options = optparse.Values()
        options.force = False
        options.cherry_pick_stacked = False
        orig_args = ["--preserve-tryjobs", "--chicken"]

        mockGetRemoteBranch.return_value = (
            "origin",
            "refs/remotes/origin/main",
        )
        branches = ["current", "upstream3", "main"]
        mockGetBranchRef.side_effect = (
            ["refs/heads/current"]  # detached HEAD check
            + ["refs/heads/%s" % b for b in branches]
        )

        mockGetCommonAncestorWithUpstream.side_effect = [
            "commit3.5",
            "commit0.5",
        ]
        mockFetchUpstreamTuple.side_effect = [
            (".", "refs/heads/upstream3"),
            ("origin", "refs/heads/main"),
        ]
        mockIsAncestor.return_value = True

        # Give upstream3 a last upload hash
        key = f"branch.upstream3.{git_cl.LAST_UPLOAD_HASH_CONFIG_KEY}"
        scm.GIT.SetConfig("", key, "commit3.4")

        # end commits
        mockRunGit.return_value = "commit4"
        mockRunGitSilent.return_value = "42"

        # Get gerrit squash hash. We only check this for branches that have a
        # diff.
        mockGitGetBranchConfigValue.return_value = "just needs to exist"

        # Test case: user cherry picks with options
        options.cherry_pick_stacked = True
        # Reset side_effects
        mockGetBranchRef.side_effect = (
            ["refs/heads/current"]  # detached HEAD check
            + ["refs/heads/%s" % b for b in branches]
        )
        mockGetCommonAncestorWithUpstream.side_effect = [
            "commit3.5",
            "commit0.5",
        ]
        mockFetchUpstreamTuple.side_effect = [
            (".", "refs/heads/upstream3"),
            ("origin", "refs/heads/main"),
        ]
        cls, cherry_pick = git_cl._UploadAllPrecheck(options, orig_args)
        self.assertTrue(cherry_pick)
        self.assertEqual(len(cls), 2)
        mockAskForData.assert_not_called()

        # Test case: user uses force, no cherry-pick.
        options.cherry_pick_stacked = False
        options.force = True
        # Reset side_effects
        mockGetBranchRef.side_effect = (
            ["refs/heads/current"]  # detached HEAD check
            + ["refs/heads/%s" % b for b in branches]
        )
        mockGetCommonAncestorWithUpstream.side_effect = [
            "commit3.5",
            "commit0.5",
        ]
        mockFetchUpstreamTuple.side_effect = [
            (".", "refs/heads/upstream3"),
            ("origin", "refs/heads/main"),
        ]
        cls, cherry_pick = git_cl._UploadAllPrecheck(options, orig_args)
        self.assertFalse(cherry_pick)
        self.assertEqual(len(cls), 2)
        mockAskForData.assert_not_called()

        # Test case: user wants to cherry pick after being asked.
        mockAskForData.return_value = "n"
        options.cherry_pick_stacked = False
        options.force = False
        # Reset side_effects
        mockGetBranchRef.side_effect = (
            ["refs/heads/current"]  # detached HEAD check
            + ["refs/heads/%s" % b for b in branches]
        )
        mockGetCommonAncestorWithUpstream.side_effect = [
            "commit3.5",
            "commit0.5",
        ]
        mockFetchUpstreamTuple.side_effect = [
            (".", "refs/heads/upstream3"),
            ("origin", "refs/heads/main"),
        ]
        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 current, upstream3.\n"
            "Or type `n` to upload only `current` cherry-picked on upstream3's "
            "last upload:"
        )

    @mock.patch(
        "git_cl.Changelist._GerritCommitMsgHookCheck",
        lambda offer_removal: None,
    )
    @mock.patch("git_cl.RunGit")
    @mock.patch("git_cl.RunGitSilent")
    @mock.patch("git_cl.Changelist._GitGetBranchConfigValue")
    @mock.patch("git_cl.Changelist.FetchUpstreamTuple")
    @mock.patch("git_cl.Changelist.GetCommonAncestorWithUpstream")
    @mock.patch("scm.GIT.GetBranchRef")
    @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,
        mockGetBranchRef,
        mockGetCommonAncestorWithUpstream,
        mockFetchUpstreamTuple,
        mockGitGetBranchConfigValue,
        mockRunGitSilent,
        mockRunGit,
        *_mocks,
    ):

        options = optparse.Values()
        options.force = False
        options.cherry_pick_stacked = False
        orig_args = ["--preserve-tryjobs", "--chicken"]

        mockGetRemoteBranch.return_value = (
            "origin",
            "refs/remotes/origin/main",
        )
        mockGetBranchRef.side_effect = [
            "refs/heads/current",  # detached HEAD check
            "refs/heads/current",  # call within while loop
            "refs/heads/main",
            "refs/heads/main",
        ]
        mockGetCommonAncestorWithUpstream.return_value = "commit3.5"
        mockFetchUpstreamTuple.return_value = ("", "refs/heads/main")
        mockIsAncestor.return_value = True

        # end commits
        mockRunGit.return_value = "commit4"
        mockRunGitSilent.return_value = "42"

        # Get gerrit squash hash. We only check this for branches that have a
        # diff. Set to None to trigger `must_upload_upstream`.
        mockGitGetBranchConfigValue.return_value = "does not matter"

        # 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()

        # No diff for current change
        mockRunGitSilent.return_value = "0"
        with self.assertRaises(SystemExitMock):
            git_cl._UploadAllPrecheck(options, orig_args)

    @mock.patch("scm.GIT.GetBranchRef", return_value=None)
    def test_upload_all_precheck_detached_HEAD(self, mockGetBranchRef):

        with self.assertRaises(SystemExitMock):
            git_cl._UploadAllPrecheck(optparse.Values(), [])

    @mock.patch("git_cl.RunGit")
    @mock.patch("git_cl.CMDupload")
    @mock.patch("sys.stdin", io.StringIO("\n"))
    @mock.patch("sys.stdout", io.StringIO())
    def test_upload_branch_deps(self, *_mocks):
        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)

        git_cl.RunGit.side_effect = mock_run_git
        git_cl.CMDupload.return_value = 0

        class MockChangelist:
            def __init__(self):
                pass

            def GetBranch(self):
                return "test1"

            def GetIssue(self):
                return "123"

            def GetPatchset(self):
                return "1001"

            def IsGerrit(self):
                return False

        ret = git_cl.upload_branch_deps(MockChangelist(), [])
        # CMDupload should have been called 5 times because of 5 dependent
        # branches.
        self.assertEqual(5, len(git_cl.CMDupload.mock_calls))
        self.assertEqual(0, ret)

    def test_gerrit_change_id(self):
        self.calls = [
            ((["git", "write-tree"],), "hashtree"),
            ((["git", "rev-parse", "HEAD~0"],), "branch-parent"),
            (
                (["git", "var", "GIT_AUTHOR_IDENT"],),
                "A B <a@b.org> 1456848326 +0100",
            ),
            (
                (["git", "var", "GIT_COMMITTER_IDENT"],),
                "C D <c@d.org> 1456858326 +0100",
            ),
        ]
        change_id = git_cl.GenerateGerritChangeId("line1\nline2\n")
        self.assertEqual(change_id, "Ied144e9f47cb1522a09e2bbf4c4824255973ff53")

    def test_gerrit_change_id_initial_commit(self):
        """Tests Change-Id generation when HEAD~0 does not exist (root commit)."""
        self.calls = [
            ((["git", "write-tree"],), "hashtree"),
            ((["git", "rev-parse", "HEAD~0"],), CERR1),
            (
                (["git", "var", "GIT_AUTHOR_IDENT"],),
                "A B <a@b.org> 1456848326 +0100",
            ),
            (
                (["git", "var", "GIT_COMMITTER_IDENT"],),
                "C D <c@d.org> 1456858326 +0100",
            ),
        ]
        change_id = git_cl.GenerateGerritChangeId("initial commit\n")
        self.assertEqual(change_id, "I35c843e461cacf1a8a20af8f6c7307bc221886d8")

    def test_gerrit_change_id_utf8_multibyte(self):
        """Tests that multibyte UTF-8 messages use exact byte-length in the commit header."""
        self.calls = [
            ((["git", "write-tree"],), "hashtree"),
            ((["git", "rev-parse", "HEAD~0"],), "branch-parent"),
            (
                (["git", "var", "GIT_AUTHOR_IDENT"],),
                "A B <a@b.org> 1456848326 +0100",
            ),
            (
                (["git", "var", "GIT_COMMITTER_IDENT"],),
                "C D <c@d.org> 1456858326 +0100",
            ),
        ]
        msg = "Feature with emoji 🚀 and accents: café\n"
        change_id = git_cl.GenerateGerritChangeId(msg)
        self.assertEqual(change_id, "Ibc994371bc1b328858a1dcc1dfcf48e9a5a02f74")

    def test_gerrit_change_id_sha256(self):
        """Tests that 64-character tree hashes automatically use SHA-256."""
        sha256_tree = (
            "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
        )
        self.calls = [
            ((["git", "write-tree"],), sha256_tree),
            ((["git", "rev-parse", "HEAD~0"],), "branch-parent"),
            (
                (["git", "var", "GIT_AUTHOR_IDENT"],),
                "A B <a@b.org> 1456848326 +0100",
            ),
            (
                (["git", "var", "GIT_COMMITTER_IDENT"],),
                "C D <c@d.org> 1456858326 +0100",
            ),
        ]
        change_id = git_cl.GenerateGerritChangeId("line1\nline2\n")
        self.assertEqual(
            change_id,
            "I102fa6c23d397531ba7821e07a2ab103a1125f6c58e1d5afc1c1306e0c9b4001",
        )

    @mock.patch("gerrit_util.IsCodeOwnersEnabledOnHost")
    @mock.patch("git_cl.Settings.GetBugPrefix")
    @mock.patch("git_cl.Changelist.FetchDescription")
    @mock.patch("git_cl.Changelist.GetBranch")
    @mock.patch("git_cl.Changelist.GetCommonAncestorWithUpstream")
    @mock.patch("git_cl.Changelist.GetGerritHost")
    @mock.patch("git_cl.Changelist.GetGerritProject")
    @mock.patch("git_cl.Changelist.GetRemoteBranch")
    @mock.patch("owners_client.OwnersClient.BatchListOwners")
    def getDescriptionForUploadTest(
        self,
        mockBatchListOwners=None,
        mockGetRemoteBranch=None,
        mockGetGerritProject=None,
        mockGetGerritHost=None,
        mockGetCommonAncestorWithUpstream=None,
        mockGetBranch=None,
        mockFetchDescription=None,
        mockGetBugPrefix=None,
        mockIsCodeOwnersEnabledOnHost=None,
        initial_description="desc",
        commit_description=None,
        bug=None,
        fixed=None,
        branch="branch",
        reviewers=None,
        add_owners_to=None,
        expected_description="desc",
    ):
        reviewers = reviewers or []
        owners_by_path = {
            "a": ["a@example.com"],
            "b": ["b@example.com"],
            "c": ["c@example.com"],
        }
        mockIsCodeOwnersEnabledOnHost.return_value = True
        mockGetBranch.return_value = branch
        mockGetBugPrefix.return_value = "prefix"
        mockGetCommonAncestorWithUpstream.return_value = "upstream"
        mockGetRemoteBranch.return_value = (
            "origin",
            "refs/remotes/origin/main",
        )
        mockFetchDescription.return_value = "desc"
        mockBatchListOwners.side_effect = lambda ps: {
            p: owners_by_path.get(p) for p in ps
        }

        cl = git_cl.Changelist(issue=1234)
        actual = cl._GetDescriptionForUpload(
            options=mock.Mock(
                bug=bug,
                fixed=fixed,
                reviewers=reviewers,
                add_owners_to=add_owners_to,
                message=initial_description,
                commit_description=commit_description,
            ),
            git_diff_args=None,
            files=list(owners_by_path),
        )
        self.assertEqual(expected_description, actual.description)

    def testGetDescriptionForUpload(self):
        self.getDescriptionForUploadTest()

    def testGetDescriptionForUpload_Bug(self):
        self.getDescriptionForUploadTest(
            bug="1234",
            expected_description="\n".join(
                [
                    "desc",
                    "",
                    "Bug: prefix:1234",
                ]
            ),
        )

    def testGetDescriptionForUpload_Fixed(self):
        self.getDescriptionForUploadTest(
            fixed="1234",
            expected_description="\n".join(
                [
                    "desc",
                    "",
                    "Fixed: prefix:1234",
                ]
            ),
        )

    @mock.patch("git_cl.Changelist.GetIssue")
    def testGetDescriptionForUpload_BugFromBranch(self, mockGetIssue):
        mockGetIssue.return_value = None
        self.getDescriptionForUploadTest(
            branch="bug-1234",
            expected_description="\n".join(
                [
                    "desc",
                    "",
                    "Bug: prefix:1234",
                ]
            ),
        )

    @mock.patch("git_cl.Changelist.GetIssue")
    def testGetDescriptionForUpload_FixedFromBranch(self, mockGetIssue):
        mockGetIssue.return_value = None
        self.getDescriptionForUploadTest(
            branch="fix-1234",
            expected_description="\n".join(
                [
                    "desc",
                    "",
                    "Fixed: prefix:1234",
                ]
            ),
        )

    def testGetDescriptionForUpload_SkipBugFromBranchIfAlreadyUploaded(self):
        self.getDescriptionForUploadTest(
            branch="bug-1234",
            expected_description="desc",
        )

    def testGetDescriptionForUpload_AddOwnersToR(self):
        self.getDescriptionForUploadTest(
            reviewers=["a@example.com"],
            add_owners_to="R",
            expected_description="\n".join(
                [
                    "desc",
                    "",
                    "R=a@example.com, b@example.com, c@example.com",
                ]
            ),
        )

    def testGetDescriptionForUpload_AddOwnersToNoOwnersNeeded(self):
        self.getDescriptionForUploadTest(
            reviewers=["a@example.com", "c@example.com"],
            expected_description="\n".join(
                [
                    "desc",
                    "",
                    "R=a@example.com, c@example.com",
                ]
            ),
        )

    def testGetDescriptionForUpload_Reviewers(self):
        self.getDescriptionForUploadTest(
            reviewers=["a@example.com", "b@example.com"],
            expected_description="\n".join(
                [
                    "desc",
                    "",
                    "R=a@example.com, b@example.com",
                ]
            ),
        )

    def testGetDescriptionForUpload_NewDesc(self):
        self.getDescriptionForUploadTest(
            commit_description="this is a new desc",
            expected_description="this is a new desc",
        )

    @mock.patch("sys.stdin", io.StringIO("this is a new desc"))
    def testGetDescriptionForUpload_NewDescFromStdin(self):
        self.getDescriptionForUploadTest(
            commit_description="-", expected_description="this is a new desc"
        )

    def test_description_append_footer(self):
        for init_desc, footer_line, expected_desc in [
            # Use unique desc first lines for easy test failure
            # identification.
            ("foo", "R=one", "foo\n\nR=one"),
            ("foo\n\nR=one", "BUG=", "foo\n\nR=one\nBUG="),
            (
                "foo\n\nR=one",
                "Change-Id: Ixx",
                "foo\n\nR=one\n\nChange-Id: Ixx",
            ),
            (
                "foo\n\nChange-Id: Ixx",
                "R=one",
                "foo\n\nR=one\n\nChange-Id: Ixx",
            ),
            (
                "foo\n\nR=one\n\nChange-Id: Ixx",
                "Foo-Bar: baz",
                "foo\n\nR=one\n\nChange-Id: Ixx\nFoo-Bar: baz",
            ),
            (
                "foo\n\nChange-Id: Ixx",
                "Foo-Bak: baz",
                "foo\n\nChange-Id: Ixx\nFoo-Bak: baz",
            ),
            ("foo", "Change-Id: Ixx", "foo\n\nChange-Id: Ixx"),
        ]:
            desc = git_cl.ChangeDescription(init_desc)
            desc.append_footer(footer_line)
            self.assertEqual(desc.description, expected_desc)

    def test_update_reviewers(self):
        data = [
            ("foo", [], "foo"),
            ("foo\nR=xx", [], "foo\nR=xx"),
            ("foo", ["a@c"], "foo\n\nR=a@c"),
            ("foo\nR=xx", ["a@c"], "foo\n\nR=a@c, xx"),
            ("foo\nBUG=", ["a@c"], "foo\nBUG=\nR=a@c"),
            ("foo\nR=xx\nR=bar", ["a@c"], "foo\n\nR=a@c, bar, xx"),
            ("foo", ["a@c", "b@c"], "foo\n\nR=a@c, b@c"),
            ("foo\nBar\n\nR=\nBUG=", ["c@c"], "foo\nBar\n\nR=c@c\nBUG="),
            ("foo\nBar\n\nR=\nBUG=\nR=", ["c@c"], "foo\nBar\n\nR=c@c\nBUG="),
            # Same as the line before, but full of whitespaces.
            (
                "foo\nBar\n\n R = \n BUG = \n R = ",
                ["c@c"],
                "foo\nBar\n\nR=c@c\n BUG =",
            ),
            # Whitespaces aren't interpreted as new lines.
            ("foo BUG=allo R=joe ", ["c@c"], "foo BUG=allo R=joe\n\nR=c@c"),
        ]
        expected = [i[-1] for i in data]
        actual = []
        for orig, reviewers, _expected in data:
            obj = git_cl.ChangeDescription(orig)
            obj.update_reviewers(reviewers)
            actual.append(obj.description)
        self.assertEqual(expected, actual)

    def test_get_hash_tags(self):
        cases = [
            ("", []),
            ("a", []),
            ("[a]", ["a"]),
            ("[aa]", ["aa"]),
            ("[a ]", ["a"]),
            ("[a- ]", ["a"]),
            ("[a- b]", ["a-b"]),
            ("[a--b]", ["a-b"]),
            ("[a", []),
            ("[a]x", ["a"]),
            ("[aa]x", ["aa"]),
            ("[a b]", ["a-b"]),
            ("[a  b]", ["a-b"]),
            ("[a__b]", ["a-b"]),
            ("[a] x", ["a"]),
            ("[a][b]", ["a", "b"]),
            ("[a] [b]", ["a", "b"]),
            ("[a][b]x", ["a", "b"]),
            ("[a][b] x", ["a", "b"]),
            ("[a]\n[b]", ["a"]),
            ("[a\nb]", []),
            ("[a][", ["a"]),
            ('Revert "[a] feature"', ["a"]),
            ('Reland "[a] feature"', ["a"]),
            ("Revert: [a] feature", ["a"]),
            ("Reland: [a] feature", ["a"]),
            ('Revert "Reland: [a] feature"', ["a"]),
            ("Foo: feature", ["foo"]),
            ("Foo Bar: feature", ["foo-bar"]),
            ("Change Foo::Bar", []),
            ("Foo: Change Foo::Bar", ["foo"]),
            ('Revert "Foo bar: feature"', ["foo-bar"]),
            ('Reland "Foo bar: feature"', ["foo-bar"]),
        ]
        for desc, expected in cases:
            change_desc = git_cl.ChangeDescription(desc)
            actual = change_desc.get_hash_tags()
            self.assertEqual(
                actual,
                expected,
                "GetHashTags(%r) == %r, expected %r" % (desc, actual, expected),
            )

        self.assertEqual(None, git_cl.GetTargetRef("origin", None, "main"))
        self.assertEqual(
            None, git_cl.GetTargetRef(None, "refs/remotes/origin/main", "main")
        )

        # Check default target refs for branches.
        self.assertEqual(
            "refs/heads/main",
            git_cl.GetTargetRef("origin", "refs/remotes/origin/main", None),
        )
        self.assertEqual(
            "refs/heads/main",
            git_cl.GetTargetRef("origin", "refs/remotes/origin/lkgr", None),
        )
        self.assertEqual(
            "refs/heads/main",
            git_cl.GetTargetRef("origin", "refs/remotes/origin/lkcr", None),
        )
        self.assertEqual(
            "refs/branch-heads/123",
            git_cl.GetTargetRef(
                "origin", "refs/remotes/branch-heads/123", None
            ),
        )
        self.assertEqual(
            "refs/diff/test",
            git_cl.GetTargetRef(
                "origin", "refs/remotes/origin/refs/diff/test", None
            ),
        )
        self.assertEqual(
            "refs/heads/chrome/m42",
            git_cl.GetTargetRef(
                "origin", "refs/remotes/origin/chrome/m42", None
            ),
        )

        # Check target refs for user-specified target branch.
        for branch in (
            "branch-heads/123",
            "remotes/branch-heads/123",
            "refs/remotes/branch-heads/123",
        ):
            self.assertEqual(
                "refs/branch-heads/123",
                git_cl.GetTargetRef(
                    "origin", "refs/remotes/origin/main", branch
                ),
            )
        for branch in (
            "origin/main",
            "remotes/origin/main",
            "refs/remotes/origin/main",
        ):
            self.assertEqual(
                "refs/heads/main",
                git_cl.GetTargetRef(
                    "origin", "refs/remotes/branch-heads/123", branch
                ),
            )
        for branch in ("main", "heads/main", "refs/heads/main"):
            self.assertEqual(
                "refs/heads/main",
                git_cl.GetTargetRef(
                    "origin", "refs/remotes/branch-heads/123", branch
                ),
            )

    @mock.patch("git_common.is_dirty_git_tree", return_value=True)
    def test_patch_when_dirty(self, *_mocks):
        # Patch when local tree is dirty.
        self.assertNotEqual(git_cl.main(["patch", "123456"]), 0)

    def assertIssueAndPatchset(
        self,
        branch="main",
        issue="123456",
        patchset="7",
        git_short_host="chromium",
    ):
        self.assertEqual(
            issue, scm.GIT.GetBranchConfig("", branch, "gerritissue")
        )
        self.assertEqual(
            patchset, scm.GIT.GetBranchConfig("", branch, "gerritpatchset")
        )
        self.assertEqual(
            "https://%s-review.googlesource.com" % git_short_host,
            scm.GIT.GetBranchConfig("", branch, "gerritserver"),
        )

    def _patch_common(self, git_short_host="chromium"):
        mock.patch("scm.GIT.ResolveCommit", return_value="deadbeef").start()
        scm.GIT.SetConfig(
            "",
            "remote.origin.url",
            f"https://{git_short_host}.googlesource.com/my/repo",
        )
        gerrit_util.GetChangeDetail.return_value = {
            "current_revision": "7777777777",
            "revisions": {
                "1111111111": {
                    "_number": 1,
                    "fetch": {
                        "http": {
                            "url": "https://%s.googlesource.com/my/repo"
                            % git_short_host,
                            "ref": "refs/changes/56/123456/1",
                        }
                    },
                },
                "7777777777": {
                    "_number": 7,
                    "fetch": {
                        "http": {
                            "url": "https://%s.googlesource.com/my/repo"
                            % git_short_host,
                            "ref": "refs/changes/56/123456/7",
                        }
                    },
                },
            },
        }

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_patch_gerrit_default(self):
        self._patch_common()
        self.calls += [
            (
                (
                    [
                        "git",
                        "fetch",
                        "https://chromium.googlesource.com/my/repo",
                        "refs/changes/56/123456/7",
                    ],
                ),
                "",
            ),
            ((["git", "cherry-pick", "FETCH_HEAD"],), ""),
        ]
        self.assertEqual(git_cl.main(["patch", "123456"]), 0)
        self.assertIssueAndPatchset()

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_patch_gerrit_reauthor(self):
        self._patch_common()
        self.calls += [
            (
                (
                    [
                        "git",
                        "fetch",
                        "https://chromium.googlesource.com/my/repo",
                        "refs/changes/56/123456/7",
                    ],
                ),
                "",
            ),
            ((["git", "cherry-pick", "FETCH_HEAD"],), ""),
            (
                (["git", "log", "-1", "--format=%B"],),
                "Auto-generated spans\n\nChange-Id: I2345\n",
            ),
            (
                (
                    [
                        "git",
                        "commit",
                        "--amend",
                        "--reset-author",
                        "-m",
                        "Auto-generated spans\n",
                    ],
                ),
                "",
            ),
        ]
        self.assertEqual(git_cl.main(["patch", "--reauthor", "123456"]), 0)
        self.assertIsNone(scm.GIT.GetBranchConfig("", "main", "gerritissue"))
        self.assertIsNone(scm.GIT.GetBranchConfig("", "main", "gerritpatchset"))

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_patch_gerrit_new_branch(self):
        self._patch_common()
        self.calls += [
            (
                (
                    [
                        "git",
                        "fetch",
                        "https://chromium.googlesource.com/my/repo",
                        "refs/changes/56/123456/7",
                    ],
                ),
                "",
            ),
            ((["git", "cherry-pick", "FETCH_HEAD"],), ""),
        ]
        self.assertEqual(git_cl.main(["patch", "-b", "feature", "123456"]), 0)
        self.assertIssueAndPatchset(branch="feature")

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_patch_gerrit_no_commit(self):
        self._patch_common()
        self.calls += [
            (
                (
                    [
                        "git",
                        "fetch",
                        "https://chromium.googlesource.com/my/repo",
                        "refs/changes/56/123456/7",
                    ],
                ),
                "",
            ),
            ((["git", "cherry-pick", "--no-commit", "FETCH_HEAD"],), ""),
        ]
        self.assertEqual(git_cl.main(["patch", "-n", "123456"]), 0)
        self.assertEqual(
            "123456", scm.GIT.GetBranchConfig("", "main", "gerritissue")
        )
        self.assertEqual(
            "7", scm.GIT.GetBranchConfig("", "main", "gerritpatchset")
        )
        # last-upload-hash should NOT be set.
        self.assertIsNone(
            scm.GIT.GetBranchConfig("", "main", "last-upload-hash")
        )
        self.assertIsNone(
            scm.GIT.GetBranchConfig("", "main", "gerrit-squash-hash")
        )

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_patch_gerrit_force(self):
        self._patch_common("host")
        self.calls += [
            (
                (
                    [
                        "git",
                        "fetch",
                        "https://host.googlesource.com/my/repo",
                        "refs/changes/56/123456/7",
                    ],
                ),
                "",
            ),
            ((["git", "reset", "--hard", "FETCH_HEAD"],), ""),
        ]
        self.assertEqual(git_cl.main(["patch", "123456", "--force"]), 0)
        self.assertIssueAndPatchset(git_short_host="host")

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_patch_gerrit_new_branch_fails(self):
        self._patch_common()
        with mock.patch("git_new_branch.create_new_branch", return_value=1):
            self.assertEqual(
                git_cl.main(["patch", "-b", "feature", "123456"]), 1
            )

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_patch_gerrit_guess_by_url(self):
        self._patch_common("else")
        self.calls += [
            (
                (
                    [
                        "git",
                        "fetch",
                        "https://else.googlesource.com/my/repo",
                        "refs/changes/56/123456/1",
                    ],
                ),
                "",
            ),
            ((["git", "cherry-pick", "FETCH_HEAD"],), ""),
        ]
        self.assertEqual(
            git_cl.main(
                ["patch", "https://else-review.googlesource.com/#/c/123456/1"]
            ),
            0,
        )
        self.assertIssueAndPatchset(patchset="1", git_short_host="else")

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_patch_gerrit_guess_by_url_with_repo(self):
        self._patch_common("else")
        self.calls += [
            (
                (
                    [
                        "git",
                        "fetch",
                        "https://else.googlesource.com/my/repo",
                        "refs/changes/56/123456/1",
                    ],
                ),
                "",
            ),
            ((["git", "cherry-pick", "FETCH_HEAD"],), ""),
        ]
        self.assertEqual(
            git_cl.main(
                [
                    "patch",
                    "https://else-review.googlesource.com/c/my/repo/+/123456/1",
                ]
            ),
            0,
        )
        self.assertIssueAndPatchset(patchset="1", git_short_host="else")

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    @mock.patch("sys.stderr", io.StringIO())
    def test_patch_gerrit_conflict(self):
        self._patch_common()
        self.calls += [
            (
                (
                    [
                        "git",
                        "fetch",
                        "https://chromium.googlesource.com/my/repo",
                        "refs/changes/56/123456/7",
                    ],
                ),
                "",
            ),
            ((["git", "cherry-pick", "FETCH_HEAD"],), CERR1),
        ]
        with self.assertRaises(SystemExitMock):
            git_cl.main(["patch", "123456"])
        self.assertEqual(
            'Command "git cherry-pick FETCH_HEAD" failed.\n\n',
            sys.stderr.getvalue(),
        )

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    @mock.patch(
        "gerrit_util.GetChangeDetail",
        side_effect=gerrit_util.GerritError(404, ""),
    )
    @mock.patch("sys.stderr", io.StringIO())
    def test_patch_gerrit_not_exists(self, *_mocks):
        scm.GIT.SetConfig(
            "", "remote.origin.url", "https://chromium.googlesource.com/my/repo"
        )
        with self.assertRaises(SystemExitMock):
            self.assertEqual(1, git_cl.main(["patch", "123456"]))
        self.assertEqual(
            "change 123456 at https://chromium-review.googlesource.com does not "
            "exist or you have no access to it\n",
            sys.stderr.getvalue(),
        )

    def _checkout_config(self):
        scm.GIT.SetConfig("", "branch.ger-branch.gerritissue", "123456")
        scm.GIT.SetConfig("", "branch.gbranch654.gerritissue", "654321")

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_checkout_gerrit(self):
        """Tests git cl checkout <issue>."""
        self._checkout_config()
        self.calls += [((["git", "checkout", "ger-branch"],), "")]
        self.assertEqual(0, git_cl.main(["checkout", "123456"]))

    def test_checkout_not_found(self):
        """Tests git cl checkout <issue>."""
        self._checkout_config()
        self.assertEqual(1, git_cl.main(["checkout", "99999"]))

    def test_checkout_no_branch_issues(self):
        """Tests git cl checkout <issue>."""
        self.assertEqual(1, git_cl.main(["checkout", "99999"]))

    def _add_patch_with_cleanup(self, *args):
        """Creates a mock.patch(*args), starts it and register its cleanup."""
        patcher = mock.patch(*args)
        patcher.start()
        self.addCleanup(patcher.stop)

    def _setup_mock_for_cookies_authenticator(self, creds):
        """Sets up mocks for CookiesAuthenticator, and returns a git_cl.Changelist() for testing."""

        self._add_patch_with_cleanup(
            "gclient_utils.AskForData",
            lambda prompt: self._mocked_call("ask_for_data", prompt),
        )

        cookies_authenticator_factory = CookiesAuthenticatorMockFactory(
            hosts_with_creds=creds
        )

        self._add_patch_with_cleanup(
            "git_cl.gerrit_util.CookiesAuthenticator",
            cookies_authenticator_factory,
        )

        # Mocks _Authenticator.get as well, because it returns a ChainedAuthenticator by default.
        self._add_patch_with_cleanup(
            "git_cl.gerrit_util._Authenticator.get",
            cookies_authenticator_factory,
        )

        cl = git_cl.Changelist()
        cl.branch = "main"
        cl.branchref = "refs/heads/main"
        return cl

    def test_gerrit_ensure_authenticated_ok(self):
        scm.GIT.SetConfig(
            "", "remote.origin.url", "https://chromium.googlesource.com/my/repo"
        )

        cl = self._setup_mock_for_cookies_authenticator(
            creds={
                "chromium.googlesource.com": ("git-same.example.com", "secret"),
                "chromium-review.googlesource.com": (
                    "git-same.example.com",
                    "secret",
                ),
            }
        )
        self.assertIsNone(cl.EnsureAuthenticated(force=False))

    def test_gerrit_ensure_authenticated_skipped(self):
        scm.GIT.SetConfig(
            "", "remote.origin.url", "https://chromium.googlesource.com/my/repo"
        )
        scm.GIT.SetConfig("", "gerrit.skip-ensure-authenticated", "true")

        cl = self._setup_mock_for_cookies_authenticator(creds={})
        self.assertIsNone(cl.EnsureAuthenticated(force=False))

    def test_gerrit_ensure_authenticated_sso(self):
        scm.GIT.SetConfig("", "remote.origin.url", "sso://repo")

        cl = self._setup_mock_for_cookies_authenticator(creds={})
        cl.lookedup_issue = True
        self.assertIsNone(cl.EnsureAuthenticated(force=False))

    def test_gerrit_ensure_authenticated_bearer_token(self):
        scm.GIT.SetConfig(
            "", "remote.origin.url", "https://chromium.googlesource.com/my/repo"
        )

        cl = self._setup_mock_for_cookies_authenticator(
            creds={
                "chromium.googlesource.com": ("", "secret"),
                "chromium-review.googlesource.com": ("", "secret"),
            }
        )
        self.assertIsNone(cl.EnsureAuthenticated(force=False))
        conn = gerrit_util.HttpConn(
            req_uri="???",
            req_method="GET",
            req_host="chromium.googlesource.com",
            req_headers={},
            req_body=None,
        )
        gerrit_util.CookiesAuthenticator().authenticate(conn)
        self.assertIn("Authorization", conn.req_headers)
        self.assertIn("Bearer", conn.req_headers["Authorization"])

    def test_gerrit_ensure_authenticated_non_https_sso(self):
        scm.GIT.SetConfig("", "remote.origin.url", "custom-scheme://repo")
        self.calls = [
            (
                (
                    "logging.warning",
                    "Ignoring branch %(branch)s with non-https remote "
                    "%(remote)s",
                    {"branch": "main", "remote": "custom-scheme://repo"},
                ),
                None,
            ),
        ]
        mock.patch(
            "logging.warning",
            lambda *a: self._mocked_call("logging.warning", *a),
        ).start()

        cl = self._setup_mock_for_cookies_authenticator(creds={})
        cl.lookedup_issue = True
        self.assertIsNone(cl.EnsureAuthenticated(force=False))

    def test_gerrit_ensure_authenticated_non_url(self):
        scm.GIT.SetConfig(
            "", "remote.origin.url", "git@somehost.example:foo/bar.git"
        )
        self.calls = [
            (
                (
                    "logging.error",
                    'Remote "%(remote)s" for branch "%(branch)s" points to "%(url)s", '
                    "but it doesn't exist.",
                    {
                        "remote": "origin",
                        "branch": "main",
                        "url": "git@somehost.example:foo/bar.git",
                    },
                ),
                None,
            ),
        ]
        mock.patch(
            "logging.error", lambda *a: self._mocked_call("logging.error", *a)
        ).start()

        cl = self._setup_mock_for_cookies_authenticator(creds={})
        cl.lookedup_issue = True
        self.assertIsNone(cl.EnsureAuthenticated(force=False))

    @mock.patch("sys.stderr", io.StringIO())
    def test_gerrit_ensure_authenticated_with_reauth(self):
        scm.GIT.SetConfig(
            "", "remote.origin.url", "https://chromium.googlesource.com/my/repo"
        )

        self._add_patch_with_cleanup(
            "gclient_utils.AskForData",
            lambda prompt: self._mocked_call("ask_for_data", prompt),
        )

        mock_ensure_authenticated = mock.MagicMock(
            spec=gerrit_util.ensure_authenticated,
            return_value=(False, "You have not done ReAuth"),
        )
        self._add_patch_with_cleanup(
            "git_cl.gerrit_util.ensure_authenticated", mock_ensure_authenticated
        )

        cl = git_cl.Changelist()
        cl.branch = "main"
        cl.branchref = "refs/heads/main"

        with self.assertRaises(SystemExitMock):
            cl.EnsureAuthenticated(force=False)

        self.assertRegex(sys.stderr.getvalue(), "You have not done ReAuth")
        mock_ensure_authenticated.assert_called_with(
            gerrit_host="chromium-review.googlesource.com",
            git_host="chromium.googlesource.com",
            reauth_context=auth.ReAuthContext(
                host="chromium-review.googlesource.com", project="my/repo"
            ),
        )

    def test_gerrit_ensure_authenticated_reauth_not_needed(self):
        scm.GIT.SetConfig(
            "", "remote.origin.url", "https://chromium.googlesource.com/my/repo"
        )

        self._add_patch_with_cleanup(
            "gclient_utils.AskForData",
            lambda prompt: self._mocked_call("ask_for_data", prompt),
        )

        mock_ensure_authenticated = mock.MagicMock(
            spec=gerrit_util.ensure_authenticated, return_value=(True, "")
        )
        self._add_patch_with_cleanup(
            "git_cl.gerrit_util.ensure_authenticated", mock_ensure_authenticated
        )

        cl = git_cl.Changelist()
        cl.branch = "main"
        cl.branchref = "refs/heads/main"

        self.assertIsNone(
            cl.EnsureAuthenticated(force=False, skip_reauth_check=True)
        )
        mock_ensure_authenticated.assert_called_with(
            gerrit_host="chromium-review.googlesource.com",
            git_host="chromium.googlesource.com",
            reauth_context=None,
        )

    def _cmd_set_commit_gerrit_common(self, vote, notify=None):
        scm.GIT.SetConfig("", "branch.main.gerritissue", "123")
        scm.GIT.SetConfig(
            "",
            "branch.main.gerritserver",
            "https://chromium-review.googlesource.com",
        )
        scm.GIT.SetConfig(
            "",
            "remote.origin.url",
            "https://chromium.googlesource.com/infra/infra",
        )
        self.calls = [
            (
                (
                    "SetReview",
                    "chromium-review.googlesource.com",
                    "infra%2Finfra~123",
                    None,
                    {"Commit-Queue": vote},
                    notify,
                    None,
                    None,
                    "infra/infra",
                ),
                "",
            ),
        ]

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_cmd_set_commit_gerrit_clear(self):
        self._cmd_set_commit_gerrit_common(0)
        self.assertEqual(0, git_cl.main(["set-commit", "-c"]))

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_cmd_set_commit_gerrit_dry(self):
        self._cmd_set_commit_gerrit_common(1, notify=False)
        self.assertEqual(0, git_cl.main(["set-commit", "-d"]))

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_cmd_set_commit_gerrit(self):
        self._cmd_set_commit_gerrit_common(2)
        self.assertEqual(0, git_cl.main(["set-commit"]))

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_description_display(self):
        mock.patch("git_cl.Changelist", ChangelistMock).start()
        ChangelistMock.desc = "foo\n"

        self.assertEqual(0, git_cl.main(["description", "-d"]))
        self.assertEqual("foo\n", sys.stdout.getvalue())

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    @mock.patch("sys.stderr", io.StringIO())
    def test_StatusFieldOverrideIssueMissingArgs(self):
        try:
            self.assertEqual(git_cl.main(["status", "--issue", "1"]), 0)
        except SystemExitMock:
            self.assertIn(
                "--field must be given when --issue is set.",
                sys.stderr.getvalue(),
            )

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_StatusFieldOverrideIssue(self):
        def assertIssue(cl_self, *_args):
            self.assertEqual(cl_self.issue, 1)
            return "foobar"

        mock.patch("git_cl.Changelist.FetchDescription", assertIssue).start()
        self.assertEqual(
            git_cl.main(["status", "--issue", "1", "--field", "desc"]), 0
        )
        self.assertEqual(sys.stdout.getvalue(), "foobar\n")

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_SetCloseOverrideIssue(self):
        def assertIssue(cl_self, *_args):
            self.assertEqual(cl_self.issue, 1)
            return "foobar"

        mock.patch("git_cl.Changelist.FetchDescription", assertIssue).start()
        mock.patch("git_cl.Changelist.CloseIssue", lambda *_: None).start()
        self.assertEqual(git_cl.main(["set-close", "--issue", "1"]), 0)

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_description(self):
        scm.GIT.SetConfig(
            "", "remote.origin.url", "https://chromium.googlesource.com/my/repo"
        )
        gerrit_util.GetChangeDetail.return_value = {
            "current_revision": "sha1",
            "revisions": {
                "sha1": {
                    "commit": {"message": "foobar"},
                }
            },
        }
        self.assertEqual(
            0,
            git_cl.main(
                [
                    "description",
                    "https://chromium-review.googlesource.com/c/my/repo/+/123123",
                    "-d",
                ]
            ),
        )
        self.assertEqual("foobar\n", sys.stdout.getvalue())

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_description_set_raw(self):
        mock.patch("git_cl.Changelist", ChangelistMock).start()
        mock.patch("git_cl.sys.stdin", io.StringIO("hihi")).start()

        self.assertEqual(0, git_cl.main(["description", "-n", "hihi"]))
        self.assertEqual("hihi", ChangelistMock.desc)

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_description_appends_bug_line(self):
        current_desc = "Some.\n\nChange-Id: xxx"

        def RunEditor(desc, _, **kwargs):
            self.assertEqual(
                "# Enter a description of the change.\n"
                "# This will be displayed on the codereview site.\n"
                "# The first line will also be used as the subject of the review.\n"
                "#--------------------This line is 72 characters long"
                "--------------------\n"
                "Some.\n\nChange-Id: xxx\nBug: ",
                desc,
            )
            # Simulate user changing something.
            return "Some.\n\nChange-Id: xxx\nBug: 123"

        def UpdateDescription(_, desc, force=False):
            self.assertEqual(desc, "Some.\n\nChange-Id: xxx\nBug: 123")

        mock.patch(
            "git_cl.Changelist.FetchDescription", lambda *args: current_desc
        ).start()
        mock.patch(
            "git_cl.Changelist.UpdateDescription", UpdateDescription
        ).start()
        mock.patch("git_cl.gclient_utils.RunEditor", RunEditor).start()

        scm.GIT.SetConfig("", "branch.main.gerritissue", "123")
        self.assertEqual(0, git_cl.main(["description"]))

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_description_does_not_append_bug_line_if_fixed_is_present(self):
        current_desc = "Some.\n\nFixed: 123\nChange-Id: xxx"

        def RunEditor(desc, _, **kwargs):
            self.assertEqual(
                "# Enter a description of the change.\n"
                "# This will be displayed on the codereview site.\n"
                "# The first line will also be used as the subject of the review.\n"
                "#--------------------This line is 72 characters long"
                "--------------------\n"
                "Some.\n\nFixed: 123\nChange-Id: xxx",
                desc,
            )
            return desc

        mock.patch(
            "git_cl.Changelist.FetchDescription", lambda *args: current_desc
        ).start()
        mock.patch("git_cl.gclient_utils.RunEditor", RunEditor).start()

        scm.GIT.SetConfig("", "branch.main.gerritissue", "123")
        self.assertEqual(0, git_cl.main(["description"]))

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_description_set_stdin(self):
        mock.patch("git_cl.Changelist", ChangelistMock).start()
        mock.patch(
            "git_cl.sys.stdin", io.StringIO("hi \r\n\t there\n\nman")
        ).start()

        self.assertEqual(0, git_cl.main(["description", "-n", "-"]))
        self.assertEqual("hi\n\t there\n\nman", ChangelistMock.desc)

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_diff_with_files(self):
        mock.patch("git_common.current_branch", return_value="main").start()

        scm.GIT.SetConfig("", "branch.main.gerritissue", "123")
        scm.GIT.SetConfig("", "branch.main.last-upload-hash", "deadbeaf")

        self.calls = [
            ((["git", "diff", "deadbeaf", "file1"],), ""),
        ]
        self.assertEqual(0, git_cl.main(["diff", "file1"]))

        self.calls = [
            ((["git", "diff", "deadbeaf", "--", "file1"],), ""),
        ]
        self.assertEqual(0, git_cl.main(["diff", "--", "file1"]))

        with mock.patch(
            "git_cl.OptionParser.error", side_effect=ParserErrorMock
        ):
            with self.assertRaises(ParserErrorMock):
                git_cl.main(["diff", "a.txt", "--", "b.txt"])

        self.calls = [
            ((["git", "diff", "deadbeaf", "--", "a.txt", "b.txt"],), ""),
        ]
        self.assertEqual(0, git_cl.main(["diff", "--", "a.txt", "b.txt"]))

        self.calls = [
            (
                (
                    [
                        "git",
                        "diff",
                        "--stat",
                        "deadbeaf",
                        "--",
                        "a.txt",
                        "b.txt",
                    ],
                ),
                "",
            ),
        ]
        self.assertEqual(
            0, git_cl.main(["diff", "--stat", "--", "a.txt", "b.txt"])
        )

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_archive(self):
        self.calls = [
            (
                (["git", "for-each-ref", "--format=%(refname)", "refs/heads"],),
                "refs/heads/main\nrefs/heads/foo\nrefs/heads/bar",
            ),
            (
                (["git", "for-each-ref", "--format=%(refname)", "refs/tags"],),
                "",
            ),
            ((["git", "tag", "git-cl-archived-456-foo", "foo"],), ""),
            ((["git", "branch", "-D", "foo"],), ""),
        ]

        mock.patch(
            "git_cl.get_cl_statuses",
            lambda branches, fine_grained, max_processes: [
                (MockChangelistWithBranchAndIssue("main", 1), "open"),
                (MockChangelistWithBranchAndIssue("foo", 456), "closed"),
                (MockChangelistWithBranchAndIssue("bar", 789), "open"),
            ],
        ).start()

        self.assertEqual(0, git_cl.main(["archive", "-f"]))

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_archive_tag_collision(self):
        self.calls = [
            (
                (["git", "for-each-ref", "--format=%(refname)", "refs/heads"],),
                "refs/heads/main\nrefs/heads/foo\nrefs/heads/bar",
            ),
            (
                (["git", "for-each-ref", "--format=%(refname)", "refs/tags"],),
                "refs/tags/git-cl-archived-456-foo",
            ),
            ((["git", "tag", "git-cl-archived-456-foo-2", "foo"],), ""),
            ((["git", "branch", "-D", "foo"],), ""),
        ]

        mock.patch(
            "git_cl.get_cl_statuses",
            lambda branches, fine_grained, max_processes: [
                (MockChangelistWithBranchAndIssue("main", 1), "open"),
                (MockChangelistWithBranchAndIssue("foo", 456), "closed"),
                (MockChangelistWithBranchAndIssue("bar", 789), "open"),
            ],
        ).start()

        self.assertEqual(0, git_cl.main(["archive", "-f"]))

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_archive_current_branch_fails(self):
        self.calls = [
            (
                (["git", "for-each-ref", "--format=%(refname)", "refs/heads"],),
                "refs/heads/main",
            ),
            (
                (["git", "for-each-ref", "--format=%(refname)", "refs/tags"],),
                "",
            ),
        ]

        mock.patch(
            "git_cl.get_cl_statuses",
            lambda branches, fine_grained, max_processes: [
                (MockChangelistWithBranchAndIssue("main", 1), "closed")
            ],
        ).start()

        self.assertEqual(1, git_cl.main(["archive", "-f"]))

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_archive_dry_run(self):
        self.calls = [
            (
                (["git", "for-each-ref", "--format=%(refname)", "refs/heads"],),
                "refs/heads/main\nrefs/heads/foo\nrefs/heads/bar",
            ),
            (
                (["git", "for-each-ref", "--format=%(refname)", "refs/tags"],),
                "",
            ),
        ]

        mock.patch(
            "git_cl.get_cl_statuses",
            lambda branches, fine_grained, max_processes: [
                (MockChangelistWithBranchAndIssue("main", 1), "open"),
                (MockChangelistWithBranchAndIssue("foo", 456), "closed"),
                (MockChangelistWithBranchAndIssue("bar", 789), "open"),
            ],
        ).start()

        self.assertEqual(0, git_cl.main(["archive", "-f", "--dry-run"]))

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_archive_no_tags(self):
        self.calls = [
            (
                (["git", "for-each-ref", "--format=%(refname)", "refs/heads"],),
                "refs/heads/main\nrefs/heads/foo\nrefs/heads/bar",
            ),
            (
                (["git", "for-each-ref", "--format=%(refname)", "refs/tags"],),
                "",
            ),
            ((["git", "branch", "-D", "foo"],), ""),
        ]

        mock.patch(
            "git_cl.get_cl_statuses",
            lambda branches, fine_grained, max_processes: [
                (MockChangelistWithBranchAndIssue("main", 1), "open"),
                (MockChangelistWithBranchAndIssue("foo", 456), "closed"),
                (MockChangelistWithBranchAndIssue("bar", 789), "open"),
            ],
        ).start()

        self.assertEqual(0, git_cl.main(["archive", "-f", "--notags"]))

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_archive_tag_cleanup_on_branch_deletion_error(self):
        self.calls = [
            (
                (["git", "for-each-ref", "--format=%(refname)", "refs/heads"],),
                "refs/heads/main\nrefs/heads/foo\nrefs/heads/bar",
            ),
            (
                (["git", "for-each-ref", "--format=%(refname)", "refs/tags"],),
                "",
            ),
            (
                (["git", "tag", "git-cl-archived-456-foo", "foo"],),
                "refs/tags/git-cl-archived-456-foo",
            ),
            ((["git", "branch", "-D", "foo"],), CERR1),
            (
                (["git", "tag", "-d", "git-cl-archived-456-foo"],),
                "refs/tags/git-cl-archived-456-foo",
            ),
        ]

        mock.patch(
            "git_cl.get_cl_statuses",
            lambda branches, fine_grained, max_processes: [
                (MockChangelistWithBranchAndIssue("main", 1), "open"),
                (MockChangelistWithBranchAndIssue("foo", 456), "closed"),
                (MockChangelistWithBranchAndIssue("bar", 789), "open"),
            ],
        ).start()

        self.assertEqual(0, git_cl.main(["archive", "-f"]))

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_archive_with_format(self):
        self.calls = [
            (
                (["git", "for-each-ref", "--format=%(refname)", "refs/heads"],),
                "refs/heads/main\nrefs/heads/foo\nrefs/heads/bar",
            ),
            (
                (["git", "for-each-ref", "--format=%(refname)", "refs/tags"],),
                "",
            ),
            ((["git", "tag", "archived/12-foo", "foo"],), ""),
            ((["git", "branch", "-D", "foo"],), ""),
        ]

        mock.patch(
            "git_cl.get_cl_statuses",
            lambda branches, fine_grained, max_processes: [
                (MockChangelistWithBranchAndIssue("foo", 12), "closed")
            ],
        ).start()

        self.assertEqual(
            0, git_cl.main(["archive", "-f", "-p", "archived/{issue}-{branch}"])
        )

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_squash_closed(self):
        self.calls = [
            (
                (["git", "for-each-ref", "--format=%(refname)", "refs/heads"],),
                "refs/heads/main\nrefs/heads/foo\nrefs/heads/bar",
            ),
            ((["git", "checkout", "foo"],), ""),
            ((["git", "checkout", "main"],), ""),
        ]

        mock.patch(
            "git_cl.get_cl_statuses",
            lambda branches, fine_grained, max_processes: [
                (MockChangelistWithBranchAndIssue("main", 1), "open"),
                (MockChangelistWithBranchAndIssue("foo", 456), "closed"),
                (MockChangelistWithBranchAndIssue("bar", 789), "open"),
            ],
        ).start()
        mock.patch("git_common.current_branch", return_value="main").start()
        mock.patch("git_squash_branch.main", return_value=0).start()

        self.assertEqual(0, git_cl.main(["squash-closed", "-f"]))

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_squash_closed_dry_run(self):
        self.calls = [
            (
                (["git", "for-each-ref", "--format=%(refname)", "refs/heads"],),
                "refs/heads/main\nrefs/heads/foo\nrefs/heads/bar",
            ),
        ]

        mock.patch(
            "git_cl.get_cl_statuses",
            lambda branches, fine_grained, max_processes: [
                (MockChangelistWithBranchAndIssue("main", 1), "open"),
                (MockChangelistWithBranchAndIssue("foo", 456), "closed"),
                (MockChangelistWithBranchAndIssue("bar", 789), "open"),
            ],
        ).start()

        self.assertEqual(0, git_cl.main(["squash-closed", "-d"]))

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_squash_closed_current_branch_fails(self):
        self.calls = [
            (
                (["git", "for-each-ref", "--format=%(refname)", "refs/heads"],),
                "refs/heads/main\nrefs/heads/foo\nrefs/heads/bar",
            ),
        ]

        mock.patch(
            "git_cl.get_cl_statuses",
            lambda branches, fine_grained, max_processes: [
                (MockChangelistWithBranchAndIssue("main", 1), "closed"),
            ],
        ).start()

        self.assertEqual(1, git_cl.main(["squash-closed", "-f"]))

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_squash_closed_reset_on_failure(self):
        self.calls = [
            (
                (["git", "for-each-ref", "--format=%(refname)", "refs/heads"],),
                "refs/heads/main\nrefs/heads/foo\nrefs/heads/bar",
            ),
            ((["git", "checkout", "foo"],), ""),
            ((["git", "checkout", "main"],), ""),
        ]

        mock.patch(
            "git_cl.get_cl_statuses",
            lambda branches, fine_grained, max_processes: [
                (MockChangelistWithBranchAndIssue("main", 1), "open"),
                (MockChangelistWithBranchAndIssue("foo", 456), "closed"),
                (MockChangelistWithBranchAndIssue("bar", 789), "open"),
            ],
        ).start()
        mock.patch("git_common.current_branch", return_value="main").start()
        mock.patch("git_squash_branch.main", return_value=1).start()

        self.assertEqual(1, git_cl.main(["squash-closed", "-f"]))

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_squash_closed_clean_exit_no_closed_branches(self):
        self.calls = [
            (
                (["git", "for-each-ref", "--format=%(refname)", "refs/heads"],),
                "refs/heads/main\nrefs/heads/foo\nrefs/heads/bar",
            ),
        ]

        mock.patch(
            "git_cl.get_cl_statuses",
            lambda branches, fine_grained, max_processes: [
                (MockChangelistWithBranchAndIssue("main", 1), "open"),
                (MockChangelistWithBranchAndIssue("foo", 456), "open"),
                (MockChangelistWithBranchAndIssue("bar", 789), "open"),
            ],
        ).start()
        mock.patch("git_common.current_branch", return_value="main").start()
        mock.patch("git_squash_branch.main", return_value=0).start()

        self.assertEqual(0, git_cl.main(["squash-closed", "-f"]))

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_squash_closed_abort_on_dirty_tree(self):
        mock.patch("git_common.is_dirty_git_tree", return_value=True).start()
        self.assertEqual(1, git_cl.main(["squash-closed", "-f"]))

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_cmd_issue_erase_existing(self):
        scm.GIT.SetConfig("", "branch.main.gerritissue", "123")
        scm.GIT.SetConfig(
            "",
            "branch.main.gerritserver",
            "https://chromium-review.googlesource.com",
        )
        self.calls = [
            ((["git", "log", "-1", "--format=%B"],), "This is a description"),
        ]
        self.assertEqual(0, git_cl.main(["issue", "0"]))
        self.assertIsNone(scm.GIT.GetConfig("root", "branch.main.gerritissue"))
        self.assertIsNone(scm.GIT.GetConfig("root", "branch.main.gerritserver"))

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_cmd_issue_erase_existing_with_change_id(self):
        scm.GIT.SetConfig("", "branch.main.gerritissue", "123")
        scm.GIT.SetConfig(
            "",
            "branch.main.gerritserver",
            "https://chromium-review.googlesource.com",
        )
        mock.patch(
            "git_cl.Changelist.FetchDescription",
            lambda _: "This is a description\n\nChange-Id: Ideadbeef",
        ).start()
        self.calls = [
            (
                (["git", "log", "-1", "--format=%B"],),
                "This is a description\n\nChange-Id: Ideadbeef",
            ),
            (
                (
                    [
                        "git",
                        "commit",
                        "--amend",
                        "-m",
                        "This is a description\n",
                    ],
                ),
                "",
            ),
        ]
        self.assertEqual(0, git_cl.main(["issue", "0"]))
        self.assertIsNone(scm.GIT.GetConfig("root", "branch.main.gerritissue"))
        self.assertIsNone(scm.GIT.GetConfig("root", "branch.main.gerritserver"))

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_cmd_issue_json(self):
        scm.GIT.SetConfig("", "branch.main.gerritissue", "123")
        scm.GIT.SetConfig(
            "",
            "branch.main.gerritserver",
            "https://chromium-review.googlesource.com",
        )
        scm.GIT.SetConfig(
            "",
            "remote.origin.url",
            "https://chromium.googlesource.com/chromium/src",
        )
        self.calls = [
            (
                (
                    "write_json",
                    "output.json",
                    {
                        "issue": 123,
                        "issue_url": "https://chromium-review.googlesource.com/123",
                        "gerrit_host": "chromium-review.googlesource.com",
                        "gerrit_project": "chromium/src",
                    },
                ),
                "",
            )
        ]
        self.assertEqual(0, git_cl.main(["issue", "--json", "output.json"]))

    def _common_GerritCommitMsgHookCheck(self):
        mock.patch(
            "git_cl.os.path.abspath",
            lambda path: self._mocked_call(["abspath", path]),
        ).start()
        mock.patch(
            "git_cl.os.path.exists",
            lambda path: self._mocked_call(["exists", path]),
        ).start()
        mock.patch(
            "git_cl.gclient_utils.FileRead",
            lambda path: self._mocked_call(["FileRead", path]),
        ).start()
        mock.patch(
            "git_cl.gclient_utils.rm_file_or_tree",
            lambda path: self._mocked_call(["rm_file_or_tree", path]),
        ).start()
        mock.patch(
            "gclient_utils.AskForData",
            lambda prompt: self._mocked_call("ask_for_data", prompt),
        ).start()
        return git_cl.Changelist(issue=123)

    def test_GerritCommitMsgHookCheck_custom_hook(self):
        cl = self._common_GerritCommitMsgHookCheck()
        self.calls += [
            ((["exists", os.path.join(".git", "hooks", "commit-msg")],), True),
            (
                (["FileRead", os.path.join(".git", "hooks", "commit-msg")],),
                '#!/bin/sh\necho "custom hook"',
            ),
        ]
        cl._GerritCommitMsgHookCheck(offer_removal=True)

    def test_GerritCommitMsgHookCheck_not_exists(self):
        cl = self._common_GerritCommitMsgHookCheck()
        self.calls += [
            ((["exists", os.path.join(".git", "hooks", "commit-msg")],), False),
        ]
        cl._GerritCommitMsgHookCheck(offer_removal=True)

    def test_GerritCommitMsgHookCheck(self):
        cl = self._common_GerritCommitMsgHookCheck()
        self.calls += [
            ((["exists", os.path.join(".git", "hooks", "commit-msg")],), True),
            (
                (["FileRead", os.path.join(".git", "hooks", "commit-msg")],),
                "...\n# From Gerrit Code Review\n...\nadd_ChangeId()\n",
            ),
            (
                ("ask_for_data", "Do you want to remove it now? [Yes/No]: "),
                "Yes",
            ),
            (
                (
                    [
                        "rm_file_or_tree",
                        os.path.join(".git", "hooks", "commit-msg"),
                    ],
                ),
                "",
            ),
        ]
        cl._GerritCommitMsgHookCheck(offer_removal=True)

    def test_GerritCmdLand(self):
        scm.GIT.SetConfig("", "branch.main.gerritsquashhash", "deadbeaf")
        scm.GIT.SetConfig(
            "", "branch.main.gerritserver", "chromium-review.googlesource.com"
        )
        self.calls += [
            ((["git", "diff", "deadbeaf"],), ""),  # No diff.
        ]
        cl = git_cl.Changelist(issue=123)
        cl._GetChangeDetail = lambda *args, **kwargs: {
            "labels": {},
            "current_revision": "deadbeaf",
        }
        cl._GetChangeCommit = lambda: {
            "commit": "deadbeef",
            "web_links": [
                {
                    "name": "gitiles",
                    "url": "https://git.googlesource.com/test/+/deadbeef",
                }
            ],
        }
        cl.SubmitIssue = lambda: None
        self.assertEqual(
            0,
            cl.CMDLand(
                force=True,
                bypass_hooks=True,
                verbose=True,
                parallel=False,
                resultdb=False,
                realm=None,
            ),
        )
        self.assertIn(
            "Issue chromium-review.googlesource.com/123 has been submitted",
            sys.stdout.getvalue(),
        )
        self.assertIn(
            "Landed as: https://git.googlesource.com/test/+/deadbeef",
            sys.stdout.getvalue(),
        )

    def _mock_gerrit_changes_for_detail_cache(self):
        mock.patch("git_cl.Changelist.GetGerritHost", lambda _: "host").start()

    def test_gerrit_change_detail_cache_simple(self):
        self._mock_gerrit_changes_for_detail_cache()
        gerrit_util.GetChangeDetail.side_effect = ["a", "b"]
        cl1 = git_cl.Changelist(issue=1)
        cl1._cached_remote_url = (
            True,
            "https://chromium.googlesource.com/a/my/repo.git/",
        )
        cl2 = git_cl.Changelist(issue=2)
        cl2._cached_remote_url = (
            True,
            "https://chromium.googlesource.com/ab/repo",
        )
        self.assertEqual(cl1._GetChangeDetail(), "a")  # Miss.
        self.assertEqual(cl1._GetChangeDetail(), "a")
        self.assertEqual(cl2._GetChangeDetail(), "b")  # Miss.

    def test_gerrit_change_detail_cache_options(self):
        self._mock_gerrit_changes_for_detail_cache()
        gerrit_util.GetChangeDetail.side_effect = ["cab", "ad"]
        cl = git_cl.Changelist(issue=1)
        cl._cached_remote_url = (
            True,
            "https://chromium.googlesource.com/repo/",
        )
        self.assertEqual(cl._GetChangeDetail(options=["C", "A", "B"]), "cab")
        self.assertEqual(cl._GetChangeDetail(options=["A", "B", "C"]), "cab")
        self.assertEqual(cl._GetChangeDetail(options=["B", "A"]), "cab")
        self.assertEqual(cl._GetChangeDetail(options=["C"]), "cab")
        self.assertEqual(cl._GetChangeDetail(options=["A"]), "cab")
        self.assertEqual(cl._GetChangeDetail(), "cab")

        self.assertEqual(cl._GetChangeDetail(options=["A", "D"]), "ad")
        self.assertEqual(cl._GetChangeDetail(options=["A"]), "cab")
        self.assertEqual(cl._GetChangeDetail(options=["D"]), "ad")
        self.assertEqual(cl._GetChangeDetail(), "cab")

    def test_gerrit_description_caching(self):
        gerrit_util.GetChangeDetail.return_value = {
            "current_revision": "rev1",
            "revisions": {
                "rev1": {"commit": {"message": "desc1"}},
            },
        }

        self._mock_gerrit_changes_for_detail_cache()
        cl = git_cl.Changelist(issue=1)
        cl._cached_remote_url = (
            True,
            "https://chromium.googlesource.com/a/my/repo.git/",
        )
        self.assertEqual(cl.FetchDescription(), "desc1")
        self.assertEqual(cl.FetchDescription(), "desc1")  # cache hit.

    def test_gerrit_async_warm_change_detail_no_issue(self):
        self._mock_gerrit_changes_for_detail_cache()
        cl = git_cl.Changelist()
        join_fn = cl.AsyncWarmChangeDetail()
        join_fn()
        gerrit_util.GetChangeDetail.assert_not_called()

    def test_gerrit_async_warm_change_detail_success(self):
        self._mock_gerrit_changes_for_detail_cache()
        gerrit_util.GetChangeDetail.return_value = {
            "current_revision": "rev1",
            "revisions": {
                "rev1": {"commit": {"message": "desc1"}},
            },
        }
        cl = git_cl.Changelist(issue=1)
        cl._cached_remote_url = (
            True,
            "https://chromium.googlesource.com/a/my/repo.git/",
        )
        join_fn = cl.AsyncWarmChangeDetail(options=["CURRENT_REVISION"])
        join_fn()
        # Ensure that GetChangeDetail was called once
        self.assertEqual(gerrit_util.GetChangeDetail.call_count, 1)
        # Subsequent sync call hits cache without invoking GetChangeDetail again
        detail = cl._GetChangeDetail(options=["CURRENT_REVISION"])
        self.assertEqual(detail["current_revision"], "rev1")
        self.assertEqual(gerrit_util.GetChangeDetail.call_count, 1)

    def test_gerrit_async_warm_change_detail_empty_options(self):
        self._mock_gerrit_changes_for_detail_cache()
        gerrit_util.GetChangeDetail.return_value = {"id": "123"}
        cl = git_cl.Changelist(issue=1)
        cl._cached_remote_url = (
            True,
            "https://chromium.googlesource.com/a/my/repo.git/",
        )
        join_fn = cl.AsyncWarmChangeDetail(options=[])
        join_fn()
        gerrit_util.GetChangeDetail.assert_called_once_with(
            "host", "my%2Frepo~1", frozenset()
        )

    def test_gerrit_async_warm_change_detail_already_cached(self):
        self._mock_gerrit_changes_for_detail_cache()
        gerrit_util.GetChangeDetail.return_value = {"id": "123"}
        cl = git_cl.Changelist(issue=1)
        cl._cached_remote_url = (
            True,
            "https://chromium.googlesource.com/a/my/repo.git/",
        )
        cl._GetChangeDetail(options=["DETAILED_ACCOUNTS", "CURRENT_REVISION"])
        self.assertEqual(gerrit_util.GetChangeDetail.call_count, 1)
        # Async warming for a subset of cached options is a no-op
        join_fn = cl.AsyncWarmChangeDetail(options=["CURRENT_REVISION"])
        join_fn()
        self.assertEqual(gerrit_util.GetChangeDetail.call_count, 1)

    def test_gerrit_async_warm_change_detail_error_propagation(self):
        self._mock_gerrit_changes_for_detail_cache()
        gerrit_util.GetChangeDetail.side_effect = gerrit_util.GerritError(
            404, "Not Found"
        )
        cl = git_cl.Changelist(issue=999)
        cl._cached_remote_url = (
            True,
            "https://chromium.googlesource.com/a/my/repo.git/",
        )
        join_fn = cl.AsyncWarmChangeDetail(options=["CURRENT_REVISION"])
        with self.assertRaises(git_cl.GerritChangeNotExists):
            join_fn()

    def test_gerrit_async_warm_change_detail_cross_instance_sharing(self):
        self._mock_gerrit_changes_for_detail_cache()
        gerrit_util.GetChangeDetail.return_value = {
            "current_revision": "rev1",
            "revisions": {"rev1": {"commit": {"message": "desc1"}}},
        }
        cl1 = git_cl.Changelist(issue=100)
        cl1._cached_remote_url = (
            True,
            "https://chromium.googlesource.com/a/my/repo.git/",
        )
        join_fn = cl1.AsyncWarmChangeDetail(options=["CURRENT_REVISION"])
        join_fn()
        self.assertEqual(gerrit_util.GetChangeDetail.call_count, 1)

        # A separate Changelist instance for the same issue gets a cache hit
        cl2 = git_cl.Changelist(issue=100)
        cl2._cached_remote_url = (
            True,
            "https://chromium.googlesource.com/a/my/repo.git/",
        )
        detail = cl2._GetChangeDetail(options=["CURRENT_REVISION"])
        self.assertEqual(detail["current_revision"], "rev1")
        self.assertEqual(gerrit_util.GetChangeDetail.call_count, 1)

    def test_gerrit_async_warm_change_detail_superset_options_refetches(self):
        self._mock_gerrit_changes_for_detail_cache()
        gerrit_util.GetChangeDetail.side_effect = [
            {"current_revision": "rev1"},
            {"current_revision": "rev1", "messages": ["msg1"]},
        ]
        cl = git_cl.Changelist(issue=1)
        cl._cached_remote_url = (
            True,
            "https://chromium.googlesource.com/a/my/repo.git/",
        )
        join_fn = cl.AsyncWarmChangeDetail(options=["CURRENT_REVISION"])
        join_fn()
        self.assertEqual(gerrit_util.GetChangeDetail.call_count, 1)

        # Requesting superset options fetches the broader set
        detail = cl._GetChangeDetail(options=["CURRENT_REVISION", "MESSAGES"])
        self.assertEqual(detail["messages"], ["msg1"])
        self.assertEqual(gerrit_util.GetChangeDetail.call_count, 2)

    def test_gerrit_async_warm_change_detail_retry_after_failure(self):
        self._mock_gerrit_changes_for_detail_cache()
        gerrit_util.GetChangeDetail.side_effect = [
            gerrit_util.GerritError(500, "Transient Server Error"),
            {"current_revision": "rev1"},
        ]
        cl = git_cl.Changelist(issue=1)
        cl._cached_remote_url = (
            True,
            "https://chromium.googlesource.com/a/my/repo.git/",
        )
        join_fn = cl.AsyncWarmChangeDetail(options=["CURRENT_REVISION"])
        with self.assertRaises(gerrit_util.GerritError):
            join_fn()

        # Subsequent call retries cleanly and succeeds
        detail = cl._GetChangeDetail(options=["CURRENT_REVISION"])
        self.assertEqual(detail["current_revision"], "rev1")
        self.assertEqual(gerrit_util.GetChangeDetail.call_count, 2)

    def test_gerrit_async_warm_change_detail_realistic_concurrency_barrier(
        self,
    ):
        self._mock_gerrit_changes_for_detail_cache()
        in_flight_event = threading.Event()
        release_event = threading.Event()

        def _mock_get_change_detail(host, change, options):
            in_flight_event.set()
            if not release_event.wait(timeout=5.0):
                raise RuntimeError("Timeout waiting for release_event")
            return {"status": "NEW", "options": list(options)}

        gerrit_util.GetChangeDetail.side_effect = _mock_get_change_detail

        cl = git_cl.Changelist(issue=1)
        cl._cached_remote_url = (
            True,
            "https://chromium.googlesource.com/a/my/repo.git/",
        )
        join_fn = cl.AsyncWarmChangeDetail(options=["CURRENT_REVISION"])

        self.assertTrue(
            in_flight_event.wait(timeout=5.0),
            "Background thread did not enter GetChangeDetail",
        )

        results = []
        barrier = threading.Barrier(5)

        def _worker():
            barrier.wait(timeout=5.0)
            results.append(cl._GetChangeDetail(options=["CURRENT_REVISION"]))

        threads = [threading.Thread(target=_worker) for _ in range(4)]
        for t in threads:
            t.start()

        barrier.wait(timeout=5.0)
        release_event.set()

        for t in threads:
            t.join(timeout=5.0)
        join_fn()

        self.assertEqual(len(results), 4)
        for res in results:
            self.assertEqual(res["status"], "NEW")
        self.assertEqual(gerrit_util.GetChangeDetail.call_count, 1)

    def test_gerrit_async_warm_change_detail_multi_host_isolation(self):
        gerrit_util.GetChangeDetail.side_effect = [
            {"host": "chromium"},
            {"host": "webrtc"},
        ]
        cl1 = git_cl.Changelist(
            issue=1, codereview_host="chromium-review.googlesource.com"
        )
        cl1._cached_remote_url = (
            True,
            "https://chromium.googlesource.com/a/my/repo.git/",
        )
        join1 = cl1.AsyncWarmChangeDetail(options=["CURRENT_REVISION"])
        join1()

        cl2 = git_cl.Changelist(
            issue=1, codereview_host="webrtc-review.googlesource.com"
        )
        cl2._cached_remote_url = (
            True,
            "https://webrtc.googlesource.com/a/src.git/",
        )
        join2 = cl2.AsyncWarmChangeDetail(options=["CURRENT_REVISION"])
        join2()

        self.assertEqual(
            cl1._GetChangeDetail(options=["CURRENT_REVISION"])["host"],
            "chromium",
        )
        self.assertEqual(
            cl2._GetChangeDetail(options=["CURRENT_REVISION"])["host"],
            "webrtc",
        )
        self.assertEqual(gerrit_util.GetChangeDetail.call_count, 2)

    def test_gerrit_async_warm_change_detail_thread_start_failure_no_leak(self):
        self._mock_gerrit_changes_for_detail_cache()
        cl = git_cl.Changelist(issue=1)
        cl._cached_remote_url = (
            True,
            "https://chromium.googlesource.com/a/my/repo.git/",
        )
        with mock.patch(
            "threading.Thread.start",
            side_effect=RuntimeError("Cannot start thread"),
        ):
            with self.assertRaises(RuntimeError):
                cl.AsyncWarmChangeDetail(options=["CURRENT_REVISION"])

        cache_key = (cl.GetGerritHost(), str(cl.GetIssue()))
        self.assertNotIn(cache_key, git_cl.Changelist._ASYNC_DETAIL_THREADS)

    def test_print_current_creds(self):
        class CookiesAuthenticatorMock(object):
            def __init__(self):
                self.gitcookies = {
                    "host.googlesource.com": ("user", "pass"),
                    "host-review.googlesource.com": ("user", "pass"),
                }

        mock.patch(
            "git_cl.gerrit_util.CookiesAuthenticator", CookiesAuthenticatorMock
        ).start()
        git_cl._GitCookiesChecker().print_current_creds()
        self.assertEqual(
            list(sys.stdout.getvalue().splitlines()),
            [
                "Your .gitcookies have credentials for these hosts:",
                "                        Host\tUser\t Which file",
                "============================\t====\t===========",
                "host-review.googlesource.com\tuser\t.gitcookies",
                "       host.googlesource.com\tuser\t.gitcookies",
            ],
        )
        sys.stdout.seek(0)
        sys.stdout.truncate(0)
        git_cl._GitCookiesChecker().print_current_creds()
        self.assertEqual(
            list(sys.stdout.getvalue().splitlines()),
            [
                "Your .gitcookies have credentials for these hosts:",
                "                        Host\tUser\t Which file",
                "============================\t====\t===========",
                "host-review.googlesource.com\tuser\t.gitcookies",
                "       host.googlesource.com\tuser\t.gitcookies",
            ],
        )

    def _common_creds_check_mocks(self):
        def exists_mock(path):
            dirname = os.path.dirname(path)
            if dirname == os.path.expanduser("~"):
                dirname = "~"
            base = os.path.basename(path)
            if base == ".gitcookies":
                return self._mocked_call(
                    "os.path.exists", os.path.join(dirname, base)
                )
            # git cl also checks for existence other files not relevant to this
            # test.
            return None

        mock.patch(
            "gclient_utils.AskForData",
            lambda prompt: self._mocked_call("ask_for_data", prompt),
        ).start()
        mock.patch("os.path.exists", exists_mock).start()

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_git_cl_comment_add_gerrit(self):
        git_new_branch.create_new_branch(None)  # hits mock from scm_mock.GIT.
        scm.GIT.SetConfig(
            "",
            "remote.origin.url",
            "https://chromium.googlesource.com/infra/infra",
        )
        self.calls = [
            (
                (
                    "SetReview",
                    "chromium-review.googlesource.com",
                    "infra%2Finfra~10",
                    "msg",
                    None,
                    None,
                    None,
                    None,
                    "infra/infra",
                ),
                None,
            ),
        ]
        self.assertEqual(0, git_cl.main(["comment", "-i", "10", "-a", "msg"]))

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def test_git_cl_comments_reply_gerrit(self):
        git_new_branch.create_new_branch(None)  # hits mock from scm_mock.GIT.
        scm.GIT.SetConfig(
            "",
            "remote.origin.url",
            "https://chromium.googlesource.com/infra/infra",
        )
        self.calls = [
            (
                (
                    "GetChangeComments",
                    "chromium-review.googlesource.com",
                    "infra%2Finfra~10",
                ),
                {
                    "some/file.py": [
                        {
                            "id": "uuid-123",
                            "line": 42,
                            "message": "some comment",
                            "patch_set": 2,
                        },
                    ],
                },
            ),
            (
                (
                    "CreateDraft",
                    "chromium-review.googlesource.com",
                    "infra%2Finfra~10",
                    2,
                    {
                        "in_reply_to": "uuid-123",
                        "message": "reply msg",
                        "path": "some/file.py",
                        "line": 42,
                    },
                ),
                None,
            ),
        ]
        self.assertEqual(
            0,
            git_cl.main(
                [
                    "comments",
                    "-i",
                    "10",
                    "--reply-to",
                    "uuid-123",
                    "-a",
                    "reply msg",
                ]
            ),
        )

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    @mock.patch("git_cl.Changelist.GetBranch", return_value="foo")
    def test_git_cl_comments_fetch_gerrit(self, *_mocks):
        scm.GIT.SetConfig(
            "",
            "remote.origin.url",
            "https://chromium.googlesource.com/infra/infra",
        )
        gerrit_util.GetChangeDetail.return_value = {
            "owner": {"email": "owner@example.com"},
            "current_revision": "ba5eba11",
            "revisions": {
                "deadbeaf": {
                    "_number": 1,
                },
                "ba5eba11": {
                    "_number": 2,
                },
            },
            "messages": [
                {
                    "_revision_number": 1,
                    "author": {
                        "_account_id": 1111084,
                        "email": "could-be-anything@example.com",
                        "name": "LUCI CQ",
                    },
                    "date": "2017-03-15 20:08:45.000000000",
                    "id": "f5a6c25ecbd3b3b54a43ae418ed97eff046dc50b",
                    "message": "Patch Set 1:\n\nDry run: CQ is trying the patch...",
                    "tag": "autogenerated:cv:dry-run",
                },
                {
                    "_revision_number": 2,
                    "author": {
                        "_account_id": 11151243,
                        "email": "owner@example.com",
                        "name": "owner",
                    },
                    "date": "2017-03-16 20:00:41.000000000",
                    "id": "f5a6c25ecbd3b3b54a43ae418ed97eff046d1234",
                    "message": "PTAL",
                },
                {
                    "_revision_number": 2,
                    "author": {
                        "_account_id": 148512,
                        "email": "reviewer@example.com",
                        "name": "reviewer",
                    },
                    "date": "2017-03-17 05:19:37.500000000",
                    "id": "f5a6c25ecbd3b3b54a43ae418ed97eff046d4568",
                    "message": "Patch Set 2: Code-Review+1",
                },
                {
                    "_revision_number": 2,
                    "author": {"_account_id": 42, "name": "reviewer"},
                    "date": "2017-03-17 05:19:37.900000000",
                    "id": "f5a6c25ecbd3b3b54a43ae418ed97eff046d0000",
                    "message": "A bot with no email set",
                },
            ],
        }
        self.calls = [
            (
                (
                    "GetChangeComments",
                    "chromium-review.googlesource.com",
                    "infra%2Finfra~1",
                ),
                {
                    "/COMMIT_MSG": [
                        {
                            "id": "comment_id_1",
                            "author": {"email": "reviewer@example.com"},
                            "updated": "2017-03-17 05:19:37.500000000",
                            "patch_set": 2,
                            "side": "REVISION",
                            "message": "Please include a bug link",
                        },
                    ],
                    "codereview.settings": [
                        {
                            "id": "comment_id_2",
                            "author": {"email": "owner@example.com"},
                            "updated": "2017-03-16 20:00:41.000000000",
                            "patch_set": 2,
                            "side": "PARENT",
                            "line": 42,
                            "message": "I removed this because it is bad",
                        },
                        {
                            "id": "comment_id_3",
                            "author": {"email": "owner@example.com"},
                            "updated": "2017-03-16 20:00:41.000000000",
                            "patch_set": 2,
                            "side": "PARENT",
                            "line": 42,
                            "message": "And another thing",
                        },
                    ],
                },
            ),
        ] * 2 + [
            (
                (
                    "write_json",
                    "output.json",
                    [
                        {
                            "date": "2017-03-16 20:00:41.000000",
                            "message": {
                                "message": "PTAL",
                                "comments": [
                                    {
                                        "path": "codereview.settings",
                                        "line": 42,
                                        "patchset": "Base",
                                        "unresolved": False,
                                        "content": "I removed this because it is bad",
                                        "id": "comment_id_2",
                                    },
                                    {
                                        "path": "codereview.settings",
                                        "line": 42,
                                        "patchset": "Base",
                                        "unresolved": False,
                                        "content": "And another thing",
                                        "id": "comment_id_3",
                                    },
                                ],
                            },
                            "sender": "owner@example.com",
                            "autogenerated": False,
                            "approval": False,
                            "disapproval": False,
                        },
                        {
                            "date": "2017-03-17 05:19:37.500000",
                            "message": {
                                "message": "Patch Set 2: Code-Review+1",
                                "comments": [
                                    {
                                        "path": "/COMMIT_MSG",
                                        "line": 0,
                                        "patchset": "PS2",
                                        "unresolved": False,
                                        "content": "Please include a bug link",
                                        "id": "comment_id_1",
                                    }
                                ],
                            },
                            "sender": "reviewer@example.com",
                            "autogenerated": False,
                            "approval": False,
                            "disapproval": False,
                        },
                    ],
                ),
                "",
            )
        ]
        expected_comments_summary = [
            git_cl._CommentSummary(
                message=(
                    "PTAL\n"
                    + "\n"
                    + "codereview.settings\n"
                    + "  Base, Line 42: https://crrev.com/c/1/2/"
                    + "codereview.settings#b42 (resolved)\n"
                    + "  I removed this because it is bad\n\n"
                    + "  Base, Line 42: https://crrev.com/c/1/2/"
                    + "codereview.settings#b42 (resolved)\n"
                    + "  And another thing\n"
                ),
                message_json={
                    "message": "PTAL",
                    "comments": [
                        {
                            "path": "codereview.settings",
                            "line": 42,
                            "patchset": "Base",
                            "unresolved": False,
                            "content": "I removed this because it is bad",
                            "id": "comment_id_2",
                        },
                        {
                            "path": "codereview.settings",
                            "line": 42,
                            "patchset": "Base",
                            "unresolved": False,
                            "content": "And another thing",
                            "id": "comment_id_3",
                        },
                    ],
                },
                date=datetime.datetime(2017, 3, 16, 20, 0, 41, 0),
                autogenerated=False,
                disapproval=False,
                approval=False,
                sender="owner@example.com",
            ),
            git_cl._CommentSummary(
                message=(
                    "Patch Set 2: Code-Review+1\n"
                    + "\n"
                    + "/COMMIT_MSG\n"
                    + "  PS2, File comment: https://crrev.com/c/1/2/"
                    "/COMMIT_MSG# (resolved)\n"
                    + "  Please include a bug link\n"
                ),
                message_json={
                    "message": "Patch Set 2: Code-Review+1",
                    "comments": [
                        {
                            "path": "/COMMIT_MSG",
                            "line": 0,
                            "patchset": "PS2",
                            "unresolved": False,
                            "content": "Please include a bug link",
                            "id": "comment_id_1",
                        }
                    ],
                },
                date=datetime.datetime(2017, 3, 17, 5, 19, 37, 500000),
                autogenerated=False,
                disapproval=False,
                approval=False,
                sender="reviewer@example.com",
            ),
        ]
        cl = git_cl.Changelist(issue=1, branchref="refs/heads/foo")
        self.assertEqual(cl.GetCommentsSummary(), expected_comments_summary)
        self.assertEqual(
            0, git_cl.main(["comments", "-i", "1", "-j", "output.json"])
        )

    def test_get_remote_url_with_mirror(self):
        original_os_path_isdir = os.path.isdir

        def selective_os_path_isdir_mock(path):
            if path == "/cache/this-dir-exists":
                return self._mocked_call("os.path.isdir", path)
            return original_os_path_isdir(path)

        mock.patch("os.path.isdir", selective_os_path_isdir_mock).start()

        url = "https://chromium.googlesource.com/my/repo"
        scm.GIT.SetConfig("", "remote.origin.url", "/cache/this-dir-exists")
        scm.GIT.SetConfig("/cache/this-dir-exists", "remote.origin.url", url)
        self.calls = [
            (("os.path.isdir", "/cache/this-dir-exists"), True),
        ]
        cl = git_cl.Changelist(issue=1)
        self.assertEqual(cl.GetRemoteUrl(), url)
        self.assertEqual(cl.GetRemoteUrl(), url)  # Must be cached.

    def test_get_remote_url_non_existing_mirror(self):
        original_os_path_isdir = os.path.isdir

        def selective_os_path_isdir_mock(path):
            if path == "/cache/this-dir-doesnt-exist":
                return self._mocked_call("os.path.isdir", path)
            return original_os_path_isdir(path)

        mock.patch("os.path.isdir", selective_os_path_isdir_mock).start()
        mock.patch(
            "logging.error", lambda *a: self._mocked_call("logging.error", *a)
        ).start()

        scm.GIT.SetConfig(
            "", "remote.origin.url", "/cache/this-dir-doesnt-exist"
        )
        self.calls = [
            (("os.path.isdir", "/cache/this-dir-doesnt-exist"), False),
            (
                (
                    "logging.error",
                    'Remote "%(remote)s" for branch "%(branch)s" points to "%(url)s", '
                    "but it doesn't exist.",
                    {
                        "remote": "origin",
                        "branch": "main",
                        "url": "/cache/this-dir-doesnt-exist",
                    },
                ),
                None,
            ),
        ]
        cl = git_cl.Changelist(issue=1)
        self.assertIsNone(cl.GetRemoteUrl())

    def test_get_remote_url_misconfigured_mirror(self):
        original_os_path_isdir = os.path.isdir

        def selective_os_path_isdir_mock(path):
            if path == "/cache/this-dir-exists":
                return self._mocked_call("os.path.isdir", path)
            return original_os_path_isdir(path)

        mock.patch("os.path.isdir", selective_os_path_isdir_mock).start()
        mock.patch(
            "logging.error", lambda *a: self._mocked_call("logging.error", *a)
        ).start()

        scm.GIT.SetConfig("", "remote.origin.url", "/cache/this-dir-exists")
        self.calls = [
            (("os.path.isdir", "/cache/this-dir-exists"), True),
            (
                (
                    "logging.error",
                    'Remote "%(remote)s" for branch "%(branch)s" points to '
                    '"%(cache_path)s", but it is misconfigured.\n'
                    '"%(cache_path)s" must be a git repo and must have a remote named '
                    '"%(remote)s" pointing to the git host.',
                    {
                        "remote": "origin",
                        "cache_path": "/cache/this-dir-exists",
                        "branch": "main",
                    },
                ),
                None,
            ),
        ]
        cl = git_cl.Changelist(issue=1)
        self.assertIsNone(cl.GetRemoteUrl())

    def test_gerrit_change_identifier_with_project(self):
        scm.GIT.SetConfig(
            "",
            "remote.origin.url",
            "https://chromium.googlesource.com/a/my/repo.git/",
        )
        cl = git_cl.Changelist(issue=123456)
        self.assertEqual(cl._GerritChangeIdentifier(), "my%2Frepo~123456")

    def test_gerrit_change_identifier_without_project(self):
        mock.patch(
            "logging.error", lambda *a: self._mocked_call("logging.error", *a)
        ).start()

        self.calls = [
            (
                (
                    "logging.error",
                    'Remote "%(remote)s" for branch "%(branch)s" points to "%(url)s", '
                    "but it doesn't exist.",
                    {"remote": "origin", "branch": "main", "url": ""},
                ),
                None,
            ),
        ]
        cl = git_cl.Changelist(issue=123456)
        self.assertEqual(cl._GerritChangeIdentifier(), "123456")

    def test_prepare_superproject_push_option_no_gclient_root(self):
        with mock.patch(
            "git_cl.gclient_paths.FindGclientRoot",
            autospec=True,
            return_value=None,
        ):
            self.assertIsNone(
                git_cl._prepare_superproject_push_option("/some/dir")
            )

    def test_prepare_superproject_push_option_no_solution_url(self):
        with (
            mock.patch(
                "git_cl.gclient_paths.FindGclientRoot",
                autospec=True,
                return_value="/gclient/root",
            ),
            mock.patch(
                "git_cl.gclient_paths.GetGClientPrimarySolutionURL",
                autospec=True,
                return_value=None,
            ),
        ):
            self.assertIsNone(
                git_cl._prepare_superproject_push_option("/some/dir")
            )

    def test_prepare_superproject_push_option_invalid_solution_url(self):
        with (
            mock.patch(
                "git_cl.gclient_paths.FindGclientRoot",
                autospec=True,
                return_value="/gclient/root",
            ),
            mock.patch(
                "git_cl.gclient_paths.GetGClientPrimarySolutionURL",
                autospec=True,
                return_value="https://",
            ),
        ):
            self.assertIsNone(
                git_cl._prepare_superproject_push_option("/some/dir")
            )

    def test_prepare_superproject_push_option_urlparse_value_error(self):
        with (
            mock.patch(
                "git_cl.gclient_paths.FindGclientRoot",
                autospec=True,
                return_value="/gclient/root",
            ),
            mock.patch(
                "git_cl.gclient_paths.GetGClientPrimarySolutionURL",
                autospec=True,
                return_value="https://[invalid-ipv6/repo",
            ),
        ):
            self.assertIsNone(
                git_cl._prepare_superproject_push_option("/some/dir")
            )

    def test_prepare_superproject_push_option_no_solution_path(self):
        with (
            mock.patch(
                "git_cl.gclient_paths.FindGclientRoot",
                autospec=True,
                return_value="/gclient/root",
            ),
            mock.patch(
                "git_cl.gclient_paths.GetGClientPrimarySolutionURL",
                autospec=True,
                return_value="https://chromium.googlesource.com/chromium/src.git",
            ),
            mock.patch(
                "git_cl.gclient_paths.GetPrimarySolutionPath",
                autospec=True,
                return_value=None,
            ),
        ):
            self.assertIsNone(
                git_cl._prepare_superproject_push_option("/some/dir")
            )

    def test_prepare_superproject_push_option_solution_path_not_dir(self):
        with (
            mock.patch(
                "git_cl.gclient_paths.FindGclientRoot",
                autospec=True,
                return_value="/gclient/root",
            ),
            mock.patch(
                "git_cl.gclient_paths.GetGClientPrimarySolutionURL",
                autospec=True,
                return_value="https://chromium.googlesource.com/chromium/src.git",
            ),
            mock.patch(
                "git_cl.gclient_paths.GetPrimarySolutionPath",
                autospec=True,
                return_value="/nonexistent/path",
            ),
            mock.patch("git_cl.os.path.isdir", return_value=False),
        ):
            self.assertIsNone(
                git_cl._prepare_superproject_push_option("/some/dir")
            )

    def test_prepare_superproject_push_option_empty_rev(self):
        with (
            mock.patch(
                "git_cl.gclient_paths.FindGclientRoot",
                autospec=True,
                return_value="/gclient/root",
            ),
            mock.patch(
                "git_cl.gclient_paths.GetGClientPrimarySolutionURL",
                autospec=True,
                return_value="https://chromium.googlesource.com/chromium/src.git",
            ),
            mock.patch(
                "git_cl.gclient_paths.GetPrimarySolutionPath",
                autospec=True,
                return_value="/soln/path",
            ),
            mock.patch("git_cl.os.path.isdir", return_value=True),
            mock.patch("git_cl.RunGitWithCode", return_value=(0, "")),
        ):
            self.assertIsNone(
                git_cl._prepare_superproject_push_option("/some/dir")
            )

    def test_prepare_superproject_push_option_git_rev_none(self):
        with (
            mock.patch(
                "git_cl.gclient_paths.FindGclientRoot",
                autospec=True,
                return_value="/gclient/root",
            ),
            mock.patch(
                "git_cl.gclient_paths.GetGClientPrimarySolutionURL",
                autospec=True,
                return_value="https://chromium.googlesource.com/chromium/src.git",
            ),
            mock.patch(
                "git_cl.gclient_paths.GetPrimarySolutionPath",
                autospec=True,
                return_value="/soln/path",
            ),
            mock.patch("git_cl.os.path.isdir", return_value=True),
            mock.patch("git_cl.RunGitWithCode", return_value=(0, None)),
        ):
            self.assertIsNone(
                git_cl._prepare_superproject_push_option("/some/dir")
            )

    def test_prepare_superproject_push_option_git_error_code(self):
        with (
            mock.patch(
                "git_cl.gclient_paths.FindGclientRoot",
                autospec=True,
                return_value="/gclient/root",
            ),
            mock.patch(
                "git_cl.gclient_paths.GetGClientPrimarySolutionURL",
                autospec=True,
                return_value="https://chromium.googlesource.com/chromium/src.git",
            ),
            mock.patch(
                "git_cl.gclient_paths.GetPrimarySolutionPath",
                autospec=True,
                return_value="/soln/path",
            ),
            mock.patch("git_cl.os.path.isdir", return_value=True),
            mock.patch(
                "git_cl.RunGitWithCode", return_value=(128, "refs/heads/main\n")
            ),
        ):
            self.assertIsNone(
                git_cl._prepare_superproject_push_option("/some/dir")
            )

    def test_prepare_superproject_push_option_os_error(self):
        with mock.patch(
            "git_cl.gclient_paths.FindGclientRoot",
            autospec=True,
            side_effect=OSError("Disk read error"),
        ):
            self.assertIsNone(
                git_cl._prepare_superproject_push_option("/some/dir")
            )

    def test_prepare_superproject_push_option_getcwd_os_error(self):
        with mock.patch(
            "git_cl.os.getcwd",
            side_effect=OSError("Working directory deleted"),
        ):
            self.assertIsNone(git_cl._prepare_superproject_push_option())

    def test_prepare_superproject_push_option_os_error_not_cached_and_retries(
        self,
    ):
        expected_canonical_path = os.path.realpath("/my/project/src")
        expected_gclient_root = os.path.realpath("/gclient/root")
        expected_soln_path = os.path.realpath("/soln/path")
        with (
            mock.patch(
                "git_cl.gclient_paths.FindGclientRoot",
                autospec=True,
                side_effect=[
                    OSError("Transient disk error"),
                    expected_gclient_root,
                ],
            ) as mock_find_root,
            mock.patch(
                "git_cl.gclient_paths.GetGClientPrimarySolutionURL",
                autospec=True,
                return_value="https://user@chromium.googlesource.com/chromium/src.git",
            ),
            mock.patch(
                "git_cl.gclient_paths.GetPrimarySolutionPath",
                autospec=True,
                return_value=expected_soln_path,
            ),
            mock.patch("git_cl.os.path.isdir", return_value=True),
            mock.patch("git_cl.RunGitWithCode", return_value=(0, "d3adb33f\n")),
        ):
            # First call fails with OSError and returns None without caching
            self.assertIsNone(
                git_cl._prepare_superproject_push_option("/my/project/src")
            )
            self.assertEqual(mock_find_root.call_count, 1)
            mock_find_root.assert_called_with(expected_canonical_path)

            # Subsequent call retries resolution and succeeds
            opt = git_cl._prepare_superproject_push_option("/my/project/src")
            mock_find_root.assert_called_with(expected_canonical_path)
            self.assertEqual(
                opt,
                "custom-keyed-value=rootRepo:chromium/chromium/src@d3adb33f",
            )
            self.assertEqual(mock_find_root.call_count, 2)

    def test_prepare_superproject_push_option_programming_error_raises(self):
        with mock.patch(
            "git_cl.gclient_paths.FindGclientRoot",
            autospec=True,
            side_effect=TypeError("Unexpected type"),
        ):
            with self.assertRaises(TypeError):
                git_cl._prepare_superproject_push_option("/some/dir")

    def test_prepare_superproject_push_option_success_and_cached(self):
        expected_canonical_path = os.path.realpath("/my/project/src")
        expected_gclient_root = os.path.realpath("/gclient/root")
        expected_soln_path = os.path.realpath("/soln/path")
        with (
            mock.patch(
                "git_cl.gclient_paths.FindGclientRoot",
                autospec=True,
                return_value=expected_gclient_root,
            ) as mock_find_root,
            mock.patch(
                "git_cl.gclient_paths.GetGClientPrimarySolutionURL",
                autospec=True,
                return_value="https://user@chromium.googlesource.com/chromium/src.git",
            ) as mock_get_url,
            mock.patch(
                "git_cl.gclient_paths.GetPrimarySolutionPath",
                autospec=True,
                return_value=expected_soln_path,
            ) as mock_get_soln,
            mock.patch("git_cl.os.path.isdir", return_value=True),
            mock.patch(
                "git_cl.RunGitWithCode", return_value=(0, "d3adb33f\n")
            ) as mock_run_git,
        ):
            opt1 = git_cl._prepare_superproject_push_option("/my/project/src")
            self.assertEqual(
                opt1,
                "custom-keyed-value=rootRepo:chromium/chromium/src@d3adb33f",
            )
            self.assertEqual(mock_find_root.call_count, 1)
            mock_find_root.assert_called_with(expected_canonical_path)
            self.assertEqual(mock_get_url.call_count, 1)
            mock_get_url.assert_called_with(expected_gclient_root)
            self.assertEqual(mock_get_soln.call_count, 1)
            mock_get_soln.assert_called_with(expected_canonical_path)
            self.assertEqual(mock_run_git.call_count, 1)
            mock_run_git.assert_called_with(
                ["rev-parse", "--verify", "refs/heads/main"],
                suppress_stderr=True,
                cwd=expected_soln_path,
            )

            # Second call for the same path hits the lru cache without invoking subprocesses again
            opt2 = git_cl._prepare_superproject_push_option("/my/project/src")
            self.assertEqual(opt2, opt1)
            self.assertEqual(mock_find_root.call_count, 1)
            self.assertEqual(mock_get_url.call_count, 1)
            self.assertEqual(mock_get_soln.call_count, 1)
            self.assertEqual(mock_run_git.call_count, 1)

            # Relative equivalent path also hits the normalized cache
            opt3 = git_cl._prepare_superproject_push_option(
                "/my/project/sub/../src"
            )
            self.assertEqual(opt3, opt1)
            self.assertEqual(mock_find_root.call_count, 1)
            self.assertEqual(mock_get_url.call_count, 1)
            self.assertEqual(mock_get_soln.call_count, 1)
            self.assertEqual(mock_run_git.call_count, 1)

    def test_prepare_superproject_push_option_symlink_cached(self):
        with tempfile.TemporaryDirectory() as temp_dir:
            real_path = os.path.join(temp_dir, "real")
            symlink_path = os.path.join(temp_dir, "link")
            os.makedirs(real_path)
            try:
                os.symlink(real_path, symlink_path)
            except OSError:
                self.skipTest("Skip on systems without symlink support")

            with (
                mock.patch(
                    "git_cl.gclient_paths.FindGclientRoot",
                    autospec=True,
                    return_value=real_path,
                ) as mock_find_root,
                mock.patch(
                    "git_cl.gclient_paths.GetGClientPrimarySolutionURL",
                    autospec=True,
                    return_value="https://chromium.googlesource.com/chromium/src.git",
                ),
                mock.patch(
                    "git_cl.gclient_paths.GetPrimarySolutionPath",
                    autospec=True,
                    return_value=real_path,
                ),
                mock.patch("git_cl.os.path.isdir", return_value=True),
                mock.patch(
                    "git_cl.RunGitWithCode", return_value=(0, "d3adb33f\n")
                ),
            ):
                opt1 = git_cl._prepare_superproject_push_option(real_path)
                self.assertIsNotNone(opt1)
                self.assertEqual(mock_find_root.call_count, 1)

                # Calling via symlink path resolves to real_path and hits the cache
                opt2 = git_cl._prepare_superproject_push_option(symlink_path)
                self.assertEqual(opt2, opt1)
                self.assertEqual(mock_find_root.call_count, 1)

    def test_prepare_superproject_push_option_default_cwd(self):
        expected_gclient_root = os.path.realpath("/gclient/root")
        expected_soln_path = os.path.realpath("/soln/path")
        with (
            mock.patch(
                "git_cl.gclient_paths.FindGclientRoot",
                autospec=True,
                return_value=expected_gclient_root,
            ) as mock_find_root,
            mock.patch(
                "git_cl.gclient_paths.GetGClientPrimarySolutionURL",
                autospec=True,
                return_value="https://chromium.googlesource.com/chromium/src.git",
            ) as mock_get_url,
            mock.patch(
                "git_cl.gclient_paths.GetPrimarySolutionPath",
                autospec=True,
                return_value=expected_soln_path,
            ) as mock_get_soln,
            mock.patch("git_cl.os.path.isdir", return_value=True),
            mock.patch(
                "git_cl.RunGitWithCode", return_value=(0, "d3adb33f\n")
            ) as mock_run_git,
        ):
            opt = git_cl._prepare_superproject_push_option()
            self.assertEqual(
                opt,
                "custom-keyed-value=rootRepo:chromium/chromium/src@d3adb33f",
            )
            expected_cwd = os.path.realpath(os.getcwd())
            mock_find_root.assert_called_with(expected_cwd)
            mock_get_url.assert_called_with(expected_gclient_root)
            mock_get_soln.assert_called_with(expected_cwd)
            mock_run_git.assert_called_with(
                ["rev-parse", "--verify", "refs/heads/main"],
                suppress_stderr=True,
                cwd=expected_soln_path,
            )


class ChangelistTest(unittest.TestCase):
    LAST_COMMIT_SUBJECT = "Fixes goat teleporter destination to be Australia"

    def _mock_run_git(commands):
        if commands == ["show", "-s", "--format=%s", "HEAD", "--"]:
            return ChangelistTest.LAST_COMMIT_SUBJECT

    def setUp(self):
        super(ChangelistTest, self).setUp()
        mock.patch("gclient_utils.FileRead").start()
        mock.patch("gclient_utils.FileWrite").start()
        mock.patch("gclient_utils.temporary_file", TemporaryFileMock()).start()
        mock.patch(
            "git_cl.Changelist.GetCodereviewServer",
            return_value="https://chromium-review.googlesource.com",
        ).start()
        mock.patch("git_cl.Changelist.GetAuthor", return_value="author").start()
        mock.patch("git_cl.Changelist.GetIssue", return_value=123456).start()
        mock.patch("git_cl.Changelist.GetPatchset", return_value=7).start()
        mock.patch(
            "git_cl.Changelist.GetRemoteBranch",
            return_value=("origin", "refs/remotes/origin/main"),
        ).start()
        mock.patch("git_cl.PRESUBMIT_SUPPORT", "PRESUBMIT_SUPPORT").start()
        mock.patch("git_cl.Settings.GetRoot", return_value="root").start()
        mock.patch("git_cl.Settings.GetIsGerrit", return_value=True).start()
        mock.patch("git_cl.time_time").start()
        mock.patch("metrics.collector").start()
        mock.patch("subprocess2.Popen").start()
        mock.patch(
            "git_cl.Changelist.GetGerritProject", return_value="project"
        ).start()
        mock.patch("sys.exit", side_effect=SystemExitMock).start()

        scm_mock.GIT(self)

        self.addCleanup(mock.patch.stopall)
        self.temp_count = 0
        gerrit_util._Authenticator._resolved = None

    def testRunHook(self):
        expected_results = {
            "more_cc": ["cc@example.com", "more@example.com"],
            "errors": [],
            "notifications": [],
            "warnings": [],
        }
        gclient_utils.FileRead.return_value = json.dumps(expected_results)
        git_cl.time_time.side_effect = [100, 200, 300, 400]
        mockProcess = mock.Mock()
        mockProcess.wait.return_value = 0
        subprocess2.Popen.return_value = mockProcess

        cl = git_cl.Changelist()
        results = cl.RunHook(
            committing=True,
            may_prompt=True,
            verbose=2,
            parallel=True,
            upstream="upstream",
            description="description",
            all_files=True,
            resultdb=False,
        )

        self.assertEqual(expected_results, results)
        subprocess2.Popen.assert_any_call(
            [
                "vpython3",
                "PRESUBMIT_SUPPORT",
                "--root",
                "root",
                "--upstream",
                "upstream",
                "--verbose",
                "--verbose",
                "--gerrit_url",
                "https://chromium-review.googlesource.com",
                "--gerrit_project",
                "project",
                "--gerrit_branch",
                "refs/heads/main",
                "--author",
                "author",
                "--issue",
                "123456",
                "--patchset",
                "7",
                "--name",
                "main",
                "--commit",
                "--may_prompt",
                "--parallel",
                "--all_files",
                "--no_diffs",
                "--json_output",
                "/tmp/fake-temp2",
                "--description_file",
                "/tmp/fake-temp1",
            ]
        )
        gclient_utils.FileWrite.assert_any_call(
            "/tmp/fake-temp1", "description"
        )
        metrics.collector.add_repeated(
            "sub_commands",
            {
                "command": "presubmit",
                "execution_time": 100,
                "exit_code": 0,
            },
        )

    def testRunHook_FewerOptions(self):
        expected_results = {
            "more_cc": ["cc@example.com", "more@example.com"],
            "errors": [],
            "notifications": [],
            "warnings": [],
        }
        gclient_utils.FileRead.return_value = json.dumps(expected_results)
        git_cl.time_time.side_effect = [100, 200, 300, 400]
        mockProcess = mock.Mock()
        mockProcess.wait.return_value = 0
        subprocess2.Popen.return_value = mockProcess

        git_cl.Changelist.GetAuthor.return_value = None
        git_cl.Changelist.GetIssue.return_value = None
        git_cl.Changelist.GetPatchset.return_value = None

        cl = git_cl.Changelist()
        results = cl.RunHook(
            committing=False,
            may_prompt=False,
            verbose=0,
            parallel=False,
            upstream="upstream",
            description="description",
            all_files=False,
            resultdb=False,
        )

        self.assertEqual(expected_results, results)
        subprocess2.Popen.assert_any_call(
            [
                "vpython3",
                "PRESUBMIT_SUPPORT",
                "--root",
                "root",
                "--upstream",
                "upstream",
                "--gerrit_url",
                "https://chromium-review.googlesource.com",
                "--gerrit_project",
                "project",
                "--gerrit_branch",
                "refs/heads/main",
                "--name",
                "main",
                "--upload",
                "--no-parallel",
                "--json_output",
                "/tmp/fake-temp2",
                "--description_file",
                "/tmp/fake-temp1",
            ]
        )
        gclient_utils.FileWrite.assert_any_call(
            "/tmp/fake-temp1", "description"
        )
        metrics.collector.add_repeated(
            "sub_commands",
            {
                "command": "presubmit",
                "execution_time": 100,
                "exit_code": 0,
            },
        )

    def testRunHook_FewerOptionsResultDB(self):
        expected_results = {
            "more_cc": ["cc@example.com", "more@example.com"],
            "errors": [],
            "notifications": [],
            "warnings": [],
        }
        gclient_utils.FileRead.return_value = json.dumps(expected_results)
        git_cl.time_time.side_effect = [100, 200, 300, 400]
        mockProcess = mock.Mock()
        mockProcess.wait.return_value = 0
        subprocess2.Popen.return_value = mockProcess

        git_cl.Changelist.GetAuthor.return_value = None
        git_cl.Changelist.GetIssue.return_value = None
        git_cl.Changelist.GetPatchset.return_value = None

        cl = git_cl.Changelist()
        results = cl.RunHook(
            committing=False,
            may_prompt=False,
            verbose=0,
            parallel=False,
            upstream="upstream",
            description="description",
            all_files=False,
            resultdb=True,
            realm="chromium:public",
        )

        self.assertEqual(expected_results, results)
        subprocess2.Popen.assert_any_call(
            [
                "rdb",
                "stream",
                "-new",
                "-realm",
                "chromium:public",
                "--",
                "vpython3",
                "PRESUBMIT_SUPPORT",
                "--root",
                "root",
                "--upstream",
                "upstream",
                "--gerrit_url",
                "https://chromium-review.googlesource.com",
                "--gerrit_project",
                "project",
                "--gerrit_branch",
                "refs/heads/main",
                "--name",
                "main",
                "--upload",
                "--no-parallel",
                "--json_output",
                "/tmp/fake-temp2",
                "--description_file",
                "/tmp/fake-temp1",
            ]
        )

    def testRunHook_NoGerrit(self):
        mock.patch("git_cl.Settings.GetIsGerrit", return_value=False).start()

        expected_results = {
            "more_cc": ["cc@example.com", "more@example.com"],
            "errors": [],
            "notifications": [],
            "warnings": [],
        }
        gclient_utils.FileRead.return_value = json.dumps(expected_results)
        git_cl.time_time.side_effect = [100, 200, 300, 400]
        mockProcess = mock.Mock()
        mockProcess.wait.return_value = 0
        subprocess2.Popen.return_value = mockProcess

        git_cl.Changelist.GetAuthor.return_value = None
        git_cl.Changelist.GetIssue.return_value = None
        git_cl.Changelist.GetPatchset.return_value = None

        cl = git_cl.Changelist()
        results = cl.RunHook(
            committing=False,
            may_prompt=False,
            verbose=0,
            parallel=False,
            upstream="upstream",
            description="description",
            all_files=False,
            resultdb=False,
        )

        self.assertEqual(expected_results, results)
        subprocess2.Popen.assert_any_call(
            [
                "vpython3",
                "PRESUBMIT_SUPPORT",
                "--root",
                "root",
                "--upstream",
                "upstream",
                "--name",
                "main",
                "--upload",
                "--no-parallel",
                "--json_output",
                "/tmp/fake-temp2",
                "--description_file",
                "/tmp/fake-temp1",
            ]
        )
        gclient_utils.FileWrite.assert_any_call(
            "/tmp/fake-temp1", "description"
        )
        metrics.collector.add_repeated(
            "sub_commands",
            {
                "command": "presubmit",
                "execution_time": 100,
                "exit_code": 0,
            },
        )

    @mock.patch("sys.exit", side_effect=SystemExitMock)
    def testRunHook_Failure(self, _mock):
        git_cl.time_time.side_effect = [100, 200]
        mockProcess = mock.Mock()
        mockProcess.wait.return_value = 2
        subprocess2.Popen.return_value = mockProcess

        cl = git_cl.Changelist()
        with self.assertRaises(SystemExitMock):
            cl.RunHook(
                committing=True,
                may_prompt=True,
                verbose=2,
                parallel=True,
                upstream="upstream",
                description="description",
                all_files=True,
                resultdb=False,
            )

        sys.exit.assert_called_once_with(2)

    def testRunPostUploadHook(self):
        cl = git_cl.Changelist()
        cl.RunPostUploadHook(2, "upstream", "description")

        subprocess2.Popen.assert_called_with(
            [
                "vpython3",
                "PRESUBMIT_SUPPORT",
                "--root",
                "root",
                "--upstream",
                "upstream",
                "--verbose",
                "--verbose",
                "--gerrit_url",
                "https://chromium-review.googlesource.com",
                "--gerrit_project",
                "project",
                "--gerrit_branch",
                "refs/heads/main",
                "--author",
                "author",
                "--issue",
                "123456",
                "--patchset",
                "7",
                "--name",
                "main",
                "--post_upload",
                "--description_file",
                "/tmp/fake-temp1",
            ]
        )

        gclient_utils.FileWrite.assert_called_once_with(
            "/tmp/fake-temp1", "description"
        )

    def testRunPostUploadHookPy3Only(self):
        cl = git_cl.Changelist()
        cl.RunPostUploadHook(2, "upstream", "description")

        subprocess2.Popen.assert_called_once_with(
            [
                "vpython3",
                "PRESUBMIT_SUPPORT",
                "--root",
                "root",
                "--upstream",
                "upstream",
                "--verbose",
                "--verbose",
                "--gerrit_url",
                "https://chromium-review.googlesource.com",
                "--gerrit_project",
                "project",
                "--gerrit_branch",
                "refs/heads/main",
                "--author",
                "author",
                "--issue",
                "123456",
                "--patchset",
                "7",
                "--name",
                "main",
                "--post_upload",
                "--description_file",
                "/tmp/fake-temp1",
            ]
        )

        gclient_utils.FileWrite.assert_called_once_with(
            "/tmp/fake-temp1", "description"
        )

    @mock.patch("git_cl.RunGit", _mock_run_git)
    def testDefaultTitleEmptyMessage(self):
        cl = git_cl.Changelist()
        cl.issue = 100
        options = optparse.Values(
            {
                "squash": True,
                "title": None,
                "message": None,
                "force": None,
                "skip_title": None,
            }
        )

        mock.patch("gclient_utils.AskForData", lambda _: user_title).start()
        for user_title in ["", "y", "Y"]:
            self.assertEqual(
                cl._GetTitleForUpload(options), self.LAST_COMMIT_SUBJECT
            )

        for user_title in ["not empty", "yes", "YES"]:
            self.assertEqual(cl._GetTitleForUpload(options), user_title)

    @mock.patch("git_cl.Changelist.GetMostRecentPatchset", return_value=2)
    @mock.patch("git_cl.RunGit")
    @mock.patch("git_cl.Changelist._PrepareChange")
    def testPrepareSquashedCommit(self, mockPrepareChange, mockRunGit, *_mocks):

        change_desc = git_cl.ChangeDescription("BOO!")
        reviewers = []
        ccs = []
        mockPrepareChange.return_value = (reviewers, ccs, change_desc)

        parent_hash = "upstream-gerrit-hash"
        parent_orig_hash = "upstream-last-upload-hash"
        parent_hash_root = "root-commit"
        hash_to_push = "new-squash-hash"
        hash_to_push_root = "new-squash-hash-root"
        branchref = "refs/heads/current-branch"
        end_hash = "end-hash"
        tree_hash = "tree-hash"

        def mock_run_git(commands):
            if {"commit-tree", tree_hash, "-p", parent_hash, "-F"}.issubset(
                set(commands)
            ):
                return hash_to_push
            if {
                "commit-tree",
                tree_hash,
                "-p",
                parent_hash_root,
                "-F",
            }.issubset(set(commands)):
                return hash_to_push_root
            if commands == ["rev-parse", branchref]:
                return end_hash
            if commands == ["rev-parse", end_hash + ":"]:
                return tree_hash

        mockRunGit.side_effect = mock_run_git
        cl = git_cl.Changelist(branchref=branchref)
        options = optparse.Values()

        new_upload = cl.PrepareSquashedCommit(
            options, parent_hash, parent_orig_hash
        )
        self.assertEqual(new_upload.reviewers, reviewers)
        self.assertEqual(new_upload.ccs, ccs)
        self.assertEqual(new_upload.commit_to_push, hash_to_push)
        self.assertEqual(new_upload.new_last_uploaded_commit, end_hash)
        self.assertEqual(new_upload.change_desc, change_desc)
        mockPrepareChange.assert_called_with(
            options, parent_orig_hash, end_hash
        )

    @mock.patch("git_cl.Settings.GetRoot", return_value="")
    @mock.patch("git_cl.Changelist.GetMostRecentPatchset", return_value=2)
    @mock.patch("git_cl.RunGitWithCode")
    @mock.patch("git_cl.RunGit")
    @mock.patch("git_cl.Changelist._PrepareChange")
    @mock.patch("git_cl.Changelist.GetCommonAncestorWithUpstream")
    def testPrepareCherryPickSquashedCommit(
        self,
        mockGetCommonAncestorWithUpstream,
        mockPrepareChange,
        mockRunGit,
        mockRunGitWithCode,
        *_mocks,
    ):
        cherry_pick_base_hash = "1a2bcherrypickbase"
        mockGetCommonAncestorWithUpstream.return_value = cherry_pick_base_hash

        change_desc = git_cl.ChangeDescription("BOO!")
        ccs = ["cc@review.cl"]
        reviewers = ["reviewer@review.cl"]
        mockPrepareChange.return_value = (reviewers, ccs, change_desc)

        branchref = "refs/heads/current-branch"
        cl = git_cl.Changelist(branchref=branchref)
        options = optparse.Values()

        upstream_gerrit_hash = "upstream-gerrit-hash"

        latest_tree_hash = "tree-hash"
        hash_to_cp = "squashed-hash"
        hash_to_push = "hash-to-push"
        hash_to_save_as_last_upload = "last-upload"

        def mock_run_git(commands):
            if commands == ["rev-parse", branchref]:
                return hash_to_save_as_last_upload
            if commands == ["rev-parse", branchref + ":"]:
                return latest_tree_hash
            if {
                "commit-tree",
                latest_tree_hash,
                "-p",
                cherry_pick_base_hash,
                "-F",
            }.issubset(set(commands)):
                return hash_to_cp
            if commands == ["rev-parse", "HEAD"]:
                return hash_to_push

        mockRunGit.side_effect = mock_run_git

        def mock_run_git_with_code(commands):
            if commands == ["cherry-pick", hash_to_cp]:
                return 0, ""

        mockRunGitWithCode.side_effect = mock_run_git_with_code

        new_upload = cl.PrepareCherryPickSquashedCommit(
            options, upstream_gerrit_hash
        )
        self.assertEqual(new_upload.reviewers, reviewers)
        self.assertEqual(new_upload.ccs, ccs)
        self.assertEqual(new_upload.commit_to_push, hash_to_push)
        self.assertEqual(
            new_upload.new_last_uploaded_commit, hash_to_save_as_last_upload
        )
        self.assertEqual(new_upload.change_desc, change_desc)

        # Test failed cherry-pick

        def mock_run_git_with_code(commands):
            if commands == ["cherry-pick", hash_to_cp]:
                return 1, ""

        mockRunGitWithCode.side_effect = mock_run_git_with_code

        with self.assertRaises(SystemExitMock):
            cl.PrepareCherryPickSquashedCommit(options, cherry_pick_base_hash)

    @mock.patch("git_cl.Settings.GetDefaultCCList", return_value=[])
    @mock.patch("git_cl.Changelist.GetAffectedFiles", return_value=[])
    @mock.patch("git_cl.GenerateGerritChangeId", return_value="1a2b3c")
    @mock.patch("git_cl.Changelist.GetIssue", return_value=None)
    @mock.patch("git_cl.ChangeDescription.prompt")
    @mock.patch("git_cl.Changelist.RunHook")
    @mock.patch("git_cl.Changelist._GetDescriptionForUpload")
    @mock.patch("git_cl.Changelist.EnsureCanUploadPatchset")
    def testPrepareChange_new(
        self,
        mockEnsureCanUploadPatchset,
        mockGetDescriptionForupload,
        mockRunHook,
        mockPrompt,
        *_mocks,
    ):
        options = optparse.Values()

        options.force = False
        options.bypass_hooks = False
        options.verbose = False
        options.parallel = False
        options.preserve_tryjobs = False
        options.private = False
        options.no_autocc = False
        options.message_file = None
        options.commit_description = None
        options.cc = ["chicken@bok.farm"]
        parent = "420parent"
        latest_tree = "420latest_tree"

        mockRunHook.return_value = {"more_cc": ["cow@moo.farm"]}
        desc = "AH!\nCC=cow2@moo.farm\nR=horse@apple.farm"
        mockGetDescriptionForupload.return_value = git_cl.ChangeDescription(
            desc
        )

        cl = git_cl.Changelist()
        reviewers, ccs, change_desc = cl._PrepareChange(
            options, parent, latest_tree
        )
        self.assertEqual(reviewers, ["horse@apple.farm"])
        self.assertEqual(
            ccs, ["cow@moo.farm", "chicken@bok.farm", "cow2@moo.farm"]
        )
        self.assertEqual(
            change_desc._description_lines,
            [
                "AH!",
                "CC=cow2@moo.farm",
                "R=horse@apple.farm",
                "",
                "Change-Id: 1a2b3c",
            ],
        )
        mockPrompt.assert_called_once()
        mockEnsureCanUploadPatchset.assert_called_once()
        mockRunHook.assert_called_once_with(
            committing=False,
            may_prompt=True,
            verbose=False,
            parallel=False,
            upstream="420parent",
            description=desc,
            all_files=False,
            end_commit="420latest_tree",
        )

    @mock.patch("git_cl.watchlists.Watchlists")
    @mock.patch("git_cl.Changelist.GetAffectedFiles", return_value=["foo.cc"])
    @mock.patch("git_cl.Changelist.GetIssue", return_value=None)
    @mock.patch("git_cl.ChangeDescription.prompt")
    @mock.patch("git_cl.Changelist.RunHook", return_value={"more_cc": []})
    @mock.patch("git_cl.Changelist._GetDescriptionForUpload")
    @mock.patch("git_cl.Changelist.EnsureCanUploadPatchset")
    def testPrepareChange_bypassWatchlists(
        self,
        mockEnsureCanUploadPatchset,
        mockGetDescriptionForupload,
        mockRunHook,
        mockPrompt,
        mockGetIssue,
        mockGetAffectedFiles,
        mockWatchlists,
    ):
        cl = git_cl.Changelist()
        options = optparse.Values()
        options.force = True
        options.bypass_hooks = True
        options.bypass_watchlists = True
        options.verbose = False
        options.parallel = False
        options.edit_description = False
        options.preserve_tryjobs = False
        options.private = False
        options.no_autocc = False
        options.message_file = None
        options.commit_description = None
        options.cc = []
        mockGetDescriptionForupload.return_value = git_cl.ChangeDescription(
            "title\n\nChange-Id: 1a2b3c"
        )

        cl._PrepareChange(options, "parent", "latest_tree")
        mockWatchlists.assert_not_called()

    @mock.patch("git_cl.Changelist.GetAffectedFiles", return_value=[])
    @mock.patch("git_cl.Changelist.GetIssue", return_value="123")
    @mock.patch("git_cl.ChangeDescription.prompt")
    @mock.patch("gerrit_util.GetChangeDetail")
    @mock.patch("git_cl.Changelist.RunHook")
    @mock.patch("git_cl.Changelist._GetDescriptionForUpload")
    @mock.patch("git_cl.Changelist.EnsureCanUploadPatchset")
    def testPrepareChange_existing(
        self,
        mockEnsureCanUploadPatchset,
        mockGetDescriptionForupload,
        mockRunHook,
        mockGetChangeDetail,
        mockPrompt,
        *_mocks,
    ):
        cl = git_cl.Changelist()
        options = optparse.Values()

        options.force = False
        options.bypass_hooks = False
        options.verbose = False
        options.parallel = False
        options.edit_description = False
        options.preserve_tryjobs = False
        options.private = False
        options.no_autocc = False
        options.cc = ["chicken@bok.farm"]
        parent = "420parent"
        latest_tree = "420latest_tree"

        mockRunHook.return_value = {"more_cc": ["cow@moo.farm"]}
        desc = "AH!\nCC=cow2@moo.farm\nR=horse@apple.farm"
        mockGetDescriptionForupload.return_value = git_cl.ChangeDescription(
            desc
        )

        # Existing change
        gerrit_util.GetChangeDetail.return_value = {
            "change_id": ("123456789"),
            "current_revision": "sha1_of_current_revision",
        }

        reviewers, ccs, change_desc = cl._PrepareChange(
            options, parent, latest_tree
        )
        self.assertEqual(reviewers, ["horse@apple.farm"])
        self.assertEqual(ccs, ["chicken@bok.farm", "cow2@moo.farm"])
        self.assertEqual(
            change_desc._description_lines,
            [
                "AH!",
                "CC=cow2@moo.farm",
                "R=horse@apple.farm",
                "",
                "Change-Id: 123456789",
            ],
        )
        mockRunHook.assert_called_once_with(
            committing=False,
            may_prompt=True,
            verbose=False,
            parallel=False,
            upstream=parent,
            description=desc,
            all_files=False,
            end_commit=latest_tree,
        )
        mockEnsureCanUploadPatchset.assert_called_once()

        # Test preserve_tryjob
        options.preserve_tryjobs = True
        # Test edit_description
        options.edit_description = True
        # Test private
        options.private = True
        options.no_autocc = True

        reviewers, ccs, change_desc = cl._PrepareChange(
            options, parent, latest_tree
        )
        self.assertEqual(ccs, ["chicken@bok.farm", "cow2@moo.farm"])
        mockPrompt.assert_called_once()
        self.assertEqual(
            change_desc._description_lines,
            [
                "AH!",
                "CC=cow2@moo.farm",
                "R=horse@apple.farm",
                "",
                "Change-Id: 123456789",
                "Cq-Do-Not-Cancel-Tryjobs: true",
            ],
        )

    @mock.patch("git_cl.Changelist.GetGerritHost", return_value="chromium")
    @mock.patch("git_cl.Settings.GetRunPostUploadHook", return_value=True)
    @mock.patch("git_cl.Changelist.SetPatchset")
    @mock.patch("git_cl.Changelist.RunPostUploadHook")
    @mock.patch("git_cl.gerrit_util.AddReviewers")
    def testPostUploadUpdates(
        self, mockAddReviewers, mockRunPostHook, mockSetPatchset, *_mocks
    ):

        cl = git_cl.Changelist(branchref="refs/heads/current-branch")
        options = optparse.Values()
        options.verbose = True
        options.no_python2_post_upload_hooks = True
        options.send_mail = False

        reviewers = ["monkey@vp.circus"]
        ccs = ["cow@rds.corp"]
        change_desc = git_cl.ChangeDescription("[stonks] honk honk")
        new_upload = git_cl._NewUpload(
            reviewers,
            ccs,
            "pushed-commit",
            "last-uploaded-commit",
            "parent-commit",
            change_desc,
            2,
        )

        cl.PostUploadUpdates(options, new_upload, "12345")
        mockSetPatchset.assert_called_once_with(3)
        self.assertEqual(
            scm.GIT.GetConfig("root", "branch.current-branch.gerritsquashhash"),
            new_upload.commit_to_push,
        )
        self.assertEqual(
            scm.GIT.GetConfig("root", "branch.current-branch.last-upload-hash"),
            new_upload.new_last_uploaded_commit,
        )

        mockAddReviewers.assert_called_once_with(
            "chromium",
            "project~123456",
            reviewers=reviewers,
            ccs=ccs,
            notify=False,
        )
        mockRunPostHook.assert_called_once_with(
            True, "parent-commit", change_desc.description
        )

    def testGerritURLsEqual(self):
        cases = [
            (
                "https://chromium.googlesource.com/chromium/tools/depot_tools",
                "sso://chromium/chromium/tools/depot_tools",
            ),
            (
                "https://chromium.googlesource.com/build",
                "sso://chromium.googlesource.com/build",
            ),
        ]
        for a, b in cases:
            with self.subTest(c=(a, b)):
                self.assertTrue(git_cl.Changelist._GerritURLsEqual(a, b))
        cases = [
            (
                "https://chrome-internal.googlesource.com/chromium/tools/depot_tools",
                "sso://chromium/chromium/tools/depot_tools",
            ),
        ]
        for a, b in cases:
            with self.subTest(c=(a, b)):
                self.assertFalse(git_cl.Changelist._GerritURLsEqual(a, b))

    def testGerritURLRepoIdentity(self):
        cases = [
            (
                "https://chromium.googlesource.com/chromium/tools/depot_tools",
                ("chromium", "/chromium/tools/depot_tools"),
            ),
            (
                "sso://chromium/chromium/tools/depot_tools",
                ("chromium", "/chromium/tools/depot_tools"),
            ),
        ]
        for x, want in cases:
            with self.subTest(c=x):
                self.assertEqual(
                    git_cl.Changelist._GerritURLRepoIdentity(x), want
                )


class CMDTestCaseBase(unittest.TestCase):
    _STATUSES = [
        "STATUS_UNSPECIFIED",
        "SCHEDULED",
        "STARTED",
        "SUCCESS",
        "FAILURE",
        "INFRA_FAILURE",
        "CANCELED",
    ]
    _CHANGE_DETAIL = {
        "project": "depot_tools",
        "status": "OPEN",
        "owner": {"email": "owner@e.mail"},
        "current_revision": "beeeeeef",
        "revisions": {
            "deadbeaf": {
                "_number": 6,
                "kind": "REWORK",
            },
            "beeeeeef": {
                "_number": 7,
                "kind": "NO_CODE_CHANGE",
                "fetch": {
                    "http": {
                        "url": "https://chromium.googlesource.com/depot_tools",
                        "ref": "refs/changes/56/123456/7",
                    }
                },
            },
        },
    }
    _DEFAULT_RESPONSE = {
        "builds": [
            {
                "id": str(100 + idx),
                "builder": {
                    "project": "chromium",
                    "bucket": "try",
                    "builder": "bot_" + status.lower(),
                },
                "createTime": "2019-10-09T08:00:0%d.854286Z" % (idx % 10),
                "tags": [],
                "status": status,
            }
            for idx, status in enumerate(_STATUSES)
        ]
    }

    def setUp(self):
        super(CMDTestCaseBase, self).setUp()
        mock.patch("git_cl.sys.stdout", io.StringIO()).start()
        mock.patch("git_cl.uuid.uuid4", return_value="uuid4").start()
        mock.patch("git_cl.Changelist.GetIssue", return_value=123456).start()
        mock.patch(
            "git_cl.Changelist.GetCodereviewServer",
            return_value="https://chromium-review.googlesource.com",
        ).start()
        mock.patch(
            "git_cl.Changelist.GetGerritHost",
            return_value="chromium-review.googlesource.com",
        ).start()
        mock.patch(
            "git_cl.Changelist.GetMostRecentPatchset", return_value=7
        ).start()
        mock.patch(
            "git_cl.Changelist.GetMostRecentDryRunPatchset", return_value=6
        ).start()
        mock.patch(
            "git_cl.Changelist.GetRemoteUrl",
            return_value="https://chromium.googlesource.com/depot_tools",
        ).start()
        mock.patch(
            "auth.Authenticator", return_value=AuthenticatorMock()
        ).start()
        mock.patch("git_cl.subprocess2.call", return_value=0).start()
        mock.patch(
            "gerrit_util.GetChangeDetail", return_value=self._CHANGE_DETAIL
        ).start()
        mock.patch(
            "git_cl._buildbucket_search", return_value=self._DEFAULT_RESPONSE
        ).start()
        mock.patch("git_common.is_dirty_git_tree", return_value=False).start()
        self.addCleanup(mock.patch.stopall)


@unittest.skipIf(
    gclient_utils.IsEnvCog(), "not supported in non-git environment"
)
class CMDPresubmitTestCase(CMDTestCaseBase):
    _RUN_HOOK_RETURN = {
        "errors": [],
        "more_cc": [],
        "notifications": [],
        "warnings": [],
    }

    def setUp(self):
        super(CMDPresubmitTestCase, self).setUp()
        mock.patch(
            "git_cl.Changelist.GetCommonAncestorWithUpstream",
            return_value="upstream",
        ).start()
        mock.patch(
            "git_cl.Changelist.FetchDescription",
            return_value="fetch description",
        ).start()
        mock.patch(
            "git_cl._create_description_from_log",
            return_value="get description",
        ).start()
        mock.patch(
            "git_cl.Changelist.RunHook", return_value=self._RUN_HOOK_RETURN
        ).start()

    def testDefaultCase(self):
        self.assertEqual(0, git_cl.main(["presubmit"]))
        git_cl.Changelist.RunHook.assert_called_once_with(
            committing=True,
            may_prompt=False,
            verbose=0,
            parallel=True,
            upstream="upstream",
            description="fetch description",
            all_files=None,
            files=None,
            resultdb=None,
            realm=None,
        )

    def testNoIssue(self):
        git_cl.Changelist.GetIssue.return_value = None
        self.assertEqual(0, git_cl.main(["presubmit"]))
        git_cl.Changelist.RunHook.assert_called_once_with(
            committing=True,
            may_prompt=False,
            verbose=0,
            parallel=True,
            upstream="upstream",
            description="get description",
            all_files=None,
            files=None,
            resultdb=None,
            realm=None,
        )

    def testCustomBranch(self):
        self.assertEqual(0, git_cl.main(["presubmit", "custom_branch"]))
        git_cl.Changelist.RunHook.assert_called_once_with(
            committing=True,
            may_prompt=False,
            verbose=0,
            parallel=True,
            upstream="custom_branch",
            description="fetch description",
            all_files=None,
            files=None,
            resultdb=None,
            realm=None,
        )

    def testNoParallelOption(self):
        self.assertEqual(0, git_cl.main(["presubmit", "--no-parallel"]))
        git_cl.Changelist.RunHook.assert_called_once_with(
            committing=True,
            may_prompt=False,
            verbose=0,
            parallel=False,
            upstream="upstream",
            description="fetch description",
            all_files=None,
            files=None,
            resultdb=None,
            realm=None,
        )

    def testOptions(self):
        self.assertEqual(
            0,
            git_cl.main(
                [
                    "presubmit",
                    "-v",
                    "-v",
                    "--all",
                    "--parallel",
                    "-u",
                    "--resultdb",
                    "--realm",
                    "chromium:public",
                ]
            ),
        )
        git_cl.Changelist.RunHook.assert_called_once_with(
            committing=False,
            may_prompt=False,
            verbose=2,
            parallel=True,
            upstream="upstream",
            description="fetch description",
            all_files=True,
            files=None,
            resultdb=True,
            realm="chromium:public",
        )

    @mock.patch("git_cl.write_json")
    def testJson(self, mock_write_json):
        self.assertEqual(0, git_cl.main(["presubmit", "--json", "file.json"]))
        mock_write_json.assert_called_once_with(
            "file.json", self._RUN_HOOK_RETURN
        )


class CMDTryResultsTestCase(CMDTestCaseBase):
    _DEFAULT_REQUEST = {
        "predicate": {
            "gerritChanges": [
                {
                    "project": "depot_tools",
                    "host": "chromium-review.googlesource.com",
                    "patchset": 6,
                    "change": 123456,
                }
            ],
        },
        "fields": (
            "builds.*.id,builds.*.builder,builds.*.status"
            + ",builds.*.createTime,builds.*.tags"
        ),
    }

    _TRIVIAL_REQUEST = {
        "predicate": {
            "gerritChanges": [
                {
                    "project": "depot_tools",
                    "host": "chromium-review.googlesource.com",
                    "patchset": 7,
                    "change": 123456,
                }
            ],
        },
        "fields": (
            "builds.*.id,builds.*.builder,builds.*.status"
            + ",builds.*.createTime,builds.*.tags"
        ),
    }

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def testNoJobs(self):
        git_cl._buildbucket_search.return_value = {}

        self.assertEqual(0, git_cl.main(["try-results"]))
        self.assertEqual("No tryjobs scheduled.\n", sys.stdout.getvalue())
        git_cl._buildbucket_search.assert_called_once_with(
            "cr-buildbucket.appspot.com", self._DEFAULT_REQUEST
        )

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def testTrivialCommits(self):
        self.assertEqual(0, git_cl.main(["try-results"]))
        git_cl._buildbucket_search.assert_called_with(
            "cr-buildbucket.appspot.com", self._DEFAULT_REQUEST
        )

        git_cl._buildbucket_search.return_value = {}
        self.assertEqual(0, git_cl.main(["try-results", "--patchset", "7"]))
        git_cl._buildbucket_search.assert_called_with(
            "cr-buildbucket.appspot.com", self._TRIVIAL_REQUEST
        )
        self.assertEqual(
            [
                "Successes:",
                "  bot_success            https://ci.chromium.org/b/103",
                "Infra Failures:",
                "  bot_infra_failure      https://ci.chromium.org/b/105",
                "Failures:",
                "  bot_failure            https://ci.chromium.org/b/104",
                "Canceled:",
                "  bot_canceled          ",
                "Started:",
                "  bot_started            https://ci.chromium.org/b/102",
                "Scheduled:",
                "  bot_scheduled          id=101",
                "Other:",
                "  bot_status_unspecified id=100",
                "Total: 7 tryjobs",
                "No tryjobs scheduled.",
            ],
            sys.stdout.getvalue().splitlines(),
        )

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def testPrintToStdout(self):
        self.assertEqual(0, git_cl.main(["try-results"]))
        self.assertEqual(
            [
                "Successes:",
                "  bot_success            https://ci.chromium.org/b/103",
                "Infra Failures:",
                "  bot_infra_failure      https://ci.chromium.org/b/105",
                "Failures:",
                "  bot_failure            https://ci.chromium.org/b/104",
                "Canceled:",
                "  bot_canceled          ",
                "Started:",
                "  bot_started            https://ci.chromium.org/b/102",
                "Scheduled:",
                "  bot_scheduled          id=101",
                "Other:",
                "  bot_status_unspecified id=100",
                "Total: 7 tryjobs",
            ],
            sys.stdout.getvalue().splitlines(),
        )
        git_cl._buildbucket_search.assert_called_once_with(
            "cr-buildbucket.appspot.com", self._DEFAULT_REQUEST
        )

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def testPrintToStdoutWithMasters(self):
        self.assertEqual(0, git_cl.main(["try-results", "--print-master"]))
        self.assertEqual(
            [
                "Successes:",
                "  try bot_success            https://ci.chromium.org/b/103",
                "Infra Failures:",
                "  try bot_infra_failure      https://ci.chromium.org/b/105",
                "Failures:",
                "  try bot_failure            https://ci.chromium.org/b/104",
                "Canceled:",
                "  try bot_canceled          ",
                "Started:",
                "  try bot_started            https://ci.chromium.org/b/102",
                "Scheduled:",
                "  try bot_scheduled          id=101",
                "Other:",
                "  try bot_status_unspecified id=100",
                "Total: 7 tryjobs",
            ],
            sys.stdout.getvalue().splitlines(),
        )
        git_cl._buildbucket_search.assert_called_once_with(
            "cr-buildbucket.appspot.com", self._DEFAULT_REQUEST
        )

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    @mock.patch("git_cl.write_json")
    def testWriteToJson(self, mockJsonDump):
        self.assertEqual(0, git_cl.main(["try-results", "--json", "file.json"]))
        git_cl._buildbucket_search.assert_called_once_with(
            "cr-buildbucket.appspot.com", self._DEFAULT_REQUEST
        )
        mockJsonDump.assert_called_once_with(
            "file.json", self._DEFAULT_RESPONSE["builds"]
        )

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    def testNotLoggedInWarning(self):
        with mock.patch("git_cl.subprocess2.call", return_value=1):
            git_cl._buildbucket_search.return_value = {}
            self.assertEqual(0, git_cl.main(["try-results"]))
            self.assertIn(
                "Warning: Some results might be missing because you are not "
                "logged in. Please login first by running:\n  bb auth-login\n",
                sys.stdout.getvalue(),
            )

    def test_filter_failed_for_one_simple(self):
        self.assertEqual([], git_cl._filter_failed_for_retry([]))
        self.assertEqual(
            [
                ("chromium", "try", "bot_failure"),
                ("chromium", "try", "bot_infra_failure"),
            ],
            git_cl._filter_failed_for_retry(self._DEFAULT_RESPONSE["builds"]),
        )

    def test_filter_failed_for_retry_many_builds(self):
        def _build(name, created_sec, status, experimental=False):
            assert 0 <= created_sec < 100, created_sec
            b = {
                "id": 112112,
                "builder": {
                    "project": "chromium",
                    "bucket": "try",
                    "builder": name,
                },
                "createTime": "2019-10-09T08:00:%02d.854286Z" % created_sec,
                "status": status,
                "tags": [],
            }
            if experimental:
                b["tags"].append({"key": "cq_experimental", "value": "true"})
            return b

        builds = [
            _build("flaky-last-green", 1, "FAILURE"),
            _build("flaky-last-green", 2, "SUCCESS"),
            _build("flaky", 1, "SUCCESS"),
            _build("flaky", 2, "FAILURE"),
            _build("running", 1, "FAILED"),
            _build("running", 2, "SCHEDULED"),
            _build("yep-still-running", 1, "STARTED"),
            _build("yep-still-running", 2, "FAILURE"),
            _build("cq-experimental", 1, "SUCCESS", experimental=True),
            _build("cq-experimental", 2, "FAILURE", experimental=True),
            # Simulate experimental in CQ builder, which developer decided
            # to retry manually which resulted in 2nd build non-experimental.
            _build("sometimes-experimental", 1, "FAILURE", experimental=True),
            _build("sometimes-experimental", 2, "FAILURE", experimental=False),
        ]
        builds.sort(key=lambda b: b["status"])  # ~deterministic shuffle.
        self.assertEqual(
            [
                ("chromium", "try", "flaky"),
                ("chromium", "try", "sometimes-experimental"),
            ],
            git_cl._filter_failed_for_retry(builds),
        )


class CMDTryTestCase(CMDTestCaseBase):
    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    @mock.patch("git_cl.Changelist.SetCQState")
    def testSetCQDryRunByDefault(self, mockSetCQState):
        mockSetCQState.return_value = 0
        self.assertEqual(0, git_cl.main(["try"]))
        git_cl.Changelist.SetCQState.assert_called_with(git_cl._CQState.DRY_RUN)
        self.assertEqual(
            sys.stdout.getvalue(),
            "Scheduling CQ dry run on: "
            "https://chromium-review.googlesource.com/123456\n",
        )

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    @mock.patch("git_cl._buildbucket_batch")
    def testScheduleOnBuildbucket(self, mockCallBuildbucket):
        mockCallBuildbucket.return_value = {}

        self.assertEqual(
            0,
            git_cl.main(
                [
                    "try",
                    "-B",
                    "luci.chromium.try",
                    "-b",
                    "win",
                    "-p",
                    "key=val",
                    "-p",
                    'json=[{"a":1}, null]',
                ]
            ),
        )
        self.assertIn(
            "Scheduling jobs on:\n  chromium/try: win",
            git_cl.sys.stdout.getvalue(),
        )

        expected_request = {
            "requests": [
                {
                    "scheduleBuild": {
                        "requestId": "uuid4",
                        "builder": {
                            "project": "chromium",
                            "builder": "win",
                            "bucket": "try",
                        },
                        "gerritChanges": [
                            {
                                "project": "depot_tools",
                                "host": "chromium-review.googlesource.com",
                                "patchset": 7,
                                "change": 123456,
                            }
                        ],
                        "properties": {
                            "category": "git_cl_try",
                            "json": [{"a": 1}, None],
                            "key": "val",
                        },
                        "tags": [
                            {"value": "win", "key": "builder"},
                            {"value": "git_cl_try", "key": "user_agent"},
                        ],
                    },
                }
            ],
        }
        mockCallBuildbucket.assert_called_with(
            "cr-buildbucket.appspot.com",
            schedules=[
                req["scheduleBuild"] for req in expected_request["requests"]
            ],
        )

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    @mock.patch("git_cl._buildbucket_batch")
    def testScheduleOnBuildbucketWithRevision(self, mockCallBuildbucket):
        mockCallBuildbucket.return_value = {}
        mock.patch(
            "git_cl.Changelist.GetRemoteBranch",
            return_value=("origin", "refs/remotes/origin/main"),
        ).start()

        self.assertEqual(
            0,
            git_cl.main(
                [
                    "try",
                    "-B",
                    "luci.chromium.try",
                    "-b",
                    "win",
                    "-b",
                    "linux",
                    "-p",
                    "key=val",
                    "-p",
                    'json=[{"a":1}, null]',
                    "-r",
                    "beeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeef",
                ]
            ),
        )
        self.assertIn(
            "Scheduling jobs on:\n  chromium/try: linux\n  chromium/try: win",
            git_cl.sys.stdout.getvalue(),
        )

        expected_request = {
            "requests": [
                {
                    "scheduleBuild": {
                        "requestId": "uuid4",
                        "builder": {
                            "project": "chromium",
                            "builder": "linux",
                            "bucket": "try",
                        },
                        "gerritChanges": [
                            {
                                "project": "depot_tools",
                                "host": "chromium-review.googlesource.com",
                                "patchset": 7,
                                "change": 123456,
                            }
                        ],
                        "properties": {
                            "category": "git_cl_try",
                            "json": [{"a": 1}, None],
                            "key": "val",
                        },
                        "tags": [
                            {"value": "linux", "key": "builder"},
                            {"value": "git_cl_try", "key": "user_agent"},
                        ],
                        "gitilesCommit": {
                            "host": "chromium.googlesource.com",
                            "project": "depot_tools",
                            "id": "beeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeef",
                            "ref": "refs/heads/main",
                        },
                    },
                },
                {
                    "scheduleBuild": {
                        "requestId": "uuid4",
                        "builder": {
                            "project": "chromium",
                            "builder": "win",
                            "bucket": "try",
                        },
                        "gerritChanges": [
                            {
                                "project": "depot_tools",
                                "host": "chromium-review.googlesource.com",
                                "patchset": 7,
                                "change": 123456,
                            }
                        ],
                        "properties": {
                            "category": "git_cl_try",
                            "json": [{"a": 1}, None],
                            "key": "val",
                        },
                        "tags": [
                            {"value": "win", "key": "builder"},
                            {"value": "git_cl_try", "key": "user_agent"},
                        ],
                        "gitilesCommit": {
                            "host": "chromium.googlesource.com",
                            "project": "depot_tools",
                            "id": "beeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeef",
                            "ref": "refs/heads/main",
                        },
                    },
                },
            ],
        }
        mockCallBuildbucket.assert_called_with(
            "cr-buildbucket.appspot.com",
            schedules=[
                req["scheduleBuild"] for req in expected_request["requests"]
            ],
        )

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    @mock.patch("sys.stderr", io.StringIO())
    def testScheduleOnBuildbucket_WrongBucket(self):
        with self.assertRaises(SystemExit):
            git_cl.main(
                [
                    "try",
                    "-B",
                    "not-a-bucket",
                    "-b",
                    "win",
                    "-p",
                    "key=val",
                    "-p",
                    'json=[{"a":1}, null]',
                ]
            )
        self.assertIn("Invalid bucket: not-a-bucket.", sys.stderr.getvalue())

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    @mock.patch("git_cl._buildbucket_batch")
    @mock.patch("git_cl._fetch_tryjobs")
    def testScheduleOnBuildbucketRetryFailed(
        self, mockFetchTryJobs, mockCallBuildbucket
    ):
        git_cl._fetch_tryjobs.side_effect = lambda *_, **kw: {
            7: [],
            6: [
                {
                    "id": 112112,
                    "builder": {
                        "project": "chromium",
                        "bucket": "try",
                        "builder": "linux",
                    },
                    "createTime": "2019-10-09T08:00:01.854286Z",
                    "tags": [],
                    "status": "FAILURE",
                }
            ],
        }[kw["patchset"]]
        mockCallBuildbucket.return_value = {}

        self.assertEqual(0, git_cl.main(["try", "--retry-failed"]))
        self.assertIn(
            "Scheduling jobs on:\n  chromium/try: linux",
            git_cl.sys.stdout.getvalue(),
        )

        expected_request = {
            "requests": [
                {
                    "scheduleBuild": {
                        "requestId": "uuid4",
                        "builder": {
                            "project": "chromium",
                            "bucket": "try",
                            "builder": "linux",
                        },
                        "gerritChanges": [
                            {
                                "project": "depot_tools",
                                "host": "chromium-review.googlesource.com",
                                "patchset": 7,
                                "change": 123456,
                            }
                        ],
                        "properties": {
                            "category": "git_cl_try",
                        },
                        "tags": [
                            {"value": "linux", "key": "builder"},
                            {"value": "git_cl_try", "key": "user_agent"},
                            {"value": "1", "key": "retry_failed"},
                        ],
                    },
                }
            ],
        }
        mockCallBuildbucket.assert_called_with(
            "cr-buildbucket.appspot.com",
            schedules=[
                req["scheduleBuild"] for req in expected_request["requests"]
            ],
        )

    def test_parse_bucket(self):
        test_cases = [
            {
                "bucket": "chromium/try",
                "result": ("chromium", "try"),
            },
            {
                "bucket": "luci.chromium.try",
                "result": ("chromium", "try"),
                "has_warning": True,
            },
            {
                "bucket": "skia.primary",
                "result": ("skia", "skia.primary"),
                "has_warning": True,
            },
            {
                "bucket": "not-a-bucket",
                "result": (None, None),
            },
        ]

        for test_case in test_cases:
            git_cl.sys.stdout.truncate(0)
            self.assertEqual(
                test_case["result"], git_cl._parse_bucket(test_case["bucket"])
            )
            if test_case.get("has_warning"):
                expected_warning = (
                    "WARNING Please use %s/%s to specify the bucket"
                    % (test_case["result"])
                )
                self.assertIn(expected_warning, git_cl.sys.stdout.getvalue())


class CMDUploadTestCase(CMDTestCaseBase):
    def setUp(self):
        super(CMDUploadTestCase, self).setUp()
        mock.patch("git_cl._fetch_tryjobs").start()
        mock.patch("git_cl._trigger_tryjobs", return_value={}).start()
        mock.patch("git_cl.Changelist.CMDUpload", return_value=0).start()
        mock.patch("git_cl.Settings.GetRoot", return_value="").start()
        mock.patch(
            "git_cl.Settings.GetSquashGerritUploads", return_value=True
        ).start()
        self.addCleanup(mock.patch.stopall)


class MakeRequestsHelperTestCase(unittest.TestCase):
    def exampleGerritChange(self):
        return {
            "host": "chromium-review.googlesource.com",
            "project": "depot_tools",
            "change": 1,
            "patchset": 2,
        }

    def testMakeRequestsHelperNoOptions(self):
        # Basic test for the helper function _make_tryjob_schedule_requests;
        # it shouldn't throw AttributeError even when options doesn't have any
        # of the expected values; it will use default option values.
        changelist = ChangelistMock(gerrit_change=self.exampleGerritChange())
        jobs = [("chromium", "try", "my-builder")]
        options = optparse.Values()
        requests = git_cl._make_tryjob_schedule_requests(
            changelist, jobs, options, patchset=None
        )

        # requestId is non-deterministic. Just assert that it's there and has
        # a particular length.
        self.assertEqual(len(requests[0].pop("requestId")), 36)
        self.assertEqual(
            requests,
            [
                {
                    "builder": {
                        "bucket": "try",
                        "builder": "my-builder",
                        "project": "chromium",
                    },
                    "gerritChanges": [self.exampleGerritChange()],
                    "properties": {"category": "git_cl_try"},
                    "tags": [
                        {"key": "builder", "value": "my-builder"},
                        {"key": "user_agent", "value": "git_cl_try"},
                    ],
                }
            ],
        )

    def testMakeRequestsHelperPresubmitSetsDryRunProperty(self):
        changelist = ChangelistMock(gerrit_change=self.exampleGerritChange())
        jobs = [("chromium", "try", "presubmit")]
        options = optparse.Values()
        requests = git_cl._make_tryjob_schedule_requests(
            changelist, jobs, options, patchset=None
        )
        self.assertEqual(
            requests[0]["properties"],
            {"category": "git_cl_try", "dry_run": "true"},
        )

    def testMakeRequestsHelperRevisionSet(self):
        # Gitiles commit is specified when revision is in options.
        changelist = ChangelistMock(gerrit_change=self.exampleGerritChange())
        jobs = [("chromium", "try", "my-builder")]
        options = optparse.Values({"revision": "ba5eba11"})
        requests = git_cl._make_tryjob_schedule_requests(
            changelist, jobs, options, patchset=None
        )
        self.assertEqual(
            requests[0]["gitilesCommit"],
            {
                "host": "chromium.googlesource.com",
                "id": "ba5eba11",
                "project": "depot_tools",
                "ref": "refs/heads/main",
            },
        )

    def testMakeRequestsHelperRetryFailedSet(self):
        # An extra tag is added when retry_failed is in options.
        changelist = ChangelistMock(gerrit_change=self.exampleGerritChange())
        jobs = [("chromium", "try", "my-builder")]
        options = optparse.Values({"retry_failed": "true"})
        requests = git_cl._make_tryjob_schedule_requests(
            changelist, jobs, options, patchset=None
        )
        self.assertEqual(
            requests[0]["tags"],
            [
                {"key": "builder", "value": "my-builder"},
                {"key": "user_agent", "value": "git_cl_try"},
                {"key": "retry_failed", "value": "1"},
            ],
        )

    def testMakeRequestsHelperCategorySet(self):
        # The category property can be overridden with options.
        changelist = ChangelistMock(gerrit_change=self.exampleGerritChange())
        jobs = [("chromium", "try", "my-builder")]
        options = optparse.Values({"category": "my-special-category"})
        requests = git_cl._make_tryjob_schedule_requests(
            changelist, jobs, options, patchset=None
        )
        self.assertEqual(
            requests[0]["properties"], {"category": "my-special-category"}
        )


@unittest.skipIf(
    gclient_utils.IsEnvCog(), "not supported in non-git environment"
)
class CMDStatusTestCase(CMDTestCaseBase):
    # Return branch names a,..,f with comitterdates in increasing order, i.e.
    # 'f' is the most-recently changed branch.
    def _mock_run_git(commands):
        if commands == [
            "for-each-ref",
            "--format=%(refname) %(committerdate:unix)",
            "refs/heads",
        ]:
            branches_and_committerdates = [
                "refs/heads/a 1",
                "refs/heads/b 2",
                "refs/heads/c 3",
                "refs/heads/d 4",
                "refs/heads/e 5",
                "refs/heads/f 6",
            ]
            return "\n".join(branches_and_committerdates)

    # Mock the status in such a way that the issue number gives us an
    # indication of the commit date (simplifies manual debugging).
    def _mock_get_cl_statuses(branches, fine_grained, max_processes):
        for c in branches:
            c.issue = 100 + int(c.GetCommitDate())
            yield (c, "open")

    @mock.patch("git_cl.Changelist.EnsureAuthenticated")
    @mock.patch("git_cl.Changelist.FetchDescription", lambda cl, pretty: "x")
    @mock.patch("git_cl.Changelist.GetIssue", lambda cl: cl.issue)
    @mock.patch("git_cl.RunGit", _mock_run_git)
    @mock.patch("git_cl.get_cl_statuses", _mock_get_cl_statuses)
    @mock.patch("git_cl.Settings.GetRoot", return_value="")
    @mock.patch("git_cl.Settings.IsStatusCommitOrderByDate", return_value=False)
    @mock.patch("scm.GIT.GetBranch", return_value="a")
    def testStatus(self, *_mocks):
        self.assertEqual(0, git_cl.main(["status", "--no-branch-color"]))
        self.maxDiff = None
        self.assertEqual(
            sys.stdout.getvalue(),
            "Branches associated with reviews:\n"
            "    * a : https://crrev.com/c/101 (open)\n"
            "      b : https://crrev.com/c/102 (open)\n"
            "      c : https://crrev.com/c/103 (open)\n"
            "      d : https://crrev.com/c/104 (open)\n"
            "      e : https://crrev.com/c/105 (open)\n"
            "      f : https://crrev.com/c/106 (open)\n\n"
            "Current branch: a\n"
            "Issue number: 101 (https://chromium-review.googlesource.com/101)\n"
            "Issue description:\n"
            "x\n",
        )

    @mock.patch("git_cl.Changelist.EnsureAuthenticated")
    @mock.patch("git_cl.Changelist.FetchDescription", lambda cl, pretty: "x")
    @mock.patch("git_cl.Changelist.GetIssue", lambda cl: cl.issue)
    @mock.patch("git_cl.RunGit", _mock_run_git)
    @mock.patch("git_cl.get_cl_statuses", _mock_get_cl_statuses)
    @mock.patch("git_cl.Settings.GetRoot", return_value="")
    @mock.patch("git_cl.Settings.IsStatusCommitOrderByDate", return_value=False)
    @mock.patch("scm.GIT.GetBranch", return_value="a")
    def testStatusByDate(self, *_mocks):
        self.assertEqual(
            0, git_cl.main(["status", "--no-branch-color", "--date-order"])
        )
        self.maxDiff = None
        self.assertEqual(
            sys.stdout.getvalue(),
            "Branches associated with reviews:\n"
            "      f : https://crrev.com/c/106 (open)\n"
            "      e : https://crrev.com/c/105 (open)\n"
            "      d : https://crrev.com/c/104 (open)\n"
            "      c : https://crrev.com/c/103 (open)\n"
            "      b : https://crrev.com/c/102 (open)\n"
            "    * a : https://crrev.com/c/101 (open)\n\n"
            "Current branch: a\n"
            "Issue number: 101 (https://chromium-review.googlesource.com/101)\n"
            "Issue description:\n"
            "x\n",
        )

    @mock.patch("git_cl.Changelist.EnsureAuthenticated")
    @mock.patch("git_cl.Changelist.FetchDescription", lambda cl, pretty: "x")
    @mock.patch("git_cl.Changelist.GetIssue", lambda cl: cl.issue)
    @mock.patch("git_cl.RunGit", _mock_run_git)
    @mock.patch("git_cl.get_cl_statuses", _mock_get_cl_statuses)
    @mock.patch("git_cl.Settings.GetRoot", return_value="")
    @mock.patch("git_cl.Settings.IsStatusCommitOrderByDate", return_value=True)
    @mock.patch("scm.GIT.GetBranch", return_value="a")
    def testStatusByDate(self, *_mocks):  # noqa: F811
        self.assertEqual(0, git_cl.main(["status", "--no-branch-color"]))
        self.maxDiff = None
        self.assertEqual(
            sys.stdout.getvalue(),
            "Branches associated with reviews:\n"
            "      f : https://crrev.com/c/106 (open)\n"
            "      e : https://crrev.com/c/105 (open)\n"
            "      d : https://crrev.com/c/104 (open)\n"
            "      c : https://crrev.com/c/103 (open)\n"
            "      b : https://crrev.com/c/102 (open)\n"
            "    * a : https://crrev.com/c/101 (open)\n\n"
            "Current branch: a\n"
            "Issue number: 101 (https://chromium-review.googlesource.com/101)\n"
            "Issue description:\n"
            "x\n",
        )


@unittest.skipIf(
    gclient_utils.IsEnvCog(), "not supported in non-git environment"
)
class CMDOwnersTestCase(CMDTestCaseBase):
    def setUp(self):
        super(CMDOwnersTestCase, self).setUp()
        self.owners_by_path = {
            "foo": ["a@example.com"],
            "bar": ["b@example.com", "c@example.com"],
        }
        mock.patch("git_cl.Settings.GetRoot", return_value="root").start()
        mock.patch("git_cl.Changelist.GetAuthor", return_value="author").start()
        mock.patch(
            "git_cl.Changelist.GetAffectedFiles",
            return_value=list(self.owners_by_path),
        ).start()
        mock.patch(
            "git_cl.Changelist.GetCommonAncestorWithUpstream",
            return_value="upstream",
        ).start()
        mock.patch(
            "git_cl.Changelist.GetGerritHost", return_value="host"
        ).start()
        mock.patch(
            "git_cl.Changelist.GetGerritProject", return_value="project"
        ).start()
        mock.patch(
            "git_cl.Changelist.GetRemoteBranch",
            return_value=("origin", "refs/remotes/origin/main"),
        ).start()
        mock.patch(
            "owners_client.OwnersClient.BatchListOwners",
            return_value=self.owners_by_path,
        ).start()
        mock.patch(
            "gerrit_util.IsCodeOwnersEnabledOnHost", return_value=True
        ).start()
        self.addCleanup(mock.patch.stopall)

    def testShowAllNoArgs(self):
        self.assertEqual(0, git_cl.main(["owners", "--show-all"]))
        self.assertEqual(
            "No files specified for --show-all. Nothing to do.\n",
            git_cl.sys.stdout.getvalue(),
        )

    def testShowAll(self):
        self.assertEqual(
            0, git_cl.main(["owners", "--show-all", "foo", "bar", "baz"])
        )
        owners_client.OwnersClient.BatchListOwners.assert_called_once_with(
            ["foo", "bar", "baz"]
        )
        self.assertEqual(
            "\n".join(
                [
                    "Owners for foo:",
                    " - a@example.com",
                    "Owners for bar:",
                    " - b@example.com",
                    " - c@example.com",
                    "Owners for baz:",
                    " - No owners found",
                    "",
                ]
            ),
            sys.stdout.getvalue(),
        )

    @mock.patch("owners_finder.OwnersFinder")
    @mock.patch("git_cl.Changelist.GetIssue", return_value=None)
    def testNoIssueNumber(self, _mock_get_issue, mock_owners_finder):
        mock_owners_finder.return_value.run.return_value = 0
        self.assertEqual(0, git_cl.main(["owners"]))
        mock_owners_finder.assert_called_once_with(
            ["foo", "bar"],
            "author",
            [],
            mock.ANY,
            disable_color=None,
            ignore_author=None,
        )

    def testBatch(self):
        self.assertEqual(0, git_cl.main(["owners", "--batch"]))
        self.assertIn("a@example.com", sys.stdout.getvalue())
        self.assertIn("b@example.com", sys.stdout.getvalue())


class CMDLintTestCase(CMDTestCaseBase):
    bad_indent = "\n".join(
        [
            "// Copyright 1999 <a@example.com>",
            "namespace foo {",
            "  class a;",
            "}",
            "",
        ]
    )
    filesInCL = ["foo", "bar"]

    def setUp(self):
        super(CMDLintTestCase, self).setUp()
        mock.patch("git_cl.sys.stderr", io.StringIO()).start()
        mock.patch("codecs.open", mock.mock_open()).start()
        mock.patch("os.path.isfile", return_value=True).start()

    def testLintSingleFile(self, *_mock):
        codecs.open().read.return_value = self.bad_indent
        self.assertEqual(1, git_cl.main(["lint", "pdf.h"]))
        self.assertIn(
            "pdf.h:3:  (cpplint) Do not indent within a namespace",
            git_cl.sys.stderr.getvalue(),
        )

    def testLintMultiFiles(self, *_mock):
        codecs.open().read.return_value = self.bad_indent
        self.assertEqual(1, git_cl.main(["lint", "pdf.h", "pdf.cc"]))
        self.assertIn(
            "pdf.h:3:  (cpplint) Do not indent within a namespace",
            git_cl.sys.stderr.getvalue(),
        )
        self.assertIn(
            "pdf.cc:3:  (cpplint) Do not indent within a namespace",
            git_cl.sys.stderr.getvalue(),
        )

    @unittest.skipIf(
        gclient_utils.IsEnvCog(), "not supported in non-git environment"
    )
    @mock.patch(
        "git_cl.Changelist.GetAffectedFiles",
        return_value=["chg-1.h", "chg-2.cc"],
    )
    @mock.patch(
        "git_cl.Changelist.GetCommonAncestorWithUpstream",
        return_value="upstream",
    )
    @mock.patch("git_cl.Settings.GetRoot", return_value=".")
    @mock.patch("git_cl_core.FindCodereviewSettingsFile", return_value=None)
    def testLintChangelist(self, *_mock):
        codecs.open().read.return_value = self.bad_indent
        self.assertEqual(1, git_cl.main(["lint"]))
        self.assertIn(
            "chg-1.h:3:  (cpplint) Do not indent within a namespace",
            git_cl.sys.stderr.getvalue(),
        )
        self.assertIn(
            "chg-2.cc:3:  (cpplint) Do not indent within a namespace",
            git_cl.sys.stderr.getvalue(),
        )


class CMDCherryPickTestCase(CMDTestCaseBase):
    def testCreateCommitMessage(self):
        orig_message = """Foo the bar

This change foo's the bar.

Bug: 123456
Change-Id: I25699146b24c7ad8776f17775f489b9d41499595
"""
        expected_message = """Cherry pick "Foo the bar"

Original change's description:
> Foo the bar
>
> This change foo's the bar.
>
> Bug: 123456
> Change-Id: I25699146b24c7ad8776f17775f489b9d41499595

Change-Id: I25699146b24c7ad8776f17775f489b9d41499595
"""
        self.assertEqual(
            git_cl._create_commit_message(orig_message), expected_message
        )

    def testCreateCommitMessageWithBug(self):
        bug = "987654"
        orig_message = """Foo the bar

This change foo's the bar.

Bug: 123456
Change-Id: I25699146b24c7ad8776f17775f489b9d41499595
"""
        expected_message = f"""Cherry pick "Foo the bar"

Original change's description:
> Foo the bar
>
> This change foo's the bar.
>
> Bug: 123456
> Change-Id: I25699146b24c7ad8776f17775f489b9d41499595

Bug: {bug}
Change-Id: I25699146b24c7ad8776f17775f489b9d41499595
"""
        self.assertEqual(
            git_cl._create_commit_message(orig_message, bug), expected_message
        )

    @mock.patch("gerrit_util.QueryChanges")
    @mock.patch("gerrit_util.CherryPick")
    @mock.patch("gerrit_util.GetChangePageUrl", return_value="url")
    def testCherryPick_AllowConflicts(
        self, _mockGetUrl, mockCherryPick, mockQueryChanges
    ):
        mockQueryChanges.return_value = [
            {
                "id": "change_id",
                "revisions": {"abc": {"commit": {"message": "msg"}}},
            }
        ]
        mockCherryPick.return_value = {
            "_number": 123,
            "contains_git_conflicts": True,
        }

        self.assertEqual(
            0,
            git_cl.main(
                ["cherry-pick", "--branch", "main", "abc", "--allow-conflicts"]
            ),
        )

        expected_message = (
            'Cherry pick "msg"\n\nOriginal change\'s description:\n> msg\n\n'
        )
        mockCherryPick.assert_called_once_with(
            "chromium-review.googlesource.com",
            "change_id",
            "main",
            message=expected_message,
            base=None,
            allow_conflicts=True,
        )
        self.assertIn(
            "Warning: Change url contains merge conflicts",
            sys.stdout.getvalue(),
        )

    @mock.patch("gerrit_util.QueryChanges")
    @mock.patch("gerrit_util.CherryPick")
    @mock.patch("gerrit_util.GetChangePageUrl", return_value="url")
    def testCherryPick_NoAllowConflicts(
        self, _mockGetUrl, mockCherryPick, mockQueryChanges
    ):
        mockQueryChanges.return_value = [
            {
                "id": "change_id",
                "current_revision": "abc",
                "revisions": {"abc": {"commit": {"message": "msg"}}},
            }
        ]
        mockCherryPick.return_value = {"_number": 123}

        self.assertEqual(
            0, git_cl.main(["cherry-pick", "--branch", "main", "abc"])
        )

        expected_message = (
            'Cherry pick "msg"\n\nOriginal change\'s description:\n> msg\n\n'
        )
        mockCherryPick.assert_called_once_with(
            "chromium-review.googlesource.com",
            "change_id",
            "main",
            message=expected_message,
            base=None,
            allow_conflicts=False,
        )

    @mock.patch("gclient_utils.AskForData")
    @mock.patch("gerrit_util.QueryChanges")
    @mock.patch("gerrit_util.CherryPick")
    @mock.patch("gerrit_util.GetChangePageUrl", return_value="url")
    def testCherryPick_ConflictPromptYes(
        self, _mockGetUrl, mockCherryPick, mockQueryChanges, mockAskForData
    ):
        mockQueryChanges.return_value = [
            {
                "id": "change_id",
                "revisions": {"abc": {"commit": {"message": "msg"}}},
            }
        ]
        mockCherryPick.side_effect = [
            gerrit_util.GerritError(409, "Conflict"),
            {"_number": 123},
        ]
        # First call returns '', second returns 'maybe', third returns 'y'
        mockAskForData.side_effect = ["", "maybe", "y"]

        self.assertEqual(
            0, git_cl.main(["cherry-pick", "--branch", "main", "abc"])
        )

        expected_message = (
            'Cherry pick "msg"\n\nOriginal change\'s description:\n> msg\n\n'
        )
        mockCherryPick.assert_has_calls(
            [
                mock.call(
                    "chromium-review.googlesource.com",
                    "change_id",
                    "main",
                    message=expected_message,
                    base=None,
                    allow_conflicts=False,
                ),
                mock.call(
                    "chromium-review.googlesource.com",
                    "change_id",
                    "main",
                    message=expected_message,
                    base=None,
                    allow_conflicts=True,
                ),
            ]
        )
        self.assertEqual(mockAskForData.call_count, 3)

    @mock.patch("gclient_utils.AskForData")
    @mock.patch("gerrit_util.QueryChanges")
    @mock.patch("gerrit_util.CherryPick")
    @mock.patch("gerrit_util.GetChangePageUrl", return_value="url")
    def testCherryPick_ConflictPromptNo(
        self, _mockGetUrl, mockCherryPick, mockQueryChanges, mockAskForData
    ):
        mockQueryChanges.return_value = [
            {
                "id": "change_id",
                "revisions": {"abc": {"commit": {"message": "msg"}}},
            }
        ]
        mockCherryPick.side_effect = gerrit_util.GerritError(409, "Conflict")
        mockAskForData.return_value = "n"

        self.assertEqual(
            1, git_cl.main(["cherry-pick", "--branch", "main", "abc"])
        )

        expected_message = (
            'Cherry pick "msg"\n\nOriginal change\'s description:\n> msg\n\n'
        )
        mockCherryPick.assert_called_once_with(
            "chromium-review.googlesource.com",
            "change_id",
            "main",
            message=expected_message,
            base=None,
            allow_conflicts=False,
        )

    @mock.patch("gerrit_util.QueryChanges")
    @mock.patch("gerrit_util.CherryPick")
    @mock.patch("gerrit_util.AddReviewers")
    @mock.patch("gerrit_util.SetReview")
    @mock.patch("gerrit_util.GetChangePageUrl", return_value="url")
    def testCherryPick_CQDryRun(
        self,
        _mockGetUrl,
        mockSetReview,
        mockAddReviewers,
        mockCherryPick,
        mockQueryChanges,
    ):
        mockQueryChanges.return_value = [
            {
                "id": "change_id",
                "current_revision": "abc",
                "revisions": {"abc": {"commit": {"message": "msg"}}},
            }
        ]
        mockCherryPick.return_value = {"_number": 123}

        self.assertEqual(
            0,
            git_cl.main(
                ["cherry-pick", "--branch", "main", "abc", "--cq-dry-run"]
            ),
        )

        expected_message = (
            'Cherry pick "msg"\n\nOriginal change\'s description:\n> msg\n\n'
        )
        mockCherryPick.assert_called_once_with(
            "chromium-review.googlesource.com",
            "change_id",
            "main",
            message=expected_message,
            base=None,
            allow_conflicts=False,
        )
        mockAddReviewers.assert_not_called()
        mockSetReview.assert_called_once_with(
            "chromium-review.googlesource.com", 123, labels={"Commit-Queue": 1}
        )

    @mock.patch("gerrit_util.QueryChanges")
    @mock.patch("gerrit_util.CherryPick")
    @mock.patch("gerrit_util.AddReviewers")
    @mock.patch("gerrit_util.GetChangePageUrl", return_value="url")
    def testCherryPick_WithMilestone(
        self, _mockGetUrl, mockAddReviewers, mockCherryPick, mockQueryChanges
    ):
        mockQueryChanges.return_value = [
            {
                "id": "change_id",
                "current_revision": "abc",
                "revisions": {"abc": {"commit": {"message": "msg"}}},
            }
        ]
        mockCherryPick.return_value = {"_number": 123}

        self.assertEqual(
            0,
            git_cl.main(
                ["cherry-pick", "--branch", "main", "abc", "--milestone", "123"]
            ),
        )

        expected_message = (
            "[123] msg\n\nOriginal change's description:\n> msg\n\n"
        )
        mockCherryPick.assert_called_once_with(
            "chromium-review.googlesource.com",
            "change_id",
            "main",
            message=expected_message,
            base=None,
            allow_conflicts=False,
        )
        mockAddReviewers.assert_not_called()

    @mock.patch("gerrit_util.QueryChanges")
    @mock.patch("gerrit_util.CherryPick")
    @mock.patch("gerrit_util.AddReviewers")
    @mock.patch("gerrit_util.GetChangePageUrl", return_value="url")
    def testCherryPick_WithRubberStamper(
        self, _mockGetUrl, mockAddReviewers, mockCherryPick, mockQueryChanges
    ):
        mockQueryChanges.return_value = [
            {
                "id": "change_id",
                "current_revision": "abc",
                "revisions": {"abc": {"commit": {"message": "msg"}}},
            }
        ]
        mockCherryPick.return_value = {"_number": 123}

        self.assertEqual(
            0,
            git_cl.main(
                ["cherry-pick", "--branch", "main", "abc", "--rubber-stamper"]
            ),
        )

        expected_message = (
            'Cherry pick "msg"\n\nOriginal change\'s description:\n> msg\n\n'
        )
        mockCherryPick.assert_called_once_with(
            "chromium-review.googlesource.com",
            "change_id",
            "main",
            message=expected_message,
            base=None,
            allow_conflicts=False,
        )
        mockAddReviewers.assert_called_once_with(
            "chromium-review.googlesource.com",
            123,
            reviewers=["rubber-stamper@appspot.gserviceaccount.com"],
        )


@unittest.skipIf(
    gclient_utils.IsEnvCog(), "not supported in non-git environment"
)
class CMDSplitTestCase(CMDTestCaseBase):
    def setUp(self):
        super(CMDTestCaseBase, self).setUp()
        self.mock_get_root = mock.patch(
            "git_cl.Settings.GetRoot", return_value="root"
        ).start()
        self.mock_split_cl = mock.patch(
            "split_cl.SplitCl", return_value=0
        ).start()
        self.mock_parser_error = mock.patch(
            "git_cl.OptionParser.error", side_effect=ParserErrorMock
        ).start()

    def testDescriptionFlagRequired(self):
        # --description-file is mandatory...
        self.assertRaises(ParserErrorMock, git_cl.main, ["split"])
        self.assertEqual(self.mock_split_cl.call_count, 0)

        self.assertEqual(
            git_cl.main(["split", "--description=SomeFile.txt"]), 0
        )
        self.assertEqual(self.mock_split_cl.call_count, 1)

        # ...unless we're doing a dry run
        self.mock_split_cl.reset_mock()
        self.assertEqual(git_cl.main(["split", "-n"]), 0)
        self.assertEqual(self.mock_split_cl.call_count, 1)

    def testReviewerParsing(self):
        """Make sure we correctly parse various combinations of --reviewers"""

        # Helper function to pull out the reviewers arg and compare it
        def testOneSetOfFlags(flags, expected):
            self.assertEqual(git_cl.main(["split", "-n"] + flags), 0)
            self.mock_split_cl.assert_called_once()
            # It's unfortunate that there's no better way to get the argument
            # than to hardcode its number, unless we switch to using keyword
            # arguments everywhere or pass the options in directly.
            reviewers_arg = self.mock_split_cl.call_args.args[6]
            self.assertEqual(reviewers_arg, expected)
            self.mock_split_cl.reset_mock()

        # If no --reviewers flag is passed, we should get None
        testOneSetOfFlags([], None)
        # If --reviewers flag is passed, we should get a list of args
        testOneSetOfFlags(["--reviewers", "a@b.com"], ["a@b.com"])
        testOneSetOfFlags(
            ["--reviewers", "a@b.com", "--reviewers", "c@d.com"],
            ["a@b.com", "c@d.com"],
        )
        # If --no-reviewers flag is passed, we should always get an empty list
        testOneSetOfFlags(["--no-reviewers"], [])
        testOneSetOfFlags(["--reviewers", "a@b.com", "--no-reviewers"], [])

    def testTargetRangeParsing(self):
        # Helper function to pull out the target_range arg and compare it
        def testOneSetOfFlags(flags, expected):
            self.assertEqual(git_cl.main(["split", "-n"] + flags), 0)
            self.mock_split_cl.assert_called_once()
            # It's unfortunate that there's no better way to get the argument
            # than to hardcode its number, unless we switch to using keyword
            # arguments everywhere or pass the options in directly.
            target_range = self.mock_split_cl.call_args.args[11]
            self.assertEqual(target_range, expected)
            self.mock_split_cl.reset_mock()

        def ensureFailure(flags):
            self.assertRaises(
                ParserErrorMock, git_cl.main, (["split", "-n"] + flags)
            )

        testOneSetOfFlags([], None)
        testOneSetOfFlags(["--target-range", "3", "5"], (3, 5))
        testOneSetOfFlags(["--target-range", "3", "3"], (3, 3))
        # Can't have second arg larger than first
        ensureFailure(["--target-range", "5", "3"])
        # Need exactly two args
        ensureFailure(["--target-range"])
        ensureFailure(["--target-range", "5"])
        # Only accepts int args
        ensureFailure(["--target-range", "5", "six"])


class TestMergeCommentsIntoThreads(unittest.TestCase):
    def test_merge_comments_into_threads(self):
        # A simple linear thread.
        c1 = {
            "id": "c1",
            "updated": "2025-01-01 01:00:00.000000000",
            "in_reply_to": None,
        }
        c2 = {
            "id": "c2",
            "updated": "2025-01-01 02:00:00.000000000",
            "in_reply_to": "c1",
        }
        c3 = {
            "id": "c3",
            "updated": "2025-01-01 03:00:00.000000000",
            "in_reply_to": "c2",
        }

        # A branching thread.
        c4 = {
            "id": "c4",
            "updated": "2025-01-01 01:00:00.000000000",
            "in_reply_to": None,
        }
        c5 = {
            "id": "c5",
            "updated": "2025-01-01 02:00:00.000000000",
            "in_reply_to": "c4",
        }
        c6 = {
            "id": "c6",
            "updated": "2025-01-01 03:00:00.000000000",
            "in_reply_to": "c4",
        }

        # Independent comment.
        c7 = {
            "id": "c7",
            "updated": "2025-01-01 01:00:00.000000000",
            "in_reply_to": None,
        }

        # Comments are shuffled.
        file_comments = {
            "path/to/file": [c3, c2, c1, c6, c5, c4, c7],
        }

        threads = git_cl._merge_comments_into_threads(file_comments)

        # We expect 3 threads.
        self.assertEqual(len(threads), 3)

        # Helper to extract ids from a thread.
        def get_ids(thread):
            return [c.id for c in thread]

        threads_by_root = {thread[0].id: thread for thread in threads}

        # Check thread 1 (c1 -> c2 -> c3)
        self.assertIn("c1", threads_by_root)
        self.assertEqual(get_ids(threads_by_root["c1"]), ["c1", "c2", "c3"])

        # Check thread 2 (c4 -> c5, c6). c5 and c6 are replies to c4.
        # c5 time: 02:00, c6 time: 03:00.
        self.assertIn("c4", threads_by_root)
        self.assertEqual(get_ids(threads_by_root["c4"]), ["c4", "c5", "c6"])

        # Check thread 3 (c7)
        self.assertIn("c7", threads_by_root)
        self.assertEqual(get_ids(threads_by_root["c7"]), ["c7"])


class BuildbucketBatchTest(unittest.TestCase):
    @mock.patch("subprocess2.communicate")
    def test_buildbucket_batch_success(self, mock_comm):
        mock_comm.return_value = (
            ('{"responses": [{"scheduleBuild": {"id": "1"}}]}', ""),
            0,
        )
        res = git_cl._buildbucket_batch("cr-buildbucket.appspot.com")
        self.assertEqual(res, {"responses": [{"scheduleBuild": {"id": "1"}}]})
        mock_comm.assert_called_once_with(
            ["bb", "batch", "-host", "cr-buildbucket.appspot.com"],
            stdin='{"requests": []}',
            stdout=git_cl.subprocess2.PIPE,
            stderr=git_cl.subprocess2.PIPE,
            encoding="utf-8",
        )

    @mock.patch("subprocess2.communicate")
    def test_buildbucket_batch_error(self, mock_comm):
        mock_comm.return_value = (
            ('{"responses": [{"error": {"message": "bot fail"}}]}', ""),
            1,
        )
        with self.assertRaises(git_cl.BuildbucketResponseException) as cm:
            git_cl._buildbucket_batch("cr-buildbucket.appspot.com")
        self.assertIn("bot fail", str(cm.exception))
        self.assertEqual(len(cm.exception.responses), 1)

    @mock.patch("subprocess2.communicate")
    def test_buildbucket_search_success(self, mock_comm):
        mock_comm.return_value = (
            (
                '{"responses": [{"searchBuilds": {"builds": [{"id": "123"}]}}]}',
                "",
            ),
            0,
        )
        res = git_cl._buildbucket_search("cr-buildbucket.appspot.com", {})
        self.assertEqual(res, {"builds": [{"id": "123"}]})

    @mock.patch("subprocess2.communicate")
    def test_buildbucket_search_error(self, mock_comm):
        mock_comm.return_value = (
            ('{"responses": [{"error": {"message": "search fail"}}]}', ""),
            1,
        )
        with self.assertRaises(git_cl.BuildbucketResponseException) as cm:
            git_cl._buildbucket_search("cr-buildbucket.appspot.com", {})
        self.assertIn("search fail", str(cm.exception))


class TestFindGitDir(unittest.TestCase):
    def setUp(self):
        super(TestFindGitDir, self).setUp()
        self.test_dir = tempfile.mkdtemp()
        self.old_cwd = os.getcwd()

    def tearDown(self):
        os.chdir(self.old_cwd)
        shutil.rmtree(self.test_dir)
        super(TestFindGitDir, self).tearDown()

    def test_find_git_dir_normal(self):
        git_dir = os.path.join(self.test_dir, ".git")
        os.makedirs(git_dir)
        self.assertEqual(git_cl.FindGitDir(self.test_dir), git_dir)
        sub_dir = os.path.join(self.test_dir, "sub", "dir")
        os.makedirs(sub_dir)
        self.assertEqual(git_cl.FindGitDir(sub_dir), git_dir)

    def test_find_git_dir_worktree_absolute(self):
        target_git_dir = os.path.abspath(
            os.path.join(self.test_dir, "main_repo", ".git", "worktrees", "wt")
        )
        os.makedirs(target_git_dir)
        wt_root = os.path.join(self.test_dir, "wt_root")
        os.makedirs(wt_root)
        git_file = os.path.join(wt_root, ".git")
        with open(git_file, "w") as f:
            f.write(f"gitdir: {target_git_dir}\n")
        self.assertEqual(git_cl.FindGitDir(wt_root), target_git_dir)
        sub_dir = os.path.join(wt_root, "sub", "dir")
        os.makedirs(sub_dir)
        self.assertEqual(git_cl.FindGitDir(sub_dir), target_git_dir)

    def test_find_git_dir_worktree_relative(self):
        wt_root = os.path.join(self.test_dir, "wt_root")
        os.makedirs(wt_root)
        target_git_dir = os.path.join(self.test_dir, "target_git_dir")
        os.makedirs(target_git_dir)
        git_file = os.path.join(wt_root, ".git")
        with open(git_file, "w") as f:
            f.write("gitdir: ../target_git_dir\n")
        self.assertEqual(
            git_cl.FindGitDir(wt_root), os.path.abspath(target_git_dir)
        )

    def test_find_git_dir_not_found(self):
        self.assertIsNone(git_cl.FindGitDir(self.test_dir))
        sub_dir = os.path.join(self.test_dir, "sub", "dir")
        os.makedirs(sub_dir)
        self.assertIsNone(git_cl.FindGitDir(sub_dir))

    def test_find_git_dir_invalid_file(self):
        wt_root = os.path.join(self.test_dir, "wt_root")
        os.makedirs(wt_root)
        git_file = os.path.join(wt_root, ".git")
        with open(git_file, "w") as f:
            f.write("not a gitdir link\n")
        self.assertIsNone(git_cl.FindGitDir(wt_root))

    def test_find_git_dir_read_error(self):
        wt_root = os.path.join(self.test_dir, "wt_root")
        os.makedirs(wt_root)
        git_file = os.path.join(wt_root, ".git")
        with open(git_file, "w") as f:
            f.write("gitdir: foo\n")
        with mock.patch(
            "builtins.open", side_effect=OSError("permission denied")
        ):
            self.assertIsNone(git_cl.FindGitDir(wt_root))


class TestRunGitPushWithTraces(unittest.TestCase):
    @mock.patch("git_cl._prepare_superproject_push_option", return_value=None)
    @mock.patch(
        "git_cl.gclient_utils.CheckCallAndFilter",
        return_value=b"remote: ok",
    )
    @mock.patch(
        "git_cl.Changelist.GetRemoteUrl",
        return_value="https://example.com/repo",
    )
    @mock.patch("git_cl.Changelist._WriteGitPushTraces")
    @mock.patch("git_cl.Changelist._CleanUpOldTraces")
    @mock.patch("git_cl.gclient_utils.rmtree")
    def test_run_git_push_with_traces_success(
        self,
        mock_rmtree,
        mock_cleanup,
        mock_write_traces,
        _mock_url,
        _mock_call,
        _mock_superproject,
    ) -> None:
        cl = git_cl.Changelist()
        metadata = {}
        with mock.patch.dict(os.environ):
            os.environ.pop("GIT_CL_TRACE", None)
            out = cl._RunGitPushWithTraces("refspec", [], metadata)
        self.assertEqual(out, "remote: ok")
        self.assertEqual(mock_write_traces.call_count, 0)
        self.assertEqual(mock_cleanup.call_count, 0)
        self.assertEqual(mock_rmtree.call_count, 1)

    @mock.patch("git_cl._prepare_superproject_push_option", return_value=None)
    @mock.patch(
        "git_cl.gclient_utils.CheckCallAndFilter",
        return_value=b"remote: ok",
    )
    @mock.patch(
        "git_cl.Changelist.GetRemoteUrl",
        return_value="https://example.com/repo",
    )
    @mock.patch("git_cl.Changelist._WriteGitPushTraces")
    @mock.patch("git_cl.Changelist._CleanUpOldTraces")
    @mock.patch("git_cl.gclient_utils.rmtree")
    def test_run_git_push_with_traces_trace_env(
        self,
        mock_rmtree,
        mock_cleanup,
        mock_write_traces,
        _mock_url,
        _mock_call,
        _mock_superproject,
    ) -> None:
        cl = git_cl.Changelist()
        metadata = {}
        with mock.patch.dict(os.environ, {"GIT_CL_TRACE": "1"}):
            out = cl._RunGitPushWithTraces("refspec", [], metadata)
        self.assertEqual(out, "remote: ok")
        self.assertEqual(mock_write_traces.call_count, 1)
        self.assertEqual(mock_cleanup.call_count, 1)
        self.assertEqual(mock_rmtree.call_count, 1)

    @mock.patch("git_cl._prepare_superproject_push_option", return_value=None)
    @mock.patch(
        "git_cl.gclient_utils.CheckCallAndFilter",
        side_effect=subprocess2.CalledProcessError(
            1, ["git", "push"], "repo", b"error", b"error"
        ),
    )
    @mock.patch(
        "git_cl.Changelist.GetRemoteUrl",
        return_value="https://example.com/repo",
    )
    @mock.patch("git_cl.Changelist._WriteGitPushTraces")
    @mock.patch("git_cl.Changelist._CleanUpOldTraces")
    @mock.patch("git_cl.gclient_utils.rmtree")
    def test_run_git_push_with_traces_failure(
        self,
        mock_rmtree,
        mock_cleanup,
        mock_write_traces,
        _mock_url,
        _mock_call,
        _mock_superproject,
    ) -> None:
        cl = git_cl.Changelist()
        metadata = {}
        with mock.patch.dict(os.environ):
            os.environ.pop("GIT_CL_TRACE", None)
            with self.assertRaises(git_cl.GitPushError):
                cl._RunGitPushWithTraces("refspec", [], metadata)
        self.assertEqual(mock_write_traces.call_count, 1)
        self.assertEqual(mock_cleanup.call_count, 1)
        self.assertEqual(mock_rmtree.call_count, 1)

    @mock.patch(
        "git_cl._prepare_superproject_push_option",
        return_value="custom-keyed-value=rootRepo:chromium/chromium/src@d3adb33f",
    )
    @mock.patch(
        "git_cl.gclient_utils.CheckCallAndFilter",
        return_value=b"remote: ok",
    )
    @mock.patch(
        "git_cl.Changelist.GetRemoteUrl",
        return_value="https://example.com/repo",
    )
    @mock.patch("git_cl.Changelist._WriteGitPushTraces")
    @mock.patch("git_cl.Changelist._CleanUpOldTraces")
    @mock.patch("git_cl.gclient_utils.rmtree")
    def test_run_git_push_with_traces_with_superproject_push_option(
        self,
        mock_rmtree,
        mock_cleanup,
        mock_write_traces,
        _mock_url,
        mock_call,
        _mock_superproject,
    ) -> None:
        cl = git_cl.Changelist()
        metadata = {}
        with mock.patch.dict(os.environ):
            os.environ.pop("GIT_CL_TRACE", None)
            out = cl._RunGitPushWithTraces(
                "refspec",
                [],
                metadata,
                git_push_options=["user_opt=1"],
            )
        self.assertEqual(out, "remote: ok")
        self.assertEqual(mock_call.call_count, 1)
        push_cmd = mock_call.call_args[0][0]
        self.assertEqual(push_cmd.count("-o"), 2)
        self.assertIn("user_opt=1", push_cmd)
        self.assertIn(
            "custom-keyed-value=rootRepo:chromium/chromium/src@d3adb33f",
            push_cmd,
        )


if __name__ == "__main__":
    logging.basicConfig(
        level=logging.DEBUG if "-v" in sys.argv else logging.ERROR
    )
    unittest.main()
