diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..de2e132 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +trim_trailing_whitespace = true +indent_style = tab +tab_width = 2 + +[*.toml] +indent_style = space +indent_size = 4 + +[*.{yml,yaml}] +indent_style = space +indent_size = 2 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..7ac2b46 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,32 @@ +default_install_hook_types: + - pre-commit + - commit-msg + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-added-large-files + - id: check-json + - id: check-yaml + - id: check-toml + - id: check-merge-conflict + + - repo: https://github.com/compilerla/conventional-pre-commit + rev: v4.0.0 + hooks: + - id: conventional-pre-commit + stages: [commit-msg] + args: [] + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.9 + hooks: + - id: ruff-format + + - repo: https://github.com/editorconfig-checker/editorconfig-checker.python + rev: 3.0.3 + hooks: + - id: editorconfig-checker diff --git a/git-third-party b/git-third-party index 65d53af..4014f49 100755 --- a/git-third-party +++ b/git-third-party @@ -1,135 +1,216 @@ #!/usr/bin/env python3 - # - # This file is part of the git-third-party distribution (https://github.com/kp2pml30/git-third-party). - # Copyright (c) 2024 Kira Prokopenko kp2pml30@gmail.com - # - # This program is free software: you can redistribute it and/or modify - # it under the terms of the GNU General Public License as published by - # the Free Software Foundation, version 3. - # - # This program is distributed in the hope that it will be useful, but - # WITHOUT ANY WARRANTY; without even the implied warranty of - # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - # General Public License for more details. - # - # You should have received a copy of the GNU General Public License - # along with this program. If not, see . - # +# +# This file is part of the git-third-party distribution (https://github.com/kp2pml30/git-third-party). +# Copyright (c) 2024 Kira Prokopenko kp2pml30@gmail.com +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, version 3. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# EXE_NAME = 'git third-party' import subprocess +import os +import sys +import abc +import typing +from pathlib import Path + +GIT = os.getenv('GIT', 'git') try: - top_dir_str = subprocess.check_output(['git', 'rev-parse', '--show-toplevel'], text=True) + top_dir_str = subprocess.check_output( + ['git', 'rev-parse', '--show-toplevel'], text=True + ) except: exit(1) -from pathlib import Path -import os top_dir = Path(top_dir_str.strip()) cur_dir = Path(os.getcwd()) config_path = top_dir.joinpath('.gitthirdparty') if not config_path.exists(): - config = { - "repos": {} - } + config = {'repos': {}} else: import json + with open(config_path) as f: config = json.load(f) -import sys +import textwrap +import io + + +class Command: + name: typing.ClassVar[str] + help: str + + @abc.abstractmethod + def handle(args: list[str]) -> None: ... + + def show_help(self, file: io.StringIO): + file.write(f'subcommand `{self.name}`:') + file.write(textwrap.indent(textwrap.dedent(self.help), prefix='\t')) + def _get_patches_dir(name: str) -> Path: return top_dir.joinpath('.gitthirdparty-patches', *name.split('/')) + def _dirty_check(name: str) -> None: target_dir = top_dir.joinpath(*name.split('/')) for opts in ([], ['--cached']): - rs = subprocess.run(['git', 'diff', '--exit-code', '--quiet', *opts], check=False, cwd=target_dir) + rs = subprocess.run( + ['git', 'diff', '--exit-code', '--quiet', *opts], check=False, cwd=target_dir + ) if rs.returncode != 0: raise GitThirdPartyException(f'repo {name} is dirty ( {target_dir} )') + def _update_at(name: str) -> None: conf = config['repos'][name] target_dir = top_dir.joinpath(*name.split('/')) if not target_dir.exists(): target_dir.mkdir(parents=True, exist_ok=True) subprocess.run(['git', 'init'], check=True, cwd=target_dir) - subprocess.run(['git', 'remote', 'add', 'origin', conf['url']], check=True, cwd=target_dir) + subprocess.run( + ['git', 'remote', 'add', 'origin', conf['url']], check=True, cwd=target_dir + ) _dirty_check(name) rs = subprocess.run(['git', 'checkout', conf['commit']], check=False, cwd=target_dir) if rs.returncode != 0: - subprocess.run(['git', 'fetch', 'origin', '--depth=1', conf['commit']], check=True, cwd=target_dir) + subprocess.run( + ['git', 'fetch', 'origin', '--depth=1', conf['commit']], + check=True, + cwd=target_dir, + ) subprocess.run(['git', 'checkout', conf['commit']], check=True, cwd=target_dir) - subprocess.run(['git', 'submodule', 'update', '--init', '--recursive', '--depth', '1'], check=True, cwd=target_dir) + subprocess.run( + ['git', 'submodule', 'update', '--init', '--recursive', '--depth', '1'], + check=True, + cwd=target_dir, + ) submodules = conf.get('submodules', None) if submodules is None: - subprocess.run(['git', 'submodule', 'update', '--recursive', '--depth', '1'], check=True, cwd=target_dir) + subprocess.run( + ['git', 'submodule', 'update', '--recursive', '--depth', '1'], + check=True, + cwd=target_dir, + ) elif len(submodules) != 0: - subprocess.run(['git', 'submodule', 'update', '--recursive', '--depth', '1', '--'] + submodules, check=True, cwd=target_dir) + subprocess.run( + ['git', 'submodule', 'update', '--recursive', '--depth', '1', '--'] + submodules, + check=True, + cwd=target_dir, + ) # apply patches patches_dir = _get_patches_dir(name) patch_files = [patches_dir.joinpath(str(i)) for i in range(1, conf['patches'] + 1)] if len(patch_files) != 0: subprocess.run(['git', 'am', *patch_files], cwd=target_dir, check=True) + class GitThirdPartyException(Exception): pass + def _get_path_rel_to_dir(path: str) -> str: target_dir = cur_dir.joinpath(Path(path)) if top_dir not in target_dir.parents: - raise GitThirdPartyException(f'target directory ({target_dir}) is not in git subtree ({top_dir})') + raise GitThirdPartyException( + f'target directory ({target_dir}) is not in git subtree ({top_dir})' + ) return str(target_dir.relative_to(top_dir)).replace(os.sep, '/') -def add(args: list[str]) -> None: - if len(args) != 3: - print(f'Expected `{EXE_NAME} add `, got {args}', file=sys.stderr) - exit(1) - path, repo, commit = args - import re - if re.search(r'[\\:;]', path): - print(f'bad path {path}', file=sys.stderr) - exit(1) - name = _get_path_rel_to_dir(path) - if name in config['repos']: - print(f'repo ({name}) is already in config', file=sys.stderr) - exit(1) - target_dir = top_dir.joinpath(*name.split('/')) - res = subprocess.run(['git', 'check-ignore', target_dir], check=False) - if res.returncode != 0: - raise GitThirdPartyException(f'target directory ({target_dir}) is not ignored by git') - config['repos'][name] = { - 'url': repo, - 'commit': commit, - 'patches': 0, - } - _update_at(name) + +class AddCommand(Command): + name = 'add' + help = f"""\ + {EXE_NAME} {name} + + """ + + def handle(self, args: list[str]) -> None: + if len(args) != 3: + print(f'Unexpected arguments {args}', file=sys.stderr) + self.show_help(sys.stderr) + exit(1) + path, repo, commit = args + import re + + if re.search(r'[\\:;]', path): + print(f'bad path `{path}`', file=sys.stderr) + exit(1) + name = _get_path_rel_to_dir(path) + if name in config['repos']: + print(f'repo ({name}) is already in config', file=sys.stderr) + exit(1) + target_dir = top_dir.joinpath(*name.split('/')) + res = subprocess.run(['git', 'check-ignore', target_dir], check=False) + if res.returncode != 0: + raise GitThirdPartyException( + f'target directory ({target_dir}) is not ignored by git' + ) + config['repos'][name] = { + 'url': repo, + 'commit': commit, + 'patches': 0, + } + _update_at(name) + def _get_all_names() -> list[str]: return list(config['repos'].keys()) + def _get_names_from_paths(paths: list[str]) -> list[str]: if len(paths) == 0: - raise GitThirdPartyException("excepted non-empty list of arguments") + raise GitThirdPartyException('excepted non-empty list of arguments') if paths == ['--all']: return _get_all_names() return list(set(map(_get_path_rel_to_dir, paths))) -def update(args: list[str]) -> None: - names = _get_names_from_paths(args) - for name in names: - _update_at(name) -def save(args: list[str]) -> None: - names = _get_names_from_paths(args) - for name in names: - _save_at(name) +class UpdateCommand(Command): + name = 'update' + help = f"""\ + {EXE_NAME} {name} --all + {EXE_NAME} {name} relative/path/to/repo/1 relative/path/to/repo/2 + + restore state of specified repositories + """ + + def handle(args): + names = _get_names_from_paths(args) + for name in names: + _update_at(name) + + +class SaveCommand(Command): + name = 'save' + help = f"""\ + {EXE_NAME} {name} --all + {EXE_NAME} {name} relative/path/to/repo/1 relative/path/to/repo/2 + + record state of specified repositories + """ + + def handle(args): + names = _get_names_from_paths(args) + for name in names: + _save_at(name) + def _save_at(name: str) -> None: _dirty_check(name) @@ -139,35 +220,56 @@ def _save_at(name: str) -> None: patches_dir.joinpath(str(i)).unlink() patches_dir.mkdir(parents=True, exist_ok=True) target_dir = top_dir.joinpath(*name.split('/')) - subprocess.run(['git', 'format-patch', f'{conf["commit"]}..HEAD', '--no-numbered', '--zero-commit', '--no-signature', '--numbered-files', '-o', patches_dir], check=True, cwd=target_dir) - new_patches = subprocess.run(['git', 'rev-list', '--count', f'{conf["commit"]}..HEAD'], check=True, cwd=target_dir, text=True, capture_output=True) + subprocess.run( + [ + 'git', + 'format-patch', + f'{conf["commit"]}..HEAD', + '--no-numbered', + '--zero-commit', + '--no-signature', + '--numbered-files', + '-o', + patches_dir, + ], + check=True, + cwd=target_dir, + ) + new_patches = subprocess.run( + ['git', 'rev-list', '--count', f'{conf["commit"]}..HEAD'], + check=True, + cwd=target_dir, + text=True, + capture_output=True, + ) conf['patches'] = int(new_patches.stdout.strip()) -modes = { - "add": add, - "update": update, - "save": save, - "help": lambda x: show_help(*x, file=sys.stdout, exit_code=0) -} -def show_help(*args, file=sys.stderr, exit_code=1): - if len(args) != 0: - print(f"help gets no arguments, got {args}", file=file) - exit_code = 1 - print(f"USAGE: {EXE_NAME} {'|'.join(modes.keys())}", file=file) - exit(exit_code) +mode = sys.argv[1] if len(sys.argv) > 1 else 'help' -mode = sys.argv[1] if len(sys.argv) > 1 else '' -mode_handler = modes.get(mode, None) +handlers: dict[str, Command] = {} + +for c in [UpdateCommand, SaveCommand, AddCommand]: + handlers[c.name] = c() + +mode_handler = handlers.get(mode, None) if mode_handler is None: - show_help() + print( + f'{EXE_NAME} expected one of following subcommands: ' + '|'.join(handlers.keys()), + file=sys.stderr, + ) + for k in handlers.values(): + k.show_help(sys.stderr) + exit(1) + try: - mode_handler(sys.argv[2:]) + mode_handler.handle(sys.argv[2:]) except GitThirdPartyException as e: print(*e.args, file=sys.stderr) exit(1) import json + with open(config_path, 'wt') as f: json.dump(config, f, indent='\t') f.write('\n') diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..18748aa --- /dev/null +++ b/ruff.toml @@ -0,0 +1,9 @@ +indent-width = 2 + +[format] +indent-style = "tab" +quote-style = "single" +line-ending = "lf" + +skip-magic-trailing-comma = false +docstring-code-format = true