• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 Sequence
25
26_LOG: logging.Logger = logging.getLogger(__name__)
27
28
29def git_repo_root(path: 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[Path | str],
69    repository: 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    try:
96        hook_path.parent.mkdir(exist_ok=True)
97    except FileExistsError as exc:
98        _LOG.warning('Failed to install %s hook: %s', hook, exc)
99        return
100
101    hook_stdin_args = _stdin_args_for_hook(hook)
102    read_stdin_command = 'read ' + ' '.join(hook_stdin_args)
103
104    unquoted_args = [f'${arg}' for arg in hook_stdin_args]
105    args = (_replace_arg_in_hook(str(a), unquoted_args) for a in command[1:])
106
107    command_str = ' '.join([shlex.quote(str(command[0])), *args])
108
109    with hook_path.open('w') as file:
110        line = lambda *args: print(*args, file=file)
111
112        line('#!/bin/sh')
113        line(f'# {hook} hook generated by {__file__}')
114        line()
115        line('# Unset Git environment variables, which are set when this is ')
116        line('# run as a Git hook. These environment variables cause issues ')
117        line('# when trying to run Git commands on other repos from a ')
118        line('# submodule hook.')
119        line('unset $(git rev-parse --local-env-vars)')
120        line()
121        line('# Read the stdin args for the hook, made available by git.')
122        line(read_stdin_command)
123        line()
124        line(command_str)
125
126    hook_path.chmod(0o755)
127    _LOG.info('Installed %s hook for `%s` at %s', hook, command_str, hook_path)
128
129
130def argument_parser(
131    parser: argparse.ArgumentParser | None = None,
132) -> argparse.ArgumentParser:
133    if parser is None:
134        parser = argparse.ArgumentParser(description=__doc__)
135
136    def path(arg: str) -> Path:
137        if not os.path.exists(arg):
138            raise argparse.ArgumentTypeError(f'"{arg}" is not a valid path')
139
140        return Path(arg)
141
142    parser.add_argument(
143        '-r',
144        '--repository',
145        default='.',
146        type=path,
147        help='Path to the repository in which to install the hook',
148    )
149    parser.add_argument(
150        '--hook', required=True, help='Which type of Git hook to create'
151    )
152    parser.add_argument(
153        'command', nargs='*', help='Command to run in the commit hook'
154    )
155
156    return parser
157
158
159if __name__ == '__main__':
160    logging.basicConfig(format='%(message)s', level=logging.INFO)
161    install_git_hook(**vars(argument_parser().parse_args()))
162