1#!/usr/bin/env python3 2# Copyright 2020 The Pigweed Authors 3# 4# Licensed under the Apache License, Version 2.0 (the "License"); you may not 5# use this file except in compliance with the License. You may obtain a copy of 6# the License at 7# 8# https://www.apache.org/licenses/LICENSE-2.0 9# 10# Unless required by applicable law or agreed to in writing, software 11# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13# License for the specific language governing permissions and limitations under 14# the License. 15"""Creates a Git hook that calls a script with certain arguments.""" 16 17import argparse 18import logging 19import os 20from pathlib import Path 21import re 22import shlex 23import subprocess 24from typing import Optional, Sequence, Union 25 26_LOG: logging.Logger = logging.getLogger(__name__) 27 28 29def git_repo_root(path: Union[Path, str]) -> Path: 30 return Path( 31 subprocess.run( 32 ['git', '-C', path, 'rev-parse', '--show-toplevel'], 33 check=True, 34 stdout=subprocess.PIPE, 35 ) 36 .stdout.strip() 37 .decode() 38 ) 39 40 41def _stdin_args_for_hook(hook) -> Sequence[str]: 42 """Gives stdin arguments for each hook. 43 44 See https://git-scm.com/docs/githooks for more information. 45 """ 46 if hook == 'pre-push': 47 return ( 48 'local_ref', 49 'local_object_name', 50 'remote_ref', 51 'remote_object_name', 52 ) 53 if hook in ('pre-receive', 'post-receive', 'reference-transaction'): 54 return ('old_value', 'new_value', 'ref_name') 55 if hook == 'post-rewrite': 56 return ('old_object_name', 'new_object_name') 57 return () 58 59 60def _replace_arg_in_hook(arg: str, unquoted_args: Sequence[str]) -> str: 61 if arg in unquoted_args: 62 return arg 63 return shlex.quote(arg) 64 65 66def install_git_hook( 67 hook: str, 68 command: Sequence[Union[Path, str]], 69 repository: Union[Path, str] = '.', 70) -> None: 71 """Installs a simple Git hook that executes the provided command. 72 73 Args: 74 hook: Git hook to install, e.g. 'pre-push'. 75 command: Command to execute as the hook. The command is executed from the 76 root of the repo. Arguments are sanitised with `shlex.quote`, except 77 for any arguments are equal to f'${stdin_arg}' for some `stdin_arg` 78 that matches a standard-input argument to the git hook. 79 repository: Repository to install the hook in. 80 """ 81 if not command: 82 raise ValueError('The command cannot be empty!') 83 84 root = git_repo_root(repository).resolve() 85 86 if root.joinpath('.git').is_dir(): 87 hook_path = root.joinpath('.git', 'hooks', hook) 88 else: # This repo is probably a submodule with a .git file instead 89 match = re.match('^gitdir: (.*)$', root.joinpath('.git').read_text()) 90 if not match: 91 raise ValueError('Unexpected format for .git file') 92 93 hook_path = root.joinpath(match.group(1), 'hooks', hook).resolve() 94 95 hook_path.parent.mkdir(exist_ok=True) 96 97 hook_stdin_args = _stdin_args_for_hook(hook) 98 read_stdin_command = 'read ' + ' '.join(hook_stdin_args) 99 100 unquoted_args = [f'${arg}' for arg in hook_stdin_args] 101 args = (_replace_arg_in_hook(str(a), unquoted_args) for a in command[1:]) 102 103 command_str = ' '.join([shlex.quote(str(command[0])), *args]) 104 105 with hook_path.open('w') as file: 106 line = lambda *args: print(*args, file=file) 107 108 line('#!/bin/sh') 109 line(f'# {hook} hook generated by {__file__}') 110 line() 111 line('# Unset Git environment variables, which are set when this is ') 112 line('# run as a Git hook. These environment variables cause issues ') 113 line('# when trying to run Git commands on other repos from a ') 114 line('# submodule hook.') 115 line('unset $(git rev-parse --local-env-vars)') 116 line() 117 line('# Read the stdin args for the hook, made available by git.') 118 line(read_stdin_command) 119 line() 120 line(command_str) 121 122 hook_path.chmod(0o755) 123 logging.info( 124 'Installed %s hook for `%s` at %s', hook, command_str, hook_path 125 ) 126 127 128def argument_parser( 129 parser: Optional[argparse.ArgumentParser] = None, 130) -> argparse.ArgumentParser: 131 if parser is None: 132 parser = argparse.ArgumentParser(description=__doc__) 133 134 def path(arg: str) -> Path: 135 if not os.path.exists(arg): 136 raise argparse.ArgumentTypeError(f'"{arg}" is not a valid path') 137 138 return Path(arg) 139 140 parser.add_argument( 141 '-r', 142 '--repository', 143 default='.', 144 type=path, 145 help='Path to the repository in which to install the hook', 146 ) 147 parser.add_argument( 148 '--hook', required=True, help='Which type of Git hook to create' 149 ) 150 parser.add_argument( 151 'command', nargs='*', help='Command to run in the commit hook' 152 ) 153 154 return parser 155 156 157if __name__ == '__main__': 158 logging.basicConfig(format='%(message)s', level=logging.INFO) 159 install_git_hook(**vars(argument_parser().parse_args())) 160