#!/usr/bin/env python3 # Copyright 2020 The Pigweed Authors # # Licensed under the Apache License, Version 2.0 (the "License"); you may not # use this file except in compliance with the License. You may obtain a copy of # the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations under # the License. """Creates a Git hook that calls a script with certain arguments.""" import argparse import logging import os from pathlib import Path import re import shlex import subprocess from typing import Optional, Sequence, Union _LOG: logging.Logger = logging.getLogger(__name__) def git_repo_root(path: Union[Path, str]) -> Path: return Path( subprocess.run( ['git', '-C', path, 'rev-parse', '--show-toplevel'], check=True, stdout=subprocess.PIPE, ) .stdout.strip() .decode() ) def _stdin_args_for_hook(hook) -> Sequence[str]: """Gives stdin arguments for each hook. See https://git-scm.com/docs/githooks for more information. """ if hook == 'pre-push': return ( 'local_ref', 'local_object_name', 'remote_ref', 'remote_object_name', ) if hook in ('pre-receive', 'post-receive', 'reference-transaction'): return ('old_value', 'new_value', 'ref_name') if hook == 'post-rewrite': return ('old_object_name', 'new_object_name') return () def _replace_arg_in_hook(arg: str, unquoted_args: Sequence[str]) -> str: if arg in unquoted_args: return arg return shlex.quote(arg) def install_git_hook( hook: str, command: Sequence[Union[Path, str]], repository: Union[Path, str] = '.', ) -> None: """Installs a simple Git hook that executes the provided command. Args: hook: Git hook to install, e.g. 'pre-push'. command: Command to execute as the hook. The command is executed from the root of the repo. Arguments are sanitised with `shlex.quote`, except for any arguments are equal to f'${stdin_arg}' for some `stdin_arg` that matches a standard-input argument to the git hook. repository: Repository to install the hook in. """ if not command: raise ValueError('The command cannot be empty!') root = git_repo_root(repository).resolve() if root.joinpath('.git').is_dir(): hook_path = root.joinpath('.git', 'hooks', hook) else: # This repo is probably a submodule with a .git file instead match = re.match('^gitdir: (.*)$', root.joinpath('.git').read_text()) if not match: raise ValueError('Unexpected format for .git file') hook_path = root.joinpath(match.group(1), 'hooks', hook).resolve() hook_path.parent.mkdir(exist_ok=True) hook_stdin_args = _stdin_args_for_hook(hook) read_stdin_command = 'read ' + ' '.join(hook_stdin_args) unquoted_args = [f'${arg}' for arg in hook_stdin_args] args = (_replace_arg_in_hook(str(a), unquoted_args) for a in command[1:]) command_str = ' '.join([shlex.quote(str(command[0])), *args]) with hook_path.open('w') as file: line = lambda *args: print(*args, file=file) line('#!/bin/sh') line(f'# {hook} hook generated by {__file__}') line() line('# Unset Git environment variables, which are set when this is ') line('# run as a Git hook. These environment variables cause issues ') line('# when trying to run Git commands on other repos from a ') line('# submodule hook.') line('unset $(git rev-parse --local-env-vars)') line() line('# Read the stdin args for the hook, made available by git.') line(read_stdin_command) line() line(command_str) hook_path.chmod(0o755) logging.info( 'Installed %s hook for `%s` at %s', hook, command_str, hook_path ) def argument_parser( parser: Optional[argparse.ArgumentParser] = None, ) -> argparse.ArgumentParser: if parser is None: parser = argparse.ArgumentParser(description=__doc__) def path(arg: str) -> Path: if not os.path.exists(arg): raise argparse.ArgumentTypeError(f'"{arg}" is not a valid path') return Path(arg) parser.add_argument( '-r', '--repository', default='.', type=path, help='Path to the repository in which to install the hook', ) parser.add_argument( '--hook', required=True, help='Which type of Git hook to create' ) parser.add_argument( 'command', nargs='*', help='Command to run in the commit hook' ) return parser if __name__ == '__main__': logging.basicConfig(format='%(message)s', level=logging.INFO) install_git_hook(**vars(argument_parser().parse_args()))