mirror of
https://github.com/kp2pml30/git-third-party.git
synced 2026-02-16 23:54:41 +04:00
fix: split subcommands into classes
This commit is contained in:
parent
4e6cc662a7
commit
9b137600cd
4 changed files with 240 additions and 80 deletions
17
.editorconfig
Normal file
17
.editorconfig
Normal 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
32
.pre-commit-config.yaml
Normal 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
|
||||
212
git-third-party
212
git-third-party
|
|
@ -1,100 +1,156 @@
|
|||
#!/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 <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
#
|
||||
# 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 <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
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
9
ruff.toml
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue