blob: 7aad89edbbb976d4722852619ea9c26b11f2880f [file] [log] [blame]
#!/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
# 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(['git', '-C', path, 'rev-parse', '--show-toplevel'],
def _stdin_args_for_hook(hook) -> Sequence[str]:
"""Gives stdin arguments for each hook.
See for more information.
if hook == 'pre-push':
return ('local_ref', 'local_object_name', 'remote_ref',
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.
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(, 'hooks', hook).resolve()
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'w') as file:
line = lambda *args: print(*args, file=file)
line(f'# {hook} hook generated by {__file__}')
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('# Read the stdin args for the hook, made available by git.')
hook_path.chmod(0o755)'Installed %s hook for `%s` at %s', hook, command_str,
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)
help='Path to the repository in which to install the hook')
help='Which type of Git hook to create')
help='Path to the script to execute in the hook')
help='Arguments to provide to the commit hook')
return parser
if __name__ == '__main__':
logging.basicConfig(format='%(message)s', level=logging.INFO)