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