Add gclient installhooks to add pre-commit hook

With submodules, users can accidentally stage and commit gitlink
changes. Add a new gclient command to install a pre-commit hook to
automatically drop gitlink changes that don't correspond to a DEPS
change.

Dropping gitlinks can be bypassed by setting
SKIP_GITLINK_PRECOMMIT=1.

Bug: 1481266
Change-Id: Idd8b273e7d8e37d52627964e8ed6004d068b6b7a
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/tools/depot_tools/+/4863221
Reviewed-by: Joanna Wang <jojwang@chromium.org>
Commit-Queue: Gavin Mak <gavinmak@google.com>
Reviewed-by: Josip Sokcevic <sokcevic@chromium.org>
diff --git a/gclient.py b/gclient.py
index 659fdb4..6a47709 100755
--- a/gclient.py
+++ b/gclient.py
@@ -128,6 +128,8 @@
 
 NO_SYNC_EXPERIMENT = 'no-sync'
 
+PRECOMMIT_HOOK_VAR = 'GCLIENT_PRECOMMIT'
+
 
 class GNException(Exception):
     pass
@@ -1982,6 +1984,46 @@
             patch_refs[patch_repo] = patch_ref
         return patch_refs, target_branches
 
+    def _InstallPreCommitHook(self):
+        # On Windows, this path is written to the file as
+        # "dir\hooks\pre-commit.py" but it gets interpreted as
+        # "dirhookspre-commit.py".
+        gclient_hook_path = os.path.join(DEPOT_TOOLS_DIR, 'hooks',
+                                         'pre-commit.py').replace('\\', '\\\\')
+        gclient_hook_content = '\n'.join((
+            f'{PRECOMMIT_HOOK_VAR}={gclient_hook_path}',
+            f'if [ -f "${PRECOMMIT_HOOK_VAR}" ]; then',
+            f'    python3 "${PRECOMMIT_HOOK_VAR}" || exit 1',
+            'fi',
+        ))
+
+        soln = gclient_paths.GetPrimarySolutionPath()
+        if not soln:
+            print('Could not find gclient solution.')
+            return
+
+        git_dir = os.path.join(soln, '.git')
+        if not os.path.exists(git_dir):
+            return
+
+        hook = os.path.join(git_dir, 'hooks', 'pre-commit')
+        if os.path.exists(hook):
+            with open(hook, 'r') as f:
+                content = f.read()
+            if PRECOMMIT_HOOK_VAR in content:
+                print(f'{hook} already contains the gclient pre-commit hook.')
+            else:
+                print(f'A pre-commit hook already exists at {hook}.\n'
+                      f'Please append the following lines to the hook:\n\n'
+                      f'{gclient_hook_content}')
+            return
+
+        print(f'Creating a pre-commit hook at {hook}')
+        with open(hook, 'w') as f:
+            f.write('#!/bin/sh\n')
+            f.write(f'{gclient_hook_content}\n')
+        os.chmod(hook, 0o755)
+
     def _RemoveUnversionedGitDirs(self):
         """Remove directories that are no longer part of the checkout.
 
@@ -3552,6 +3594,23 @@
     return client.RunOnDeps('runhooks', args)
 
 
+# TODO(crbug.com/1481266): Collect merics for installhooks.
+def CMDinstallhooks(parser, args):
+    """Installs gclient git hooks.
+
+    Currently only installs a pre-commit hook to drop staged gitlinks. To
+    bypass this pre-commit hook once it's installed, set the environment
+    variable SKIP_GITLINK_PRECOMMIT=1.
+    """
+    (options, args) = parser.parse_args(args)
+    client = GClient.LoadCurrentConfig(options)
+    if not client:
+        raise gclient_utils.Error(
+            'client not configured; see \'gclient config\'')
+    client._InstallPreCommitHook()
+    return 0
+
+
 @metrics.collector.collect_metrics('gclient revinfo')
 def CMDrevinfo(parser, args):
     """Outputs revision info mapping for the client and its dependencies.