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

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

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

from gclient import PRECOMMIT_HOOK_VAR
import gclient_utils
from gclient_eval import SYNC, SUBMODULES
import git_common as git


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