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
|
||||||
262
git-third-party
262
git-third-party
|
|
@ -1,135 +1,216 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
#
|
#
|
||||||
# This file is part of the git-third-party distribution (https://github.com/kp2pml30/git-third-party).
|
# 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
|
# Copyright (c) 2024 Kira Prokopenko kp2pml30@gmail.com
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU General Public License as published by
|
||||||
# the Free Software Foundation, version 3.
|
# the Free Software Foundation, version 3.
|
||||||
#
|
#
|
||||||
# This program is distributed in the hope that it will be useful, but
|
# This program is distributed in the hope that it will be useful, but
|
||||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||||
# General Public License for more details.
|
# General Public License for more details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
|
|
||||||
EXE_NAME = 'git third-party'
|
EXE_NAME = 'git third-party'
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import abc
|
||||||
|
import typing
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
GIT = os.getenv('GIT', 'git')
|
||||||
|
|
||||||
try:
|
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:
|
except:
|
||||||
exit(1)
|
exit(1)
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
import os
|
|
||||||
top_dir = Path(top_dir_str.strip())
|
top_dir = Path(top_dir_str.strip())
|
||||||
cur_dir = Path(os.getcwd())
|
cur_dir = Path(os.getcwd())
|
||||||
|
|
||||||
config_path = top_dir.joinpath('.gitthirdparty')
|
config_path = top_dir.joinpath('.gitthirdparty')
|
||||||
if not config_path.exists():
|
if not config_path.exists():
|
||||||
config = {
|
config = {'repos': {}}
|
||||||
"repos": {}
|
|
||||||
}
|
|
||||||
else:
|
else:
|
||||||
import json
|
import json
|
||||||
|
|
||||||
with open(config_path) as f:
|
with open(config_path) as f:
|
||||||
config = json.load(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:
|
def _get_patches_dir(name: str) -> Path:
|
||||||
return top_dir.joinpath('.gitthirdparty-patches', *name.split('/'))
|
return top_dir.joinpath('.gitthirdparty-patches', *name.split('/'))
|
||||||
|
|
||||||
|
|
||||||
def _dirty_check(name: str) -> None:
|
def _dirty_check(name: str) -> None:
|
||||||
target_dir = top_dir.joinpath(*name.split('/'))
|
target_dir = top_dir.joinpath(*name.split('/'))
|
||||||
for opts in ([], ['--cached']):
|
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:
|
if rs.returncode != 0:
|
||||||
raise GitThirdPartyException(f'repo {name} is dirty ( {target_dir} )')
|
raise GitThirdPartyException(f'repo {name} is dirty ( {target_dir} )')
|
||||||
|
|
||||||
|
|
||||||
def _update_at(name: str) -> None:
|
def _update_at(name: str) -> None:
|
||||||
conf = config['repos'][name]
|
conf = config['repos'][name]
|
||||||
target_dir = top_dir.joinpath(*name.split('/'))
|
target_dir = top_dir.joinpath(*name.split('/'))
|
||||||
if not target_dir.exists():
|
if not target_dir.exists():
|
||||||
target_dir.mkdir(parents=True, exist_ok=True)
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
subprocess.run(['git', 'init'], check=True, cwd=target_dir)
|
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)
|
_dirty_check(name)
|
||||||
rs = subprocess.run(['git', 'checkout', conf['commit']], check=False, cwd=target_dir)
|
rs = subprocess.run(['git', 'checkout', conf['commit']], check=False, cwd=target_dir)
|
||||||
if rs.returncode != 0:
|
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', '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)
|
submodules = conf.get('submodules', None)
|
||||||
if submodules is 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:
|
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
|
# apply patches
|
||||||
patches_dir = _get_patches_dir(name)
|
patches_dir = _get_patches_dir(name)
|
||||||
patch_files = [patches_dir.joinpath(str(i)) for i in range(1, conf['patches'] + 1)]
|
patch_files = [patches_dir.joinpath(str(i)) for i in range(1, conf['patches'] + 1)]
|
||||||
if len(patch_files) != 0:
|
if len(patch_files) != 0:
|
||||||
subprocess.run(['git', 'am', *patch_files], cwd=target_dir, check=True)
|
subprocess.run(['git', 'am', *patch_files], cwd=target_dir, check=True)
|
||||||
|
|
||||||
|
|
||||||
class GitThirdPartyException(Exception):
|
class GitThirdPartyException(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _get_path_rel_to_dir(path: str) -> str:
|
def _get_path_rel_to_dir(path: str) -> str:
|
||||||
target_dir = cur_dir.joinpath(Path(path))
|
target_dir = cur_dir.joinpath(Path(path))
|
||||||
if top_dir not in target_dir.parents:
|
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, '/')
|
return str(target_dir.relative_to(top_dir)).replace(os.sep, '/')
|
||||||
|
|
||||||
def add(args: list[str]) -> None:
|
|
||||||
if len(args) != 3:
|
class AddCommand(Command):
|
||||||
print(f'Expected `{EXE_NAME} add <PATH> <REPO URL> <COMMIT>`, got {args}', file=sys.stderr)
|
name = 'add'
|
||||||
exit(1)
|
help = f"""\
|
||||||
path, repo, commit = args
|
{EXE_NAME} {name} <PATH> <REPO URL> <COMMIT>
|
||||||
import re
|
|
||||||
if re.search(r'[\\:;]', path):
|
"""
|
||||||
print(f'bad path {path}', file=sys.stderr)
|
|
||||||
exit(1)
|
def handle(self, args: list[str]) -> None:
|
||||||
name = _get_path_rel_to_dir(path)
|
if len(args) != 3:
|
||||||
if name in config['repos']:
|
print(f'Unexpected arguments {args}', file=sys.stderr)
|
||||||
print(f'repo ({name}) is already in config', file=sys.stderr)
|
self.show_help(sys.stderr)
|
||||||
exit(1)
|
exit(1)
|
||||||
target_dir = top_dir.joinpath(*name.split('/'))
|
path, repo, commit = args
|
||||||
res = subprocess.run(['git', 'check-ignore', target_dir], check=False)
|
import re
|
||||||
if res.returncode != 0:
|
|
||||||
raise GitThirdPartyException(f'target directory ({target_dir}) is not ignored by git')
|
if re.search(r'[\\:;]', path):
|
||||||
config['repos'][name] = {
|
print(f'bad path `{path}`', file=sys.stderr)
|
||||||
'url': repo,
|
exit(1)
|
||||||
'commit': commit,
|
name = _get_path_rel_to_dir(path)
|
||||||
'patches': 0,
|
if name in config['repos']:
|
||||||
}
|
print(f'repo ({name}) is already in config', file=sys.stderr)
|
||||||
_update_at(name)
|
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]:
|
def _get_all_names() -> list[str]:
|
||||||
return list(config['repos'].keys())
|
return list(config['repos'].keys())
|
||||||
|
|
||||||
|
|
||||||
def _get_names_from_paths(paths: list[str]) -> list[str]:
|
def _get_names_from_paths(paths: list[str]) -> list[str]:
|
||||||
if len(paths) == 0:
|
if len(paths) == 0:
|
||||||
raise GitThirdPartyException("excepted non-empty list of arguments")
|
raise GitThirdPartyException('excepted non-empty list of arguments')
|
||||||
if paths == ['--all']:
|
if paths == ['--all']:
|
||||||
return _get_all_names()
|
return _get_all_names()
|
||||||
return list(set(map(_get_path_rel_to_dir, paths)))
|
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:
|
class UpdateCommand(Command):
|
||||||
names = _get_names_from_paths(args)
|
name = 'update'
|
||||||
for name in names:
|
help = f"""\
|
||||||
_save_at(name)
|
{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:
|
def _save_at(name: str) -> None:
|
||||||
_dirty_check(name)
|
_dirty_check(name)
|
||||||
|
|
@ -139,35 +220,56 @@ def _save_at(name: str) -> None:
|
||||||
patches_dir.joinpath(str(i)).unlink()
|
patches_dir.joinpath(str(i)).unlink()
|
||||||
patches_dir.mkdir(parents=True, exist_ok=True)
|
patches_dir.mkdir(parents=True, exist_ok=True)
|
||||||
target_dir = top_dir.joinpath(*name.split('/'))
|
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)
|
subprocess.run(
|
||||||
new_patches = subprocess.run(['git', 'rev-list', '--count', f'{conf["commit"]}..HEAD'], check=True, cwd=target_dir, text=True, capture_output=True)
|
[
|
||||||
|
'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())
|
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):
|
mode = sys.argv[1] if len(sys.argv) > 1 else 'help'
|
||||||
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 '<not provided>'
|
handlers: dict[str, Command] = {}
|
||||||
mode_handler = modes.get(mode, None)
|
|
||||||
|
for c in [UpdateCommand, SaveCommand, AddCommand]:
|
||||||
|
handlers[c.name] = c()
|
||||||
|
|
||||||
|
mode_handler = handlers.get(mode, None)
|
||||||
if mode_handler is 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:
|
try:
|
||||||
mode_handler(sys.argv[2:])
|
mode_handler.handle(sys.argv[2:])
|
||||||
except GitThirdPartyException as e:
|
except GitThirdPartyException as e:
|
||||||
print(*e.args, file=sys.stderr)
|
print(*e.args, file=sys.stderr)
|
||||||
exit(1)
|
exit(1)
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
with open(config_path, 'wt') as f:
|
with open(config_path, 'wt') as f:
|
||||||
json.dump(config, f, indent='\t')
|
json.dump(config, f, indent='\t')
|
||||||
f.write('\n')
|
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