| #!/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 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_hook(script, |
| hook: str, |
| args: Sequence[str] = (), |
| repository: Union[Path, str] = '.') -> None: |
| """This function is deprecated; use install_git_hook instead. |
| |
| This version of the function takes the script separately from the arguments |
| and calculates the relative path to the script from the root of the repo. |
| This does not work well when the path is to a file installed in the virtual |
| environment, since the execute permission may be lost. Instead of using a |
| path to a script, invoke the script from `python -m` or the `pw` command. |
| """ |
| root = git_repo_root(repository).resolve() |
| install_git_hook(hook, [os.path.relpath(script, root), *args], repository) |
| |
| |
| 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=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('-s', |
| '--script', |
| required=True, |
| type=path, |
| help='Path to the script to execute in the hook') |
| parser.add_argument('args', |
| nargs='*', |
| help='Arguments to provide to the commit hook') |
| |
| return parser |
| |
| |
| if __name__ == '__main__': |
| logging.basicConfig(format='%(message)s', level=logging.INFO) |
| install_hook(**vars(argument_parser().parse_args())) |