#!/usr/bin/env python # License: GPLv3 Copyright: 2025, un_pogaz import os from contextlib import suppress from typing import NamedTuple from setup import Command HOOK_TEMPLATE = '''\ #!/usr/bin/env -S calibre-debug -e -- -- # File generated by calibre "setup.py git_hooks" import os import runpy import sys base = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.argv[0] = os.path.basename({file!r}) runpy.run_path(os.path.join(base, 'setup', {file!r}), run_name='__main__') ''' class Hook(NamedTuple): name: str file: str default: bool = True HOOKS = {h.name:h for h in ( Hook('post-checkout', 'git_post_checkout_hook.py'), Hook('post-rewrite', 'git_post_rewrite_hook.py'), Hook('pre-commit', 'git_pre_commit_hook.py'), # disable by default, because except Kovid, nobody can run this hook Hook('commit-msg', 'git_commit_msg_hook.py', False), )} DEFAULT = ','.join(sorted(h.name for h in HOOKS.values() if h.default)) AVAILABLES = ', '.join(sorted(h for h in HOOKS)) class GitHooks(Command): description = 'Install/uninstall git hooks' def add_options(self, parser): parser.add_option('-n', '--name', default=DEFAULT, help='Name(s) of the hook to install, separated by commas. ' f'Default: "{DEFAULT}". Hooks available: {AVAILABLES}') parser.add_option('-u', '--uninstall', default=False, action='store_true', help='Uninstall the selected hooks') parser.add_option('-f', '--force', default=False, action='store_true', help='Force the operations on the hooks') def run(self, opts): self.force = opts.force self.names = [] invalides = [] for candidate in sorted(c.strip().lower() for c in opts.name.split(',')): if not candidate: continue if candidate not in HOOKS: invalides.append(candidate) else: self.names.append(candidate) if invalides: self.info('Info: The following hook names are not recognized:', ', '.join(invalides)) if not self.names: self.info('No supported hook names recognized.') return if opts.uninstall: self.uninstall() else: self.install() def _parse_template(self, hook_name): base_hooks = os.path.join(os.path.dirname(self.SRC), '.git', 'hooks') hook = HOOKS[hook_name] path = self.j(base_hooks, hook.name) script = HOOK_TEMPLATE.format(file=hook.file) return path, script def install(self): self.info('Installing the hooks:', ', '.join(self.names)) for candidate in self.names: path, script = self._parse_template(candidate) if self.e(path): with open(path, 'rb') as f: previous = f.read().decode('utf-8') msg = f'{candidate}: a non-calibre hook is installed.' if previous == script: self.info(f'{candidate}: installed.') continue elif self.force: self.info(msg, 'Force installation.') else: self.info(msg, 'Skip installation.') continue self.info(f'{candidate}: installed.') with suppress(OSError): os.remove(path) # remove if symlink with open(path, 'wb') as f: f.write(script.encode('utf-8')) try: os.chmod(path, 0o744, follow_symlinks=False) except NotImplementedError: # old python on windows os.chmod(path, 0o744) def uninstall(self): self.info('Uninstalling the hooks:', ', '.join(self.names)) for candidate in self.names: path, script = self._parse_template(candidate) if not self.e(path): self.info(f'{candidate}: no hook to unistall.') continue with open(path, 'rb') as f: previous = f.read().decode('utf-8') msg = f'{candidate}: a non-calibre hook is installed.' if previous == script: self.info(f'{candidate}: unistalled.') elif self.force: self.info(msg, 'Force unistallation.') else: self.info(msg, 'Skip unistallation.') continue os.remove(path)