#!/usr/bin/env python3
# Copyright 2023 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

import os
import os.path
import sys
import tempfile
import unittest
import unittest.mock

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

from gclient import PRECOMMIT_HOOK_VAR  # noqa: E402
import gclient_utils  # noqa: E402
from gclient_eval import SYNC, SUBMODULES  # noqa: E402
import git_common as git  # noqa: E402


class HooksTest(unittest.TestCase):
    def setUp(self):
        super(HooksTest, self).setUp()
        self.repo = tempfile.mkdtemp()
        self.env = os.environ.copy()
        self.env["SKIP_GITLINK_PRECOMMIT"] = "0"
        self.env["TESTING_ANSWER"] = "n"
        self.populate()

    def tearDown(self):
        gclient_utils.rmtree(self.repo)

    def write(self, repo, path, content):
        with open(os.path.join(repo, path), "w") as f:
            f.write(content)

    def populate(self):
        git.run("init", cwd=self.repo)
        deps_content = "\n".join(
            (
                f'git_dependencies = "{SYNC}"',
                "deps = {",
                f'    "dep_a": "host://dep_a@{"a" * 40}",',
                f'    "dep_b": "host://dep_b@{"b" * 40}",',
                "}",
            )
        )
        self.write(self.repo, "DEPS", deps_content)

        self.dep_a_repo = os.path.join(self.repo, "dep_a")
        os.mkdir(self.dep_a_repo)
        git.run("init", cwd=self.dep_a_repo)
        os.mkdir(os.path.join(self.repo, "dep_b"))
        gitmodules_content = "\n".join(
            (
                '[submodule "dep_a"]\tpath = dep_a',
                "\turl = host://dep_a",
                '[submodule "dep_b"]\tpath = dep_b',
                "\turl = host://dep_b",
            )
        )
        self.write(self.repo, ".gitmodules", gitmodules_content)
        git.run(
            "update-index",
            "--add",
            "--cacheinfo",
            f"160000,{'a' * 40},dep_a",
            cwd=self.repo,
        )
        git.run(
            "update-index",
            "--add",
            "--cacheinfo",
            f"160000,{'b' * 40},dep_b",
            cwd=self.repo,
        )

        git.run("add", ".", cwd=self.repo)
        git.run("commit", "-m", "init", cwd=self.repo)

        # On Windows, this path is written to the file as
        # "root_dir\hooks\pre-commit.py", but it gets interpreted as
        # "root_dirhookspre-commit.py".
        precommit_path = os.path.join(
            ROOT_DIR, "hooks", "pre-commit.py"
        ).replace("\\", "\\\\")
        precommit_content = "\n".join(
            (
                "#!/bin/sh",
                f"{PRECOMMIT_HOOK_VAR}={precommit_path}",
                f'if [ -f "${PRECOMMIT_HOOK_VAR}" ]; then',
                f'    python3 "${PRECOMMIT_HOOK_VAR}" || exit 1',
                "fi",
            )
        )
        self.write(
            self.repo,
            os.path.join(".git", "hooks", "pre-commit"),
            precommit_content,
        )
        os.chmod(os.path.join(self.repo, ".git", "hooks", "pre-commit"), 0o755)

    def testPreCommit_NoGitlinkOrDEPS(self):
        # Sanity check. Neither gitlinks nor DEPS are touched.
        self.write(self.repo, "foo", "foo")
        git.run("add", ".", cwd=self.repo)
        expected_diff = git.run("diff", "--cached", cwd=self.repo)
        git.run("commit", "-m", "foo", cwd=self.repo)
        self.assertEqual(
            expected_diff, git.run("diff", "HEAD^", "HEAD", cwd=self.repo)
        )

    def testPreCommit_GitlinkWithoutDEPS(self):
        # Gitlink changes were staged without a corresponding DEPS change.
        self.write(self.repo, "foo", "foo")
        git.run("add", ".", cwd=self.repo)
        git.run(
            "update-index",
            "--replace",
            "--cacheinfo",
            f"160000,{'b' * 40},dep_a",
            cwd=self.repo,
        )
        git.run(
            "update-index",
            "--replace",
            "--cacheinfo",
            f"160000,{'a' * 40},dep_b",
            cwd=self.repo,
        )
        diff_before_commit = git.run(
            "diff", "--cached", "--name-only", cwd=self.repo
        )
        _, stderr = git.run_with_stderr(
            "commit",
            "-m",
            "regular file and gitlinks",
            cwd=self.repo,
            env=self.env,
        )

        self.assertIn("dep_a", diff_before_commit)
        self.assertIn("dep_b", diff_before_commit)
        # Gitlinks should be dropped.
        self.assertIn(
            "Found no change to DEPS, but found staged gitlink(s) in diff",
            stderr,
        )
        diff_after_commit = git.run(
            "diff", "--name-only", "HEAD^", "HEAD", cwd=self.repo
        )
        self.assertNotIn("dep_a", diff_after_commit)
        self.assertNotIn("dep_b", diff_after_commit)
        self.assertIn("foo", diff_after_commit)

    def testPreCommit_IntentionalGitlinkWithoutDEPS(self):
        # Intentional Gitlink changes staged without a DEPS change.
        self.write(self.repo, "foo", "foo")
        git.run("add", ".", cwd=self.repo)
        git.run(
            "update-index",
            "--replace",
            "--cacheinfo",
            f"160000,{'b' * 40},dep_a",
            cwd=self.repo,
        )
        git.run(
            "update-index",
            "--replace",
            "--cacheinfo",
            f"160000,{'a' * 40},dep_b",
            cwd=self.repo,
        )
        diff_before_commit = git.run(
            "diff", "--cached", "--name-only", cwd=self.repo
        )
        self.env["TESTING_ANSWER"] = ""
        _, stderr = git.run_with_stderr(
            "commit",
            "-m",
            "regular file and gitlinks",
            cwd=self.repo,
            env=self.env,
        )

        self.assertIn("dep_a", diff_before_commit)
        self.assertIn("dep_b", diff_before_commit)
        # Gitlinks should be dropped.
        self.assertIn(
            "Found no change to DEPS, but found staged gitlink(s) in diff",
            stderr,
        )
        diff_after_commit = git.run(
            "diff", "--name-only", "HEAD^", "HEAD", cwd=self.repo
        )
        self.assertIn("dep_a", diff_after_commit)
        self.assertIn("dep_b", diff_after_commit)
        self.assertIn("foo", diff_after_commit)

    def testPreCommit_OnlyGitlinkWithoutDEPS(self):
        # Gitlink changes were staged without a corresponding DEPS change but
        # no other files were included in the commit.
        git.run(
            "update-index",
            "--replace",
            "--cacheinfo",
            f"160000,{'b' * 40},dep_a",
            cwd=self.repo,
        )
        diff_before_commit = git.run(
            "diff", "--cached", "--name-only", cwd=self.repo
        )
        ret = git.run_with_retcode(
            "commit", "-m", "gitlink only", cwd=self.repo, env=self.env
        )

        self.assertIn("dep_a", diff_before_commit)
        # Gitlinks should be droppped and the empty commit should be aborted.
        self.assertEqual(ret, 1)
        diff_after_commit = git.run(
            "diff", "--cached", "--name-only", cwd=self.repo
        )
        self.assertNotIn("dep_a", diff_after_commit)

    def testPreCommit_CommitAll(self):
        self.write(self.repo, "foo", "foo")
        git.run("add", ".", cwd=self.repo)
        git.run("commit", "-m", "add foo", cwd=self.repo)
        self.write(self.repo, "foo", "foo2")

        # Create a new commit in dep_a.
        self.write(self.dep_a_repo, "sub_foo", "sub_foo")
        git.run("add", ".", cwd=self.dep_a_repo)
        git.run("commit", "-m", "sub_foo", cwd=self.dep_a_repo)

        diff_before_commit = git.run("status", cwd=self.repo)
        self.assertIn("foo", diff_before_commit)
        self.assertIn("dep_a", diff_before_commit)
        ret = git.run_with_retcode(
            "commit", "--all", "-m", "commit all", cwd=self.repo, env=self.env
        )

        self.assertIn("dep_a", diff_before_commit)
        self.assertEqual(ret, 0)
        diff_after_commit = git.run(
            "diff", "--cached", "--name-only", cwd=self.repo
        )
        self.assertNotIn("dep_a", diff_after_commit)
        diff_from_commit = git.run(
            "diff", "--name-only", "HEAD^", "HEAD", cwd=self.repo
        )
        self.assertIn("foo", diff_from_commit)

    def testPreCommit_GitlinkWithDEPS(self):
        # A gitlink was staged with a corresponding DEPS change.
        updated_deps = "\n".join(
            (
                f'git_dependencies = "{SYNC}"',
                "deps = {",
                f'    "dep_a": "host://dep_a@{"b" * 40}",',
                f'    "dep_b": "host://dep_b@{"b" * 40}",',
                "}",
            )
        )
        self.write(self.repo, "DEPS", updated_deps)
        git.run("add", ".", cwd=self.repo)
        git.run(
            "update-index",
            "--replace",
            "--cacheinfo",
            f"160000,{'b' * 40},dep_a",
            cwd=self.repo,
        )
        diff_before_commit = git.run("diff", "--cached", cwd=self.repo)
        git.run("commit", "-m", "gitlink and DEPS", cwd=self.repo)

        # There should be no changes to the commit.
        diff_after_commit = git.run("diff", "HEAD^", "HEAD", cwd=self.repo)
        self.assertEqual(diff_before_commit, diff_after_commit)

    def testPreCommit_SkipPrecommit(self):
        # A gitlink was staged without a corresponding DEPS change but the
        # SKIP_GITLINK_PRECOMMIT envvar was set.
        git.run(
            "update-index",
            "--replace",
            "--cacheinfo",
            f"160000,{'b' * 40},dep_a",
            cwd=self.repo,
        )
        diff_before_commit = git.run(
            "diff", "--cached", "--name-only", cwd=self.repo
        )
        self.env["SKIP_GITLINK_PRECOMMIT"] = "1"
        git.run(
            "commit",
            "-m",
            "gitlink only, skipping precommit",
            cwd=self.repo,
            env=self.env,
        )

        # Gitlink should be kept.
        self.assertIn("dep_a", diff_before_commit)
        diff_after_commit = git.run(
            "diff", "--name-only", "HEAD^", "HEAD", cwd=self.repo
        )
        self.assertIn("dep_a", diff_after_commit)

    def testPreCommit_OtherDEPSState(self):
        # DEPS is set to a git_dependencies state other than SYNC.
        deps_content = "\n".join(
            (
                f"git_dependencies = '{SUBMODULES}'",
                "deps = {",
                f'    "dep_a": "host://dep_a@{"a" * 40}",',
                f'    "dep_b": "host://dep_b@{"b" * 40}",',
                "}",
            )
        )
        self.write(self.repo, "DEPS", deps_content)
        git.run("add", ".", cwd=self.repo)
        git.run("commit", "-m", "change git_dependencies", cwd=self.repo)

        git.run(
            "update-index",
            "--replace",
            "--cacheinfo",
            f"160000,{'b' * 40},dep_a",
            cwd=self.repo,
        )
        diff_before_commit = git.run("diff", "--cached", cwd=self.repo)
        git.run("commit", "-m", "update dep_a", cwd=self.repo)

        # There should be no changes to the commit.
        diff_after_commit = git.run("diff", "HEAD^", "HEAD", cwd=self.repo)
        self.assertEqual(diff_before_commit, diff_after_commit)


if __name__ == "__main__":
    unittest.main()
