import copy
import datetime
from pathlib import Path
from unittest.mock import call, patch

import arrow
import dateutil
from gitlint.git import (
    GitChangedFileStats,
    GitCommit,
    GitCommitMessage,
    GitContext,
    GitContextError,
    LocalGitCommit,
    StagedLocalGitCommit,
)
from gitlint.shell import ErrorReturnCode
from gitlint.tests.base import BaseTestCase


class GitCommitTests(BaseTestCase):
    # Expected special_args passed to 'sh'
    expected_sh_special_args = {"_tty_out": False, "_cwd": "fåke/path"}

    @patch("gitlint.git.sh")
    def test_get_latest_commit(self, sh):
        sample_sha = "d8ac47e9f2923c7f22d8668e3a1ed04eb4cdbca9"

        sh.git.side_effect = [
            sample_sha,
            "test åuthor\x00test-emåil@foo.com\x002016-12-03 15:28:15 +0100\x00åbc\ncömmit-title\n\ncömmit-body",
            "#",  # git config --get core.commentchar
            "4\t15\tfile1.txt\n-\t-\tpåth/to/file2.bin\n",
            "foöbar\n* hürdur\n",
        ]

        context = GitContext.from_local_repository("fåke/path")
        # assert that commit info was read using git command
        expected_calls = [
            call("log", "-1", "--pretty=%H", **self.expected_sh_special_args),
            call("log", sample_sha, "-1", "--pretty=%aN%x00%aE%x00%ai%x00%P%n%B", **self.expected_sh_special_args),
            call("config", "--get", "core.commentchar", _ok_code=[0, 1], **self.expected_sh_special_args),
            call(
                "diff-tree",
                "--no-commit-id",
                "--numstat",
                "-r",
                "--root",
                sample_sha,
                **self.expected_sh_special_args,
            ),
            call("branch", "--contains", sample_sha, **self.expected_sh_special_args),
        ]

        # Only first 'git log' call should've happened at this point
        self.assertListEqual(sh.git.mock_calls, expected_calls[:1])

        last_commit = context.commits[-1]
        self.assertIsInstance(last_commit, LocalGitCommit)
        self.assertEqual(last_commit.sha, sample_sha)
        self.assertEqual(last_commit.message.title, "cömmit-title")
        self.assertEqual(last_commit.message.body, ["", "cömmit-body"])
        self.assertEqual(last_commit.author_name, "test åuthor")
        self.assertEqual(last_commit.author_email, "test-emåil@foo.com")
        self.assertEqual(
            last_commit.date, datetime.datetime(2016, 12, 3, 15, 28, 15, tzinfo=dateutil.tz.tzoffset("+0100", 3600))
        )
        self.assertListEqual(last_commit.parents, ["åbc"])
        self.assertFalse(last_commit.is_merge_commit)
        self.assertFalse(last_commit.is_fixup_commit)
        self.assertFalse(last_commit.is_fixup_amend_commit)
        self.assertFalse(last_commit.is_squash_commit)
        self.assertFalse(last_commit.is_revert_commit)

        # First 2 'git log' calls should've happened at this point
        self.assertListEqual(sh.git.mock_calls, expected_calls[:3])

        self.assertListEqual(last_commit.changed_files, ["file1.txt", "påth/to/file2.bin"])
        expected_file_stats = {
            "file1.txt": GitChangedFileStats("file1.txt", 4, 15),
            "påth/to/file2.bin": GitChangedFileStats("påth/to/file2.bin", None, None),
        }
        self.assertDictEqual(last_commit.changed_files_stats, expected_file_stats)

        # 'git diff-tree' should have happened at this point
        self.assertListEqual(sh.git.mock_calls, expected_calls[:4])

        self.assertListEqual(last_commit.branches, ["foöbar", "hürdur"])
        # All expected calls should've happened at this point
        self.assertListEqual(sh.git.mock_calls, expected_calls)

    @patch("gitlint.git.sh")
    def test_from_local_repository_specific_refspec(self, sh):
        sample_refspec = "åbc123..def456"
        sample_sha = "åbc123"

        sh.git.side_effect = [
            sample_sha,  # git rev-list <sample_refspec>
            "test åuthor\x00test-emåil@foo.com\x002016-12-03 15:28:15 +0100\x00åbc\ncömmit-title\n\ncömmit-body",
            "#",  # git config --get core.commentchar
            "7\t10\tfile1.txt\n9\t12\tpåth/to/file2.txt\n",
            "foöbar\n* hürdur\n",
        ]

        context = GitContext.from_local_repository("fåke/path", refspec=sample_refspec)
        # assert that commit info was read using git command
        expected_calls = [
            call("rev-list", sample_refspec, **self.expected_sh_special_args),
            call("log", sample_sha, "-1", "--pretty=%aN%x00%aE%x00%ai%x00%P%n%B", **self.expected_sh_special_args),
            call("config", "--get", "core.commentchar", _ok_code=[0, 1], **self.expected_sh_special_args),
            call(
                "diff-tree",
                "--no-commit-id",
                "--numstat",
                "-r",
                "--root",
                sample_sha,
                **self.expected_sh_special_args,
            ),
            call("branch", "--contains", sample_sha, **self.expected_sh_special_args),
        ]

        # Only first 'git log' call should've happened at this point
        self.assertEqual(sh.git.mock_calls, expected_calls[:1])

        last_commit = context.commits[-1]
        self.assertIsInstance(last_commit, LocalGitCommit)
        self.assertEqual(last_commit.sha, sample_sha)
        self.assertEqual(last_commit.message.title, "cömmit-title")
        self.assertEqual(last_commit.message.body, ["", "cömmit-body"])
        self.assertEqual(last_commit.author_name, "test åuthor")
        self.assertEqual(last_commit.author_email, "test-emåil@foo.com")
        self.assertEqual(
            last_commit.date, datetime.datetime(2016, 12, 3, 15, 28, 15, tzinfo=dateutil.tz.tzoffset("+0100", 3600))
        )
        self.assertListEqual(last_commit.parents, ["åbc"])
        self.assertFalse(last_commit.is_merge_commit)
        self.assertFalse(last_commit.is_fixup_commit)
        self.assertFalse(last_commit.is_fixup_amend_commit)
        self.assertFalse(last_commit.is_squash_commit)
        self.assertFalse(last_commit.is_revert_commit)

        # First 2 'git log' calls should've happened at this point
        self.assertListEqual(sh.git.mock_calls, expected_calls[:3])

        self.assertListEqual(last_commit.changed_files, ["file1.txt", "påth/to/file2.txt"])
        expected_file_stats = {
            "file1.txt": GitChangedFileStats("file1.txt", 7, 10),
            "påth/to/file2.txt": GitChangedFileStats("påth/to/file2.txt", 9, 12),
        }
        self.assertDictEqual(last_commit.changed_files_stats, expected_file_stats)

        # 'git diff-tree' should have happened at this point
        self.assertListEqual(sh.git.mock_calls, expected_calls[:4])

        self.assertListEqual(last_commit.branches, ["foöbar", "hürdur"])
        # All expected calls should've happened at this point
        self.assertListEqual(sh.git.mock_calls, expected_calls)

    @patch("gitlint.git.sh")
    def test_from_local_repository_specific_commit_hash(self, sh):
        sample_hash = "åbc123"

        sh.git.side_effect = [
            sample_hash,  # git log -1 <sample_hash>
            "test åuthor\x00test-emåil@foo.com\x002016-12-03 15:28:15 +0100\x00åbc\ncömmit-title\n\ncömmit-body",
            "#",  # git config --get core.commentchar
            "8\t3\tfile1.txt\n1\t4\tpåth/to/file2.txt\n",
            "foöbar\n* hürdur\n",
        ]

        context = GitContext.from_local_repository("fåke/path", commit_hashes=[sample_hash])
        # assert that commit info was read using git command
        expected_calls = [
            call("log", "-1", sample_hash, "--pretty=%H", **self.expected_sh_special_args),
            call("log", sample_hash, "-1", "--pretty=%aN%x00%aE%x00%ai%x00%P%n%B", **self.expected_sh_special_args),
            call("config", "--get", "core.commentchar", _ok_code=[0, 1], **self.expected_sh_special_args),
            call(
                "diff-tree",
                "--no-commit-id",
                "--numstat",
                "-r",
                "--root",
                sample_hash,
                **self.expected_sh_special_args,
            ),
            call("branch", "--contains", sample_hash, **self.expected_sh_special_args),
        ]

        # Only first 'git log' call should've happened at this point
        self.assertEqual(sh.git.mock_calls, expected_calls[:1])

        last_commit = context.commits[-1]
        self.assertIsInstance(last_commit, LocalGitCommit)
        self.assertEqual(last_commit.sha, sample_hash)
        self.assertEqual(last_commit.message.title, "cömmit-title")
        self.assertEqual(last_commit.message.body, ["", "cömmit-body"])
        self.assertEqual(last_commit.author_name, "test åuthor")
        self.assertEqual(last_commit.author_email, "test-emåil@foo.com")
        self.assertEqual(
            last_commit.date, datetime.datetime(2016, 12, 3, 15, 28, 15, tzinfo=dateutil.tz.tzoffset("+0100", 3600))
        )
        self.assertListEqual(last_commit.parents, ["åbc"])
        self.assertFalse(last_commit.is_merge_commit)
        self.assertFalse(last_commit.is_fixup_commit)
        self.assertFalse(last_commit.is_fixup_amend_commit)
        self.assertFalse(last_commit.is_squash_commit)
        self.assertFalse(last_commit.is_revert_commit)

        # First 2 'git log' calls should've happened at this point
        self.assertListEqual(sh.git.mock_calls, expected_calls[:3])

        self.assertListEqual(last_commit.changed_files, ["file1.txt", "påth/to/file2.txt"])
        expected_file_stats = {
            "file1.txt": GitChangedFileStats("file1.txt", 8, 3),
            "påth/to/file2.txt": GitChangedFileStats("påth/to/file2.txt", 1, 4),
        }
        self.assertDictEqual(last_commit.changed_files_stats, expected_file_stats)
        # 'git diff-tree' should have happened at this point
        self.assertListEqual(sh.git.mock_calls, expected_calls[:4])

        self.assertListEqual(last_commit.branches, ["foöbar", "hürdur"])
        # All expected calls should've happened at this point
        self.assertListEqual(sh.git.mock_calls, expected_calls)

    @patch("gitlint.git.sh")
    def test_from_local_repository_multiple_commit_hashes(self, sh):
        hashes = ["åbc123", "dęf456", "ghí789"]
        sh.git.side_effect = [
            *hashes,
            f"test åuthor {hashes[0]}\x00test-emåil-{hashes[0]}@foo.com\x002016-12-03 15:28:15 +0100\x00åbc\n"
            f"cömmit-title {hashes[0]}\n\ncömmit-body {hashes[0]}",
            "#",  # git config --get core.commentchar
            f"test åuthor {hashes[1]}\x00test-emåil-{hashes[1]}@foo.com\x002016-12-03 15:28:15 +0100\x00åbc\n"
            f"cömmit-title {hashes[1]}\n\ncömmit-body {hashes[1]}",
            f"test åuthor {hashes[2]}\x00test-emåil-{hashes[2]}@foo.com\x002016-12-03 15:28:15 +0100\x00åbc\n"
            f"cömmit-title {hashes[2]}\n\ncömmit-body {hashes[2]}",
            f"2\t5\tfile1-{hashes[0]}.txt\n7\t1\tpåth/to/file2.txt\n",
            f"2\t5\tfile1-{hashes[1]}.txt\n7\t1\tpåth/to/file2.txt\n",
            f"2\t5\tfile1-{hashes[2]}.txt\n7\t1\tpåth/to/file2.txt\n",
            f"foöbar-{hashes[0]}\n* hürdur\n",
            f"foöbar-{hashes[1]}\n* hürdur\n",
            f"foöbar-{hashes[2]}\n* hürdur\n",
        ]

        expected_calls = [
            call("log", "-1", hashes[0], "--pretty=%H", **self.expected_sh_special_args),
            call("log", "-1", hashes[1], "--pretty=%H", **self.expected_sh_special_args),
            call("log", "-1", hashes[2], "--pretty=%H", **self.expected_sh_special_args),
            call("log", hashes[0], "-1", "--pretty=%aN%x00%aE%x00%ai%x00%P%n%B", **self.expected_sh_special_args),
            call("config", "--get", "core.commentchar", _ok_code=[0, 1], **self.expected_sh_special_args),
            call("log", hashes[1], "-1", "--pretty=%aN%x00%aE%x00%ai%x00%P%n%B", **self.expected_sh_special_args),
            call("log", hashes[2], "-1", "--pretty=%aN%x00%aE%x00%ai%x00%P%n%B", **self.expected_sh_special_args),
            call(
                "diff-tree", "--no-commit-id", "--numstat", "-r", "--root", hashes[0], **self.expected_sh_special_args
            ),
            call(
                "diff-tree", "--no-commit-id", "--numstat", "-r", "--root", hashes[1], **self.expected_sh_special_args
            ),
            call(
                "diff-tree", "--no-commit-id", "--numstat", "-r", "--root", hashes[2], **self.expected_sh_special_args
            ),
            call("branch", "--contains", hashes[0], **self.expected_sh_special_args),
            call("branch", "--contains", hashes[1], **self.expected_sh_special_args),
            call("branch", "--contains", hashes[2], **self.expected_sh_special_args),
        ]

        context = GitContext.from_local_repository("fåke/path", commit_hashes=hashes)

        # Only first set of 'git log' calls should've happened at this point
        self.assertEqual(sh.git.mock_calls, expected_calls[:3])

        for i, commit in enumerate(context.commits):
            expected_hash = hashes[i]
            self.assertIsInstance(commit, LocalGitCommit)
            self.assertEqual(commit.sha, expected_hash)
            self.assertEqual(commit.message.title, f"cömmit-title {expected_hash}")
            self.assertEqual(commit.message.body, ["", f"cömmit-body {expected_hash}"])
            self.assertEqual(commit.author_name, f"test åuthor {expected_hash}")
            self.assertEqual(commit.author_email, f"test-emåil-{expected_hash}@foo.com")
            self.assertEqual(
                commit.date, datetime.datetime(2016, 12, 3, 15, 28, 15, tzinfo=dateutil.tz.tzoffset("+0100", 3600))
            )
            self.assertListEqual(commit.parents, ["åbc"])
            self.assertFalse(commit.is_merge_commit)
            self.assertFalse(commit.is_fixup_commit)
            self.assertFalse(commit.is_fixup_amend_commit)
            self.assertFalse(commit.is_squash_commit)
            self.assertFalse(commit.is_revert_commit)

        # All 'git log' calls should've happened at this point
        self.assertListEqual(sh.git.mock_calls, expected_calls[:7])

        for i, commit in enumerate(context.commits):
            expected_hash = hashes[i]
            self.assertListEqual(commit.changed_files, [f"file1-{expected_hash}.txt", "påth/to/file2.txt"])
            expected_file_stats = {
                f"file1-{expected_hash}.txt": GitChangedFileStats(f"file1-{expected_hash}.txt", 2, 5),
                "påth/to/file2.txt": GitChangedFileStats("påth/to/file2.txt", 7, 1),
            }
            self.assertDictEqual(commit.changed_files_stats, expected_file_stats)

        # 'git diff-tree' should have happened at this point
        self.assertListEqual(sh.git.mock_calls, expected_calls[:10])

        for i, commit in enumerate(context.commits):
            expected_hash = hashes[i]
            self.assertListEqual(commit.branches, [f"foöbar-{expected_hash}", "hürdur"])

        # All expected calls should've happened at this point
        self.assertListEqual(sh.git.mock_calls, expected_calls)

    @patch("gitlint.git.sh")
    def test_get_latest_commit_merge_commit(self, sh):
        sample_sha = "d8ac47e9f2923c7f22d8668e3a1ed04eb4cdbca9"

        sh.git.side_effect = [
            sample_sha,
            'test åuthor\x00test-emåil@foo.com\x002016-12-03 15:28:15 +0100\x00åbc def\nMerge "foo bår commit"',
            "#",  # git config --get core.commentchar
            "6\t2\tfile1.txt\n1\t4\tpåth/to/file2.txt\n",
            "foöbar\n* hürdur\n",
        ]

        context = GitContext.from_local_repository("fåke/path")
        # assert that commit info was read using git command
        expected_calls = [
            call("log", "-1", "--pretty=%H", **self.expected_sh_special_args),
            call("log", sample_sha, "-1", "--pretty=%aN%x00%aE%x00%ai%x00%P%n%B", **self.expected_sh_special_args),
            call("config", "--get", "core.commentchar", _ok_code=[0, 1], **self.expected_sh_special_args),
            call(
                "diff-tree",
                "--no-commit-id",
                "--numstat",
                "-r",
                "--root",
                sample_sha,
                **self.expected_sh_special_args,
            ),
            call("branch", "--contains", sample_sha, **self.expected_sh_special_args),
        ]

        # Only first 'git log' call should've happened at this point
        self.assertEqual(sh.git.mock_calls, expected_calls[:1])

        last_commit = context.commits[-1]
        self.assertIsInstance(last_commit, LocalGitCommit)
        self.assertEqual(last_commit.sha, sample_sha)
        self.assertEqual(last_commit.message.title, 'Merge "foo bår commit"')
        self.assertEqual(last_commit.message.body, [])
        self.assertEqual(last_commit.author_name, "test åuthor")
        self.assertEqual(last_commit.author_email, "test-emåil@foo.com")
        self.assertEqual(
            last_commit.date, datetime.datetime(2016, 12, 3, 15, 28, 15, tzinfo=dateutil.tz.tzoffset("+0100", 3600))
        )
        self.assertListEqual(last_commit.parents, ["åbc", "def"])
        self.assertTrue(last_commit.is_merge_commit)
        self.assertFalse(last_commit.is_fixup_commit)
        self.assertFalse(last_commit.is_fixup_amend_commit)
        self.assertFalse(last_commit.is_squash_commit)
        self.assertFalse(last_commit.is_revert_commit)

        # First 2 'git log' calls should've happened at this point
        self.assertListEqual(sh.git.mock_calls, expected_calls[:3])

        self.assertListEqual(last_commit.changed_files, ["file1.txt", "påth/to/file2.txt"])
        expected_file_stats = {
            "file1.txt": GitChangedFileStats("file1.txt", 6, 2),
            "påth/to/file2.txt": GitChangedFileStats("påth/to/file2.txt", 1, 4),
        }
        self.assertDictEqual(last_commit.changed_files_stats, expected_file_stats)
        # 'git diff-tree' should have happened at this point
        self.assertListEqual(sh.git.mock_calls, expected_calls[:4])

        self.assertListEqual(last_commit.branches, ["foöbar", "hürdur"])
        # All expected calls should've happened at this point
        self.assertListEqual(sh.git.mock_calls, expected_calls)

    @patch("gitlint.git.sh")
    def test_get_latest_commit_fixup_squash_commit(self, sh):
        commit_prefixes = {"fixup": "is_fixup_commit", "squash": "is_squash_commit", "amend": "is_fixup_amend_commit"}
        for commit_type in commit_prefixes:
            sample_sha = "d8ac47e9f2923c7f22d8668e3a1ed04eb4cdbca9"

            sh.git.side_effect = [
                sample_sha,
                "test åuthor\x00test-emåil@foo.com\x002016-12-03 15:28:15 +0100\x00åbc\n"
                f'{commit_type}! "foo bår commit"',
                "#",  # git config --get core.commentchar
                "8\t2\tfile1.txt\n7\t3\tpåth/to/file2.txt\n",
                "foöbar\n* hürdur\n",
            ]

            context = GitContext.from_local_repository("fåke/path")
            # assert that commit info was read using git command
            expected_calls = [
                call("log", "-1", "--pretty=%H", **self.expected_sh_special_args),
                call("log", sample_sha, "-1", "--pretty=%aN%x00%aE%x00%ai%x00%P%n%B", **self.expected_sh_special_args),
                call("config", "--get", "core.commentchar", _ok_code=[0, 1], **self.expected_sh_special_args),
                call(
                    "diff-tree",
                    "--no-commit-id",
                    "--numstat",
                    "-r",
                    "--root",
                    sample_sha,
                    **self.expected_sh_special_args,
                ),
                call("branch", "--contains", sample_sha, **self.expected_sh_special_args),
            ]

            # Only first 'git log' call should've happened at this point
            self.assertEqual(sh.git.mock_calls, expected_calls[:-4])

            last_commit = context.commits[-1]
            self.assertIsInstance(last_commit, LocalGitCommit)
            self.assertEqual(last_commit.sha, sample_sha)
            self.assertEqual(last_commit.message.title, f'{commit_type}! "foo bår commit"')
            self.assertEqual(last_commit.message.body, [])
            self.assertEqual(last_commit.author_name, "test åuthor")
            self.assertEqual(last_commit.author_email, "test-emåil@foo.com")
            self.assertEqual(
                last_commit.date, datetime.datetime(2016, 12, 3, 15, 28, 15, tzinfo=dateutil.tz.tzoffset("+0100", 3600))
            )
            self.assertListEqual(last_commit.parents, ["åbc"])

            # First 2 'git log' calls should've happened at this point
            self.assertEqual(sh.git.mock_calls, expected_calls[:3])

            # Asserting that squash and fixup are correct
            for type, attr in commit_prefixes.items():
                self.assertEqual(getattr(last_commit, attr), commit_type == type)

            self.assertFalse(last_commit.is_merge_commit)
            self.assertFalse(last_commit.is_revert_commit)
            self.assertListEqual(last_commit.changed_files, ["file1.txt", "påth/to/file2.txt"])
            expected_file_stats = {
                "file1.txt": GitChangedFileStats("file1.txt", 8, 2),
                "påth/to/file2.txt": GitChangedFileStats("påth/to/file2.txt", 7, 3),
            }
            self.assertDictEqual(last_commit.changed_files_stats, expected_file_stats)

            # 'git diff-tree' should have happened at this point
            self.assertListEqual(sh.git.mock_calls, expected_calls[:4])

            self.assertListEqual(last_commit.branches, ["foöbar", "hürdur"])
            # All expected calls should've happened at this point
            self.assertListEqual(sh.git.mock_calls, expected_calls)

            sh.git.reset_mock()

    @patch("gitlint.git.git_commentchar")
    def test_from_commit_msg_full(self, commentchar):
        commentchar.return_value = "#"
        gitcontext = GitContext.from_commit_msg(self.get_sample("commit_message/sample1"))

        expected_title = "Commit title contåining 'WIP', as well as trailing punctuation."
        expected_body = [
            "This line should be empty",
            "This is the first line of the commit message body and it is meant to test a "
            + "line that exceeds the maximum line length of 80 characters.",
            "This line has a tråiling space. ",
            "This line has a trailing tab.\t",
        ]
        expected_full = expected_title + "\n" + "\n".join(expected_body)
        expected_original = (
            expected_full + "\n# This is a cömmented  line\n"
            "# ------------------------ >8 ------------------------\n"
            "# Anything after this line should be cleaned up\n"
            "# this line appears on `git commit -v` command\n"
            "diff --git a/gitlint/tests/samples/commit_message/sample1 "
            "b/gitlint/tests/samples/commit_message/sample1\n"
            "index 82dbe7f..ae71a14 100644\n"
            "--- a/gitlint/tests/samples/commit_message/sample1\n"
            "+++ b/gitlint/tests/samples/commit_message/sample1\n"
            "@@ -1 +1 @@\n"
        )

        commit = gitcontext.commits[-1]
        self.assertIsInstance(commit, GitCommit)
        self.assertFalse(isinstance(commit, LocalGitCommit))
        self.assertEqual(commit.message.title, expected_title)
        self.assertEqual(commit.message.body, expected_body)
        self.assertEqual(commit.message.full, expected_full)
        self.assertEqual(commit.message.original, expected_original)
        self.assertEqual(commit.author_name, None)
        self.assertEqual(commit.author_email, None)
        self.assertEqual(commit.date, None)
        self.assertListEqual(commit.parents, [])
        self.assertListEqual(commit.branches, [])
        self.assertFalse(commit.is_merge_commit)
        self.assertFalse(commit.is_fixup_commit)
        self.assertFalse(commit.is_fixup_amend_commit)
        self.assertFalse(commit.is_squash_commit)
        self.assertFalse(commit.is_revert_commit)
        self.assertEqual(len(gitcontext.commits), 1)

    def test_from_commit_msg_just_title(self):
        gitcontext = GitContext.from_commit_msg(self.get_sample("commit_message/sample2"))
        commit = gitcontext.commits[-1]

        self.assertIsInstance(commit, GitCommit)
        self.assertFalse(isinstance(commit, LocalGitCommit))
        self.assertEqual(commit.message.title, "Just a title contåining WIP")
        self.assertEqual(commit.message.body, [])
        self.assertEqual(commit.message.full, "Just a title contåining WIP")
        self.assertEqual(commit.message.original, "Just a title contåining WIP")
        self.assertEqual(commit.author_name, None)
        self.assertEqual(commit.author_email, None)
        self.assertListEqual(commit.parents, [])
        self.assertListEqual(commit.branches, [])
        self.assertFalse(commit.is_merge_commit)
        self.assertFalse(commit.is_fixup_commit)
        self.assertFalse(commit.is_fixup_amend_commit)
        self.assertFalse(commit.is_squash_commit)
        self.assertFalse(commit.is_revert_commit)
        self.assertEqual(len(gitcontext.commits), 1)

    def test_from_commit_msg_empty(self):
        gitcontext = GitContext.from_commit_msg("")
        commit = gitcontext.commits[-1]

        self.assertIsInstance(commit, GitCommit)
        self.assertFalse(isinstance(commit, LocalGitCommit))
        self.assertEqual(commit.message.title, "")
        self.assertEqual(commit.message.body, [])
        self.assertEqual(commit.message.full, "")
        self.assertEqual(commit.message.original, "")
        self.assertEqual(commit.author_name, None)
        self.assertEqual(commit.author_email, None)
        self.assertEqual(commit.date, None)
        self.assertListEqual(commit.parents, [])
        self.assertListEqual(commit.branches, [])
        self.assertFalse(commit.is_merge_commit)
        self.assertFalse(commit.is_fixup_commit)
        self.assertFalse(commit.is_fixup_amend_commit)
        self.assertFalse(commit.is_squash_commit)
        self.assertFalse(commit.is_revert_commit)
        self.assertEqual(len(gitcontext.commits), 1)

    @patch("gitlint.git.git_commentchar")
    def test_from_commit_msg_comment(self, commentchar):
        commentchar.return_value = "#"
        gitcontext = GitContext.from_commit_msg("Tïtle\n\nBödy 1\n#Cömment\nBody 2")
        commit = gitcontext.commits[-1]

        self.assertIsInstance(commit, GitCommit)
        self.assertFalse(isinstance(commit, LocalGitCommit))
        self.assertEqual(commit.message.title, "Tïtle")
        self.assertEqual(commit.message.body, ["", "Bödy 1", "Body 2"])
        self.assertEqual(commit.message.full, "Tïtle\n\nBödy 1\nBody 2")
        self.assertEqual(commit.message.original, "Tïtle\n\nBödy 1\n#Cömment\nBody 2")
        self.assertEqual(commit.author_name, None)
        self.assertEqual(commit.author_email, None)
        self.assertEqual(commit.date, None)
        self.assertListEqual(commit.parents, [])
        self.assertListEqual(commit.branches, [])
        self.assertFalse(commit.is_merge_commit)
        self.assertFalse(commit.is_fixup_commit)
        self.assertFalse(commit.is_squash_commit)
        self.assertFalse(commit.is_fixup_amend_commit)
        self.assertFalse(commit.is_revert_commit)
        self.assertEqual(len(gitcontext.commits), 1)

    def test_from_commit_msg_merge_commit(self):
        commit_msg = "Merge f919b8f34898d9b48048bcd703bc47139f4ff621 into 8b0409a26da6ba8a47c1fd2e746872a8dab15401"
        gitcontext = GitContext.from_commit_msg(commit_msg)
        commit = gitcontext.commits[-1]

        self.assertIsInstance(commit, GitCommit)
        self.assertFalse(isinstance(commit, LocalGitCommit))
        self.assertEqual(commit.message.title, commit_msg)
        self.assertEqual(commit.message.body, [])
        self.assertEqual(commit.message.full, commit_msg)
        self.assertEqual(commit.message.original, commit_msg)
        self.assertEqual(commit.author_name, None)
        self.assertEqual(commit.author_email, None)
        self.assertEqual(commit.date, None)
        self.assertListEqual(commit.parents, [])
        self.assertListEqual(commit.branches, [])
        self.assertTrue(commit.is_merge_commit)
        self.assertFalse(commit.is_fixup_commit)
        self.assertFalse(commit.is_fixup_amend_commit)
        self.assertFalse(commit.is_squash_commit)
        self.assertFalse(commit.is_revert_commit)
        self.assertEqual(len(gitcontext.commits), 1)

    def test_from_commit_msg_revert_commit(self):
        commit_msg = 'Revert "Prev commit message"\n\nThis reverts commit a8ad67e04164a537198dea94a4fde81c5592ae9c.'
        gitcontext = GitContext.from_commit_msg(commit_msg)
        commit = gitcontext.commits[-1]

        self.assertIsInstance(commit, GitCommit)
        self.assertFalse(isinstance(commit, LocalGitCommit))
        self.assertEqual(commit.message.title, 'Revert "Prev commit message"')
        self.assertEqual(commit.message.body, ["", "This reverts commit a8ad67e04164a537198dea94a4fde81c5592ae9c."])
        self.assertEqual(commit.message.full, commit_msg)
        self.assertEqual(commit.message.original, commit_msg)
        self.assertEqual(commit.author_name, None)
        self.assertEqual(commit.author_email, None)
        self.assertEqual(commit.date, None)
        self.assertListEqual(commit.parents, [])
        self.assertListEqual(commit.branches, [])
        self.assertFalse(commit.is_merge_commit)
        self.assertFalse(commit.is_fixup_commit)
        self.assertFalse(commit.is_fixup_amend_commit)
        self.assertFalse(commit.is_squash_commit)
        self.assertTrue(commit.is_revert_commit)
        self.assertEqual(len(gitcontext.commits), 1)

    def test_from_commit_msg_fixup_squash_amend_commit(self):
        # mapping between cleanup commit prefixes and the commit object attribute
        commit_prefixes = {"fixup": "is_fixup_commit", "squash": "is_squash_commit", "amend": "is_fixup_amend_commit"}

        for commit_type in commit_prefixes:
            commit_msg = f"{commit_type}! Test message"
            gitcontext = GitContext.from_commit_msg(commit_msg)
            commit = gitcontext.commits[-1]

            self.assertIsInstance(commit, GitCommit)
            self.assertFalse(isinstance(commit, LocalGitCommit))
            self.assertEqual(commit.message.title, commit_msg)
            self.assertEqual(commit.message.body, [])
            self.assertEqual(commit.message.full, commit_msg)
            self.assertEqual(commit.message.original, commit_msg)
            self.assertEqual(commit.author_name, None)
            self.assertEqual(commit.author_email, None)
            self.assertEqual(commit.date, None)
            self.assertListEqual(commit.parents, [])
            self.assertListEqual(commit.branches, [])
            self.assertEqual(len(gitcontext.commits), 1)
            self.assertFalse(commit.is_merge_commit)
            self.assertFalse(commit.is_revert_commit)
            # Asserting that squash and fixup are correct
            for type, commit_attr_name in commit_prefixes.items():
                self.assertEqual(getattr(commit, commit_attr_name), commit_type == type)

    @patch("gitlint.git.sh")
    @patch("arrow.now")
    def test_staged_commit(self, now, sh):
        """Test for StagedLocalGitCommit()"""

        sh.git.side_effect = [
            "#",  # git config --get core.commentchar
            "test åuthor\n",  # git config --get user.name
            "test-emåil@foo.com\n",  # git config --get user.email
            "my-brånch\n",  # git rev-parse --abbrev-ref HEAD
            "4\t2\tfile1.txt\n13\t9\tpåth/to/file2.txt\n",
        ]
        now.side_effect = [arrow.get("2020-02-19T12:18:46.675182+01:00")]

        # We use a fixup commit, just to test a non-default path
        context = GitContext.from_staged_commit("fixup! Foōbar 123\n\ncömmit-body\n", "fåke/path")

        # git calls we're expecting
        expected_calls = [
            call("config", "--get", "core.commentchar", _ok_code=[0, 1], **self.expected_sh_special_args),
            call("config", "--get", "user.name", **self.expected_sh_special_args),
            call("config", "--get", "user.email", **self.expected_sh_special_args),
            call("rev-parse", "--abbrev-ref", "HEAD", **self.expected_sh_special_args),
            call("diff", "--staged", "--numstat", "-r", **self.expected_sh_special_args),
        ]

        last_commit = context.commits[-1]
        self.assertIsInstance(last_commit, StagedLocalGitCommit)
        self.assertIsNone(last_commit.sha, None)
        self.assertEqual(last_commit.message.title, "fixup! Foōbar 123")
        self.assertEqual(last_commit.message.body, ["", "cömmit-body"])
        # Only `git config --get core.commentchar` should've happened up until this point
        self.assertListEqual(sh.git.mock_calls, expected_calls[0:1])

        self.assertEqual(last_commit.author_name, "test åuthor")
        self.assertListEqual(sh.git.mock_calls, expected_calls[0:2])

        self.assertEqual(last_commit.author_email, "test-emåil@foo.com")
        self.assertListEqual(sh.git.mock_calls, expected_calls[0:3])

        self.assertEqual(
            last_commit.date, datetime.datetime(2020, 2, 19, 12, 18, 46, tzinfo=dateutil.tz.tzoffset("+0100", 3600))
        )
        now.assert_called_once()

        self.assertListEqual(last_commit.parents, [])
        self.assertFalse(last_commit.is_merge_commit)
        self.assertTrue(last_commit.is_fixup_commit)
        self.assertFalse(last_commit.is_fixup_amend_commit)
        self.assertFalse(last_commit.is_squash_commit)
        self.assertFalse(last_commit.is_revert_commit)

        self.assertListEqual(last_commit.branches, ["my-brånch"])
        self.assertListEqual(sh.git.mock_calls, expected_calls[0:4])

        self.assertListEqual(last_commit.changed_files, ["file1.txt", "påth/to/file2.txt"])
        expected_file_stats = {
            "file1.txt": GitChangedFileStats("file1.txt", 4, 2),
            "påth/to/file2.txt": GitChangedFileStats("påth/to/file2.txt", 13, 9),
        }
        self.assertDictEqual(last_commit.changed_files_stats, expected_file_stats)

        self.assertListEqual(sh.git.mock_calls, expected_calls[0:5])

    @patch("gitlint.git.sh")
    def test_staged_commit_with_missing_username(self, sh):
        sh.git.side_effect = [
            "#",  # git config --get core.commentchar
            ErrorReturnCode("git config --get user.name", b"", b""),
        ]

        expected_msg = "Missing git configuration: please set user.name"
        with self.assertRaisesMessage(GitContextError, expected_msg):
            ctx = GitContext.from_staged_commit("Foōbar 123\n\ncömmit-body\n", "fåke/path")
            ctx.commits[0].author_name  # accessing this attribute should raise an exception

    @patch("gitlint.git.sh")
    def test_staged_commit_with_missing_email(self, sh):
        sh.git.side_effect = [
            "#",  # git config --get core.commentchar
            ErrorReturnCode("git config --get user.email", b"", b""),
        ]

        expected_msg = "Missing git configuration: please set user.email"
        with self.assertRaisesMessage(GitContextError, expected_msg):
            ctx = GitContext.from_staged_commit("Foōbar 123\n\ncömmit-body\n", "fåke/path")
            ctx.commits[0].author_email  # accessing this attribute should raise an exception

    def test_gitcommitmessage_equality(self):
        commit_message1 = GitCommitMessage(GitContext(), "tëst\n\nfoo", "tëst\n\nfoo", "tēst", ["", "föo"])
        attrs = ["original", "full", "title", "body"]
        self.object_equality_test(commit_message1, attrs, {"context": commit_message1.context})

    def test_gitchangedfilestats_equality(self):
        changed_file_stats = GitChangedFileStats(Path("foö/bar"), 5, 13)
        attrs = ["filepath", "additions", "deletions"]
        self.object_equality_test(changed_file_stats, attrs)

    @patch("gitlint.git._git")
    def test_gitcommit_equality(self, git):
        # git will be called to setup the context (commentchar and current_branch), just return the same value
        # This only matters to test gitcontext equality, not gitcommit equality
        git.return_value = "foöbar"

        # Test simple equality case
        now = datetime.datetime.now(datetime.timezone.utc)
        context1 = GitContext()
        commit_message1 = GitCommitMessage(context1, "tëst\n\nfoo", "tëst\n\nfoo", "tēst", ["", "föo"])
        commit1 = GitCommit(
            context1,
            commit_message1,
            "shä",
            now,
            "Jöhn Smith",
            "jöhn.smith@test.com",
            None,
            {"föo/bar": GitChangedFileStats("föo/bar", 5, 13)},
            ["brånch1", "brånch2"],
        )
        context1.commits = [commit1]

        context2 = GitContext()
        commit_message2 = GitCommitMessage(context2, "tëst\n\nfoo", "tëst\n\nfoo", "tēst", ["", "föo"])
        commit2 = GitCommit(
            context2,
            commit_message1,
            "shä",
            now,
            "Jöhn Smith",
            "jöhn.smith@test.com",
            None,
            {"föo/bar": GitChangedFileStats("föo/bar", 5, 13)},
            ["brånch1", "brånch2"],
        )
        context2.commits = [commit2]

        self.assertEqual(context1, context2)
        self.assertEqual(commit_message1, commit_message2)
        self.assertEqual(commit1, commit2)

        # Check that objects are unequal when changing a single attribute
        kwargs = {
            "message": commit1.message,
            "sha": commit1.sha,
            "date": commit1.date,
            "author_name": commit1.author_name,
            "author_email": commit1.author_email,
            "parents": commit1.parents,
            "branches": commit1.branches,
        }

        self.object_equality_test(
            commit1,
            kwargs.keys(),
            {"context": commit1.context, "changed_files_stats": {"föo/bar": GitChangedFileStats("föo/bar", 5, 13)}},
        )

        # Check that the is_* attributes that are affected by the commit message affect equality
        special_messages = {
            "is_merge_commit": "Merge: foöbar",
            "is_fixup_commit": "fixup! foöbar",
            "is_squash_commit": "squash! foöbar",
            "is_revert_commit": "Revert: foöbar",
        }
        for key in special_messages:
            kwargs_copy = copy.deepcopy(kwargs)
            clone1 = GitCommit(context=commit1.context, **kwargs_copy)
            clone1.message = GitCommitMessage.from_full_message(context1, special_messages[key])
            self.assertTrue(getattr(clone1, key))

            clone2 = GitCommit(context=commit1.context, **kwargs_copy)
            clone2.message = GitCommitMessage.from_full_message(context1, "foöbar")
            self.assertNotEqual(clone1, clone2)

        # Check changed_files and changed_files_stats
        commit2.changed_files_stats = {"föo/bar2": GitChangedFileStats("föo/bar2", 5, 13)}
        self.assertNotEqual(commit1, commit2)

    @patch("gitlint.git.git_commentchar")
    def test_commit_msg_custom_commentchar(self, patched):
        patched.return_value = "ä"
        context = GitContext()
        message = GitCommitMessage.from_full_message(context, "Tïtle\n\nBödy 1\näCömment\nBody 2")

        self.assertEqual(message.title, "Tïtle")
        self.assertEqual(message.body, ["", "Bödy 1", "Body 2"])
        self.assertEqual(message.full, "Tïtle\n\nBödy 1\nBody 2")
        self.assertEqual(message.original, "Tïtle\n\nBödy 1\näCömment\nBody 2")
