fix: split subcommands into classes

This commit is contained in:
kp2pml30 2025-03-22 02:44:50 +04:00
parent 4e6cc662a7
commit 9b137600cd
4 changed files with 240 additions and 80 deletions

17
.editorconfig Normal file
View file

@ -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

32
.pre-commit-config.yaml Normal file
View file

@ -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

View file

@ -20,81 +20,137 @@
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:
class AddCommand(Command):
name = 'add'
help = f"""\
{EXE_NAME} {name} <PATH> <REPO URL> <COMMIT>
"""
def handle(self, args: list[str]) -> None:
if len(args) != 3:
print(f'Expected `{EXE_NAME} add <PATH> <REPO URL> <COMMIT>`, got {args}', file=sys.stderr)
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)
print(f'bad path `{path}`', file=sys.stderr)
exit(1)
name = _get_path_rel_to_dir(path)
if name in config['repos']:
@ -103,7 +159,9 @@ def add(args: list[str]) -> None:
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')
raise GitThirdPartyException(
f'target directory ({target_dir}) is not ignored by git'
)
config['repos'][name] = {
'url': repo,
'commit': commit,
@ -111,26 +169,49 @@ def add(args: list[str]) -> None:
}
_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:
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)
def save(args: list[str]) -> None:
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)
conf = config['repos'][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 '<not provided>'
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')

9
ruff.toml Normal file
View file

@ -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