fix: add logging, fix bugs

This commit is contained in:
kp2pml30 2025-03-22 03:19:05 +04:00
parent 9b137600cd
commit 49cacfdb5e
3 changed files with 79 additions and 41 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
/.ruff_cache
__pycache__
/third-party

View file

@ -1,10 +1,10 @@
# Git third party # Git third party
Small zero-dependency python utility that is an alternative to [git-submodules](https://git-scm.com/book/en/v2/Git-Tools-Submodules) and [git-subtree](https://manpages.debian.org/testing/git-man/git-subtree.1.en.html), that allows patching third-party code, storing commits in your tree Tool for patching third-party libraries without need to fork them
No need to fork, no need to store all code in your tree, store just your patches This is a small zero-dependency utility that is an alternative to [git-submodules](https://git-scm.com/book/en/v2/Git-Tools-Submodules) and [git-subtree](https://manpages.debian.org/testing/git-man/git-subtree.1.en.html). It stores only delta (changes) inside your tree
**WARNING:** it is a raw prototype This tool is not oriented for highly concurrent modification of third-party tools by multiple users
## Usage ## Usage
```bash ```bash
@ -25,9 +25,11 @@ git-third-party update third-party/RustPython
## How it works? ## How it works?
It stores (at `/.gitthirdparty`) a configutation that describes all third party repositories and patches (at `/.gitthirdparty-patches/<local/repo/path>`). `save` command updates the patches, `update` command reapplies them It stores (at `/.git-third-party/`) a configuration that describes all third party repositories and patches. `save` command updates the patches, `update` command reapplies them
## Todo Directory `.git-third-party` must be controlled by git, while third-party repos should not
- [ ] commit hooks
- [ ] updating all Best effort is done to keep patches deterministic and strip all metadata from them, which includes:
- [ ] automated tests - git version
- commit hash (which depends on committer)
- ...

View file

@ -2,7 +2,7 @@
# #
# 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-2025 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
@ -24,31 +24,43 @@ import os
import sys import sys
import abc import abc
import typing import typing
import json
import textwrap
import io
import logging
from pathlib import Path from pathlib import Path
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
GIT = os.getenv('GIT', 'git') GIT = os.getenv('GIT', 'git')
try: try:
top_dir_str = subprocess.check_output( top_dir_str = subprocess.check_output(
['git', 'rev-parse', '--show-toplevel'], text=True [GIT, 'rev-parse', '--show-toplevel'], text=True
)
except Exception as e:
print(
f'could not execute `{GIT} rev-parse --show-toplevel`. You may wish to set `GIT` env variable',
file=sys.stderr,
) )
except:
exit(1) exit(1)
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') SAVED_FILES_PATH = top_dir.joinpath('.git-third-party')
if not config_path.exists():
PATCHES_PATH = SAVED_FILES_PATH.joinpath('patches')
CONFIG_PATH = SAVED_FILES_PATH.joinpath('config.json')
if not PATCHES_PATH.exists():
logging.info(f'patches directory does not exist, creating path=`{PATCHES_PATH}`')
PATCHES_PATH.mkdir(parents=True, exist_ok=True)
if not CONFIG_PATH.exists():
config = {'repos': {}} config = {'repos': {}}
else: else:
import json config = json.loads(CONFIG_PATH.read_text())
with open(config_path) as f:
config = json.load(f)
import textwrap
import io
class Command: class Command:
@ -59,19 +71,20 @@ class Command:
def handle(args: list[str]) -> None: ... def handle(args: list[str]) -> None: ...
def show_help(self, file: io.StringIO): def show_help(self, file: io.StringIO):
file.write(f'subcommand `{self.name}`:') file.write(f'subcommand `{self.name}`:\n')
file.write(textwrap.indent(textwrap.dedent(self.help), prefix='\t')) file.write(textwrap.indent(textwrap.dedent(self.help), prefix='\t').rstrip())
file.write('\n')
def _get_patches_dir(name: str) -> Path: def _get_patches_dir(name: str) -> Path:
return top_dir.joinpath('.gitthirdparty-patches', *name.split('/')) return PATCHES_PATH.joinpath(*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( rs = subprocess.run(
['git', 'diff', '--exit-code', '--quiet', *opts], check=False, cwd=target_dir [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} )')
@ -80,37 +93,49 @@ def _dirty_check(name: str) -> None:
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('/'))
logging.info(f'updating name={name} at path={target_dir}')
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( subprocess.run(
['git', 'remote', 'add', 'origin', conf['url']], check=True, cwd=target_dir [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, '-c', 'advice.detachedHead=false', 'checkout', conf['commit']],
check=False,
cwd=target_dir,
)
if rs.returncode != 0: if rs.returncode != 0:
logging.info(f'checking out {conf["commit"]} failed, fetching')
subprocess.run( subprocess.run(
['git', 'fetch', 'origin', '--depth=1', conf['commit']], [GIT, 'fetch', 'origin', '--depth=1', conf['commit']],
check=True,
cwd=target_dir,
)
subprocess.run(
[GIT, '-c', 'advice.detachedHead=false', 'checkout', conf['commit']],
check=True, check=True,
cwd=target_dir, cwd=target_dir,
) )
subprocess.run(['git', 'checkout', conf['commit']], check=True, cwd=target_dir)
subprocess.run( subprocess.run(
['git', 'submodule', 'update', '--init', '--recursive', '--depth', '1'], [GIT, 'submodule', 'update', '--init', '--recursive', '--depth', '1'],
check=True, check=True,
cwd=target_dir, cwd=target_dir,
) )
submodules = conf.get('submodules', None) submodules = conf.get('submodules', None)
if submodules is None: if submodules is None:
logging.info('updating all submodules')
subprocess.run( subprocess.run(
['git', 'submodule', 'update', '--recursive', '--depth', '1'], [GIT, 'submodule', 'update', '--recursive', '--depth', '1'],
check=True, check=True,
cwd=target_dir, cwd=target_dir,
) )
elif len(submodules) != 0: elif len(submodules) != 0:
logging.info(f'updating submodules {submodules}')
subprocess.run( subprocess.run(
['git', 'submodule', 'update', '--recursive', '--depth', '1', '--'] + submodules, [GIT, 'submodule', 'update', '--recursive', '--depth', '1', '--'] + submodules,
check=True, check=True,
cwd=target_dir, cwd=target_dir,
) )
@ -118,7 +143,9 @@ def _update_at(name: str) -> None:
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)
else:
logging.warning(f'no patches detected')
class GitThirdPartyException(Exception): class GitThirdPartyException(Exception):
@ -139,6 +166,8 @@ class AddCommand(Command):
help = f"""\ help = f"""\
{EXE_NAME} {name} <PATH> <REPO URL> <COMMIT> {EXE_NAME} {name} <PATH> <REPO URL> <COMMIT>
Adds a new repository to track
Example: `{EXE_NAME} {name} third-party/my-cool-tool https://github.com/kp2pml30/git-third-party 4e6cc662a7e2b409e36d2412440c5670d6034431`
""" """
def handle(self, args: list[str]) -> None: def handle(self, args: list[str]) -> None:
@ -157,7 +186,7 @@ class AddCommand(Command):
print(f'repo ({name}) is already in config', file=sys.stderr) print(f'repo ({name}) is already in config', file=sys.stderr)
exit(1) exit(1)
target_dir = top_dir.joinpath(*name.split('/')) target_dir = top_dir.joinpath(*name.split('/'))
res = subprocess.run(['git', 'check-ignore', target_dir], check=False) res = subprocess.run([GIT, 'check-ignore', target_dir], check=False)
if res.returncode != 0: if res.returncode != 0:
raise GitThirdPartyException( raise GitThirdPartyException(
f'target directory ({target_dir}) is not ignored by git' f'target directory ({target_dir}) is not ignored by git'
@ -191,7 +220,7 @@ class UpdateCommand(Command):
restore state of specified repositories restore state of specified repositories
""" """
def handle(args): def handle(self, args):
names = _get_names_from_paths(args) names = _get_names_from_paths(args)
for name in names: for name in names:
_update_at(name) _update_at(name)
@ -206,7 +235,7 @@ class SaveCommand(Command):
record state of specified repositories record state of specified repositories
""" """
def handle(args): def handle(self, args):
names = _get_names_from_paths(args) names = _get_names_from_paths(args)
for name in names: for name in names:
_save_at(name) _save_at(name)
@ -214,15 +243,17 @@ class SaveCommand(Command):
def _save_at(name: str) -> None: def _save_at(name: str) -> None:
_dirty_check(name) _dirty_check(name)
target_dir = top_dir.joinpath(*name.split('/'))
logging.info(f'saving name={name} at path={target_dir}')
conf = config['repos'][name] conf = config['repos'][name]
patches_dir = _get_patches_dir(name) patches_dir = _get_patches_dir(name)
for i in range(1, conf['patches'] + 1): for i in range(1, conf['patches'] + 1):
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('/'))
subprocess.run( subprocess.run(
[ [
'git', GIT,
'format-patch', 'format-patch',
f'{conf["commit"]}..HEAD', f'{conf["commit"]}..HEAD',
'--no-numbered', '--no-numbered',
@ -236,13 +267,15 @@ def _save_at(name: str) -> None:
cwd=target_dir, cwd=target_dir,
) )
new_patches = subprocess.run( new_patches = subprocess.run(
['git', 'rev-list', '--count', f'{conf["commit"]}..HEAD'], [GIT, 'rev-list', '--count', f'{conf["commit"]}..HEAD'],
check=True, check=True,
cwd=target_dir, cwd=target_dir,
text=True, text=True,
capture_output=True, capture_output=True,
) )
conf['patches'] = int(new_patches.stdout.strip()) patches_cnt = int(new_patches.stdout.strip())
logging.info(f'number of patches is {patches_cnt}')
conf['patches'] = patches_cnt
mode = sys.argv[1] if len(sys.argv) > 1 else 'help' mode = sys.argv[1] if len(sys.argv) > 1 else 'help'
@ -270,6 +303,6 @@ except GitThirdPartyException as e:
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')