diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0416a3b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/.ruff_cache +__pycache__ +/third-party diff --git a/README.md b/README.md index 44a6a5f..af88763 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # 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 ```bash @@ -25,9 +25,11 @@ git-third-party update third-party/RustPython ## How it works? -It stores (at `/.gitthirdparty`) a configutation that describes all third party repositories and patches (at `/.gitthirdparty-patches/`). `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 -- [ ] commit hooks -- [ ] updating all -- [ ] automated tests +Directory `.git-third-party` must be controlled by git, while third-party repos should not + +Best effort is done to keep patches deterministic and strip all metadata from them, which includes: +- git version +- commit hash (which depends on committer) +- ... diff --git a/git-third-party b/git-third-party index 4014f49..9843b7f 100755 --- a/git-third-party +++ b/git-third-party @@ -2,7 +2,7 @@ # # 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 # it under the terms of the GNU General Public License as published by @@ -24,31 +24,43 @@ import os import sys import abc import typing +import json +import textwrap +import io +import logging from pathlib import Path +logging.basicConfig(stream=sys.stderr, level=logging.INFO) + GIT = os.getenv('GIT', 'git') try: 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) top_dir = Path(top_dir_str.strip()) cur_dir = Path(os.getcwd()) -config_path = top_dir.joinpath('.gitthirdparty') -if not config_path.exists(): +SAVED_FILES_PATH = top_dir.joinpath('.git-third-party') + +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': {}} else: - import json - - with open(config_path) as f: - config = json.load(f) - -import textwrap -import io + config = json.loads(CONFIG_PATH.read_text()) class Command: @@ -59,19 +71,20 @@ class Command: 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')) + file.write(f'subcommand `{self.name}`:\n') + file.write(textwrap.indent(textwrap.dedent(self.help), prefix='\t').rstrip()) + file.write('\n') 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: 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 + [GIT, 'diff', '--exit-code', '--quiet', *opts], check=False, cwd=target_dir ) if rs.returncode != 0: 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: conf = config['repos'][name] target_dir = top_dir.joinpath(*name.split('/')) + logging.info(f'updating name={name} at path={target_dir}') 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, 'init'], check=True, cwd=target_dir) 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) - 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: + logging.info(f'checking out {conf["commit"]} failed, fetching') 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, cwd=target_dir, ) - subprocess.run(['git', 'checkout', conf['commit']], check=True, cwd=target_dir) subprocess.run( - ['git', 'submodule', 'update', '--init', '--recursive', '--depth', '1'], + [GIT, 'submodule', 'update', '--init', '--recursive', '--depth', '1'], check=True, cwd=target_dir, ) submodules = conf.get('submodules', None) if submodules is None: + logging.info('updating all submodules') subprocess.run( - ['git', 'submodule', 'update', '--recursive', '--depth', '1'], + [GIT, 'submodule', 'update', '--recursive', '--depth', '1'], check=True, cwd=target_dir, ) elif len(submodules) != 0: + logging.info(f'updating submodules {submodules}') subprocess.run( - ['git', 'submodule', 'update', '--recursive', '--depth', '1', '--'] + submodules, + [GIT, 'submodule', 'update', '--recursive', '--depth', '1', '--'] + submodules, check=True, cwd=target_dir, ) @@ -118,7 +143,9 @@ def _update_at(name: str) -> None: 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) + subprocess.run([GIT, 'am', *patch_files], cwd=target_dir, check=True) + else: + logging.warning(f'no patches detected') class GitThirdPartyException(Exception): @@ -139,6 +166,8 @@ class AddCommand(Command): help = f"""\ {EXE_NAME} {name} + 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: @@ -157,7 +186,7 @@ class AddCommand(Command): 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) + 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' @@ -191,7 +220,7 @@ class UpdateCommand(Command): restore state of specified repositories """ - def handle(args): + def handle(self, args): names = _get_names_from_paths(args) for name in names: _update_at(name) @@ -206,7 +235,7 @@ class SaveCommand(Command): record state of specified repositories """ - def handle(args): + def handle(self, args): names = _get_names_from_paths(args) for name in names: _save_at(name) @@ -214,15 +243,17 @@ class SaveCommand(Command): def _save_at(name: str) -> None: _dirty_check(name) + target_dir = top_dir.joinpath(*name.split('/')) + logging.info(f'saving name={name} at path={target_dir}') conf = config['repos'][name] patches_dir = _get_patches_dir(name) for i in range(1, conf['patches'] + 1): patches_dir.joinpath(str(i)).unlink() patches_dir.mkdir(parents=True, exist_ok=True) - target_dir = top_dir.joinpath(*name.split('/')) + subprocess.run( [ - 'git', + GIT, 'format-patch', f'{conf["commit"]}..HEAD', '--no-numbered', @@ -236,13 +267,15 @@ def _save_at(name: str) -> None: cwd=target_dir, ) new_patches = subprocess.run( - ['git', 'rev-list', '--count', f'{conf["commit"]}..HEAD'], + [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()) + 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' @@ -270,6 +303,6 @@ except GitThirdPartyException as e: import json -with open(config_path, 'wt') as f: +with open(CONFIG_PATH, 'wt') as f: json.dump(config, f, indent='\t') f.write('\n')