From 2c156c1c3ff8e8f367fa22fa0979814edf7f7c49 Mon Sep 17 00:00:00 2001 From: Maxim Slipenko Date: Sun, 5 Jan 2025 11:37:11 +0300 Subject: [PATCH] wip --- aides_spec/commands/build.py | 1 + aides_spec/commands/checksums.py | 73 +++++++ aides_spec/commands/create.py | 9 +- aides_spec/main.py | 2 + aides_spec/replacers/base.py | 48 ++++- aides_spec/replacers/checksums_replacer.py | 91 ++++++++ .../replacers/local_sources_replacer.py | 94 ++++++++ aides_spec/replacers/simple_replacer.py | 3 +- aides_spec/replacers/sources.py | 202 ++++++++++++++++++ aides_spec/utils/empty_template.py | 54 +++++ aides_spec/utils/from_pkgbuild.py | 71 ++++-- poetry.lock | 45 +++- pyproject.toml | 1 + 13 files changed, 675 insertions(+), 19 deletions(-) create mode 100644 aides_spec/commands/checksums.py create mode 100644 aides_spec/replacers/local_sources_replacer.py create mode 100644 aides_spec/replacers/sources.py create mode 100644 aides_spec/utils/empty_template.py diff --git a/aides_spec/commands/build.py b/aides_spec/commands/build.py index 4ffe9fe..bfdf5b7 100644 --- a/aides_spec/commands/build.py +++ b/aides_spec/commands/build.py @@ -23,6 +23,7 @@ def build( ) if result.returncode != 0: + print(result) exit(-1) package_name = result.stdout.strip() diff --git a/aides_spec/commands/checksums.py b/aides_spec/commands/checksums.py new file mode 100644 index 0000000..1499a28 --- /dev/null +++ b/aides_spec/commands/checksums.py @@ -0,0 +1,73 @@ +import hashlib +import os +import subprocess + +import requests +import typer + +app = typer.Typer() + + +def calculate_sha256(file_path): + sha256_hash = hashlib.sha256() + with open(file_path, "rb") as f: + for byte_block in iter(lambda: f.read(4096), b""): + sha256_hash.update(byte_block) + return sha256_hash.hexdigest() + + +def run_bash_command(command): + result = subprocess.run( + command, + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + if result.returncode != 0: + raise RuntimeError(f"Ошибка при выполнении команды: {result.stderr}") + return result.stdout.strip() + + +def download_file(url, dest_folder="downloads"): + os.makedirs(dest_folder, exist_ok=True) + local_filename = os.path.join(dest_folder, os.path.basename(url)) + with requests.get(url, stream=True, timeout=3000) as r: + r.raise_for_status() + with open(local_filename, "wb") as f: + for chunk in r.iter_content(chunk_size=8192): + f.write(chunk) + return local_filename + + +def update_checksums(script_path): + # Выполняем bash-скрипт, чтобы получить массив sources + command = ( + f"source {script_path} && printf '%s\\n' \"${{sources_amd64[@]}}\"" + ) + try: + sources_output = run_bash_command(command) + except RuntimeError as e: + print(f"Ошибка выполнения скрипта: {e}") + return + + sources = sources_output.splitlines() + if not sources: + print("Не удалось получить массив sources_amd64.") + return + + new_checksums = [] + for source_url in sources: + print(f"Скачивание: {source_url}") + local_file = download_file(source_url) + checksum = calculate_sha256(local_file) + new_checksums.append(f"sha256:{checksum}") + os.remove(local_file) + + new_checksums_block = " ".join(f"'{chk}'" for chk in new_checksums) + print(new_checksums_block) + + +@app.command() +def checksums(): + update_checksums(f"{os.getcwd()}/alr.sh") diff --git a/aides_spec/commands/create.py b/aides_spec/commands/create.py index ba6dce9..b46a263 100644 --- a/aides_spec/commands/create.py +++ b/aides_spec/commands/create.py @@ -3,6 +3,7 @@ from typing import Annotated, Optional import typer +from aides_spec.utils.empty_template import create_from_empty_template from aides_spec.utils.from_pkgbuild import ( create_from_pkgbuild, download_pkgbuild, @@ -15,9 +16,13 @@ app = typer.Typer() def create( from_aur: Annotated[Optional[str], typer.Option()] = None, from_pkgbuild: Annotated[Optional[str], typer.Option()] = None, - output_file: Annotated[str, typer.Option("--output", "-o")] = "alr.sh", + empty_template: Annotated[Optional[bool], typer.Option()] = None, ): - if from_aur: + output_file = "alr.sh" + + if empty_template: + create_from_empty_template(output_file) + elif from_aur: print(f"Загружаем PKGBUILD для пакета '{from_aur}' из AUR...") content = download_pkgbuild(from_aur) create_from_pkgbuild(content, output_file) diff --git a/aides_spec/main.py b/aides_spec/main.py index 88cdd36..5520f7d 100644 --- a/aides_spec/main.py +++ b/aides_spec/main.py @@ -1,12 +1,14 @@ import typer from aides_spec.commands.build import app as build_app +from aides_spec.commands.checksums import app as checksums_app from aides_spec.commands.create import app as create_app app = typer.Typer() app.add_typer(create_app) app.add_typer(build_app) +app.add_typer(checksums_app) def main(): diff --git a/aides_spec/replacers/base.py b/aides_spec/replacers/base.py index b2f6314..02de572 100644 --- a/aides_spec/replacers/base.py +++ b/aides_spec/replacers/base.py @@ -1,10 +1,30 @@ +from typing import TYPE_CHECKING + +from typing_extensions import List, TypedDict + +if TYPE_CHECKING: + from tree_sitter import Node, Tree + + +class Replaces(TypedDict): + node: Node + content: str + + +class Appends(TypedDict): + node: Node + content: str + + class BaseReplacer: - def __init__(self, content, tree): + def __init__(self, content, tree: Tree, ctx: dict | None = None): self.content = content self.tree = tree - self.replaces = [] + self.replaces: List[Replaces] = [] + self.appends: List[Appends] = [] + self.ctx = ctx - def _node_text(self, node): + def _node_text(self, node: Node): """Helper function to get the text of a node.""" return self.content[node.start_byte : node.end_byte].decode("utf-8") @@ -34,6 +54,28 @@ class BaseReplacer: replace_info["node"].start_point[1] + len(replacement), ), ) + + for append_info in sorted( + self.appends, + key=lambda x: x["node"].end_byte, + reverse=True, + ): + insertion_point = append_info["node"].end_byte + append_content = append_info["content"].encode("utf-8") + new_content[insertion_point:insertion_point] = append_content + + self.tree.edit( + start_byte=insertion_point, + old_end_byte=insertion_point, + new_end_byte=insertion_point + len(append_content), + start_point=append_info["node"].end_point, + old_end_point=append_info["node"].end_point, + new_end_point=( + append_info["node"].end_point[0], + append_info["node"].end_point[1] + len(append_content), + ), + ) + return new_content def process(self): diff --git a/aides_spec/replacers/checksums_replacer.py b/aides_spec/replacers/checksums_replacer.py index e69de29..6f74dc7 100644 --- a/aides_spec/replacers/checksums_replacer.py +++ b/aides_spec/replacers/checksums_replacer.py @@ -0,0 +1,91 @@ +from aides_spec.replacers.arch_replacer import ArchReplacer +from aides_spec.replacers.base import BaseReplacer + + +class ChecksumsReplacer(BaseReplacer): + CHECKSUMS_REPLACEMENTS = { + "b2sums": "blake2b-512", + "sha512sums": "sha512", + "sha384sums": "sha384", + "sha256sums": "sha256", + "sha224sums": "sha224", + "sha1sums": "sha1", + "md5sums": "md5", + } + + def process(self): + root_node = self.tree.root_node + + sums = self.CHECKSUMS_REPLACEMENTS.keys() + arches = ArchReplacer.ARCH_MAPPING.keys() + + checksums = dict() + combinations = {(s, a) for s in sums for a in arches}.union( + {(s, None) for s in sums} + ) + + def find_replacements(node): + if node.type == "variable_assignment": + var_node = node.child_by_field_name("name") + value_node = node.child_by_field_name("value") + + if var_node and value_node: + var_name = self._node_text(var_node) + for sum_part, arch_part in combinations: + if ( + sum_part == var_name + if arch_part is None + else f"{sum_part}_{arch_part}" == var_name + ): + checksums[(sum_part, arch_part)] = [] + for item in value_node.children: + if item.type == "raw_string": + element_text = self._node_text(item) + if ( + element_text.startswith("'") + and element_text.endswith("'") + ) or ( + element_text.startswith('"') + and element_text.endswith('"') + ): + # quote_char = element_text[0] + hash = element_text[1:-1] + else: + hash = element_text + + chcksm = self.CHECKSUMS_REPLACEMENTS[ + sum_part + ] + + checksums[(sum_part, arch_part)].append( + f"{chcksm}:{hash}" + ) + self.replaces.append({"node": node, "content": ""}) + + for child in node.children: + find_replacements(child) + + find_replacements(root_node) + + result = dict() + + content = "" + + for (sum_part, arch_part), hashes in checksums.items(): + key = ( + f"checksums_{ArchReplacer.ARCH_MAPPING[arch_part]}" + if arch_part + else "checksums" + ) + result.setdefault(key, []).extend(hashes) + + for key, value in result.items(): + content += f"""{key}=( + '{"',\n '".join(value)}' +)""" + + self.appends.append({"node": root_node, "content": content}) + + print(result) + + return self._apply_replacements() diff --git a/aides_spec/replacers/local_sources_replacer.py b/aides_spec/replacers/local_sources_replacer.py new file mode 100644 index 0000000..5ff240b --- /dev/null +++ b/aides_spec/replacers/local_sources_replacer.py @@ -0,0 +1,94 @@ +from typing import TYPE_CHECKING + +from aides_spec.replacers.base import BaseReplacer + +if TYPE_CHECKING: + from tree_sitter import Node + + +class LocalSourcesReplacer(BaseReplacer): + def process(self): + root_node = self.tree.root_node + + self.local_files = [] + self.prepare_func_body = None + + def find_replacements(node: Node): + if node.type == "function_definition": + func_name = self._node_text(node.child_by_field_name("name")) + + if func_name == "prepare": + self.prepare_func_body = node.child_by_field_name("body") + + if node.type == "variable_assignment": + var_node = node.child_by_field_name("name") + value_node = node.child_by_field_name("value") + + if var_node and value_node: + var_name = self._node_text(var_node) + if var_name == "sources": + self._remove_local_files(value_node) + + for child in node.children: + find_replacements(child) + + find_replacements(root_node) + + copy_commands = "\n ".join( + f'cp "${{scriptdir}}/{file}" "${{srcdir}}"' + for file in self.local_files + ) + + prepare_func_content = f""" + {copy_commands} +""" + + print(self.local_files) + + if self.prepare_func_body is not None: + text = self._node_text(self.prepare_func_body) + closing_brace_index = text.rfind("}") + text = ( + text[:closing_brace_index] + + prepare_func_content + + text[closing_brace_index:] + ) + self.replaces.append( + { + "node": self.prepare_func_body, + "content": text, + } + ) + else: + text = self._node_text(root_node) + text = f"""prepare() {{ +{prepare_func_content}}} +""" + self.appends.append({"node": root_node, "content": text}) + + return self._apply_replacements() + + def _remove_local_files(self, source_node: Node): + updated_items = [] + for item_node in source_node.children: + item_text = self._node_text(item_node) + + if item_text == "(" or item_text == ")": + continue + + if "://" in item_text: + updated_items.append(item_text) + else: + text = item_text + if item_node.type == "string": + text = self._node_text(item_node.child(1)) + + self.local_files.append(text) + + new_content = "(\n " + " \n".join(updated_items) + "\n)" + self.replaces.append( + { + "node": source_node, + "content": new_content, + } + ) diff --git a/aides_spec/replacers/simple_replacer.py b/aides_spec/replacers/simple_replacer.py index 208720c..9b27d04 100644 --- a/aides_spec/replacers/simple_replacer.py +++ b/aides_spec/replacers/simple_replacer.py @@ -6,9 +6,10 @@ class SimpleReplacer(BaseReplacer): "pkgname": "name", "pkgver": "version", "pkgrel": "release", - "pkgdesc": "description", + "pkgdesc": "desc", "url": "homepage", "arch": "architectures", + "depends": "deps", "optdepends": "opt_deps", } diff --git a/aides_spec/replacers/sources.py b/aides_spec/replacers/sources.py new file mode 100644 index 0000000..20b9cee --- /dev/null +++ b/aides_spec/replacers/sources.py @@ -0,0 +1,202 @@ +import re +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from tree_sitter import Node + +from aides_spec.replacers.arch_replacer import ArchReplacer +from aides_spec.replacers.base import BaseReplacer + + +class StringValue(str): + def __init__(self, node: Node): + self.node = node + + def get_text_value(self) -> str: + value = self.node.text.decode("utf-8") + return value[1:-1] + + def get_quote(self) -> str: + value = self.node.text.decode("utf-8") + return value[0] + + def __repr__(self): + return self.node.text.decode("utf-8") + + def __str__(self): + return self.__repr__() + + +class Utils: + def parse_variable_assignment(node: Node): + var_node = node.child_by_field_name("name") + value_node = node.child_by_field_name("value") + + if not (var_node and value_node): + return None + + return (var_node, value_node) + + def get_string_values_from_array(node: Node): + arr = [] + for item in node.children: + if ( + item.type == "string" + or item.type == "raw_string" + or item.type == "concatenation" + ): + arr.append(StringValue(item)) + + return arr + + +CHECKSUMS_REPLACEMENTS = { + "b2sums": "blake2b-512", + "sha512sums": "sha512", + "sha384sums": "sha384", + "sha256sums": "sha256", + "sha224sums": "sha224", + "sha1sums": "sha1", + "md5sums": "md5", +} + +SOURCE_PATTERN = r"^source(?:_(x86_64|i686|armv7h|aarch64))?$" +CHECKSUMS_PATTERN = ( + r"^(b2sums|sha512sums|sha384sums|sha256sums|sha224sums|sha1sums|md5sums)" + r"(?:_(x86_64|i686|armv7h|aarch64))?$" +) + + +class SourcesReplacer(BaseReplacer): + def process(self): + root_node = self.tree.root_node + + self.local_files = [] + self.prepare_func_body = None + + self.nodes_to_remove = [] + + sources = dict() + checksums = dict() + + def execute(node: Node): + if node.type == "function_definition": + func_name = self._node_text(node.child_by_field_name("name")) + if func_name != "prepare": + return + self.prepare_func_body = node + return + + if node.type == "variable_assignment": + var_node = node.child_by_field_name("name") + value_node = node.child_by_field_name("value") + if not (var_node and value_node): + return + + var_name = self._node_text(var_node) + + re_match = re.match(SOURCE_PATTERN, var_name) + if re_match: + self.nodes_to_remove.append(node) + + arch = re_match.group(1) + sources[arch if arch else "-"] = ( + Utils.get_string_values_from_array(value_node) + ) + + re_match = re.match(CHECKSUMS_PATTERN, var_name) + if re_match: + self.nodes_to_remove.append(node) + + checksum = CHECKSUMS_REPLACEMENTS[re_match.group(1)] + arch = re_match.group(2) + + checksums[arch if arch else "-"] = [ + f"'{checksum}:{v.get_text_value()}'" + for v in Utils.get_string_values_from_array(value_node) + ] + + def traverse(node: Node): + execute(node) + for child in node.children: + traverse(child) + + traverse(root_node) + + content = "" + + for node in self.nodes_to_remove: + self.replaces.append({"node": node, "content": ""}) + + for arch, files in sources.items(): + source_files = [] + checksums_str = [] + + for i, file in enumerate(files): + file_name = file.get_text_value() + print(file_name) + if "://" in file_name: + source_files.append(file) + checksums_str.append(checksums[arch][i]) + else: + self.local_files.append(file) + + content += self.source_to_str(arch, source_files) + "\n" + content += self.checksums_to_str(arch, checksums_str) + "\n" + + if len(self.local_files) > 0: + copy_commands = "\n ".join( + f'cp "${{scriptdir}}/{file.get_text_value()}" "${{srcdir}}"' + for file in self.local_files + ) + + prepare_func_content = f""" + {copy_commands} +""" + if self.prepare_func_body is not None: + text = self._node_text(self.prepare_func_body) + closing_brace_index = text.rfind("}") + text = ( + text[:closing_brace_index] + + prepare_func_content + + text[closing_brace_index:] + ) + self.replaces.append( + { + "node": self.prepare_func_body, + "content": text, + } + ) + else: + text = self._node_text(root_node) + content += f""" +prepare() {{ +{prepare_func_content}}} +""" + + self.appends.append( + { + "node": root_node, + "content": content, + } + ) + + return self._apply_replacements() + + def source_to_str(self, arch, files): + return f""" +{f"sources_{ArchReplacer.ARCH_MAPPING[arch]}" if arch != '-' else "sources"}=( + {'\n '.join([s.__str__() for s in files])} +)""" + + def checksums_to_str(self, arch, files): + var_name = ( + f"checksums_{ArchReplacer.ARCH_MAPPING[arch]}" + if arch != "-" + else "checksums" + ) + + return f""" +{var_name}=( + {'\n '.join([s.__str__() for s in files])} +)""" diff --git a/aides_spec/utils/empty_template.py b/aides_spec/utils/empty_template.py new file mode 100644 index 0000000..2e61d6f --- /dev/null +++ b/aides_spec/utils/empty_template.py @@ -0,0 +1,54 @@ +import sys + +file = """name=example +version=1.0.0 +release=1 +epoch=0 +desc=Description +homepage=https://exemple.com +maintainer= +# or 'all' for noarch +architectures=('amd64') +licenses=('custom:proprietary') +provides=() +conflicts=() +deps=() +build_deps=() +opt_deps=() +auto_deps=0 +auto_req=0 +replaces=() +sources=() +checksums=() +backup=() +scripts=( + ['preinstall']='preinstall.sh' + ['postinstall']='postinstall.sh' + ['preremove']='preremove.sh' + ['postremove']='postremove.sh' + ['preupgrade']='preupgrade.sh' + ['postupgrade']='postupgrade.sh' + ['pretrans']='pretrans.sh' + ['posttrans']='posttrans.sh' +) + +prepare() { + echo "PREPARE" +} +build() { + echo "BUILD" +} +package() { + echo "PACKAGE" +} +""" + + +def create_from_empty_template(output_file): + try: + with open(output_file, "w") as f: + f.write(file) + print(f"Файл успешно записан в {output_file}.") + except IOError as e: + print(f"Ошибка при записи файла: {e}") + sys.exit(1) diff --git a/aides_spec/utils/from_pkgbuild.py b/aides_spec/utils/from_pkgbuild.py index a0450f6..e7d49aa 100644 --- a/aides_spec/utils/from_pkgbuild.py +++ b/aides_spec/utils/from_pkgbuild.py @@ -1,27 +1,63 @@ +import os +import shutil import sys +import tempfile -import requests +import git import tree_sitter_bash as tsbash from tree_sitter import Language, Parser from aides_spec.replacers.arch_replacer import ArchReplacer + +# from aides_spec.replacers.checksums_replacer import ChecksumsReplacer +# from aides_spec.replacers.local_sources_replacer import LocalSourcesReplacer from aides_spec.replacers.simple_replacer import SimpleReplacer -from aides_spec.replacers.sources_replacer import SourcesReplacer +from aides_spec.replacers.sources import SourcesReplacer parser_ts = None +def download_pkgbuild_and_all_files(pkgname): + aur_url = f"https://aur.archlinux.org/{pkgname}.git" + + with tempfile.TemporaryDirectory() as tmpdirname: + try: + print(f"Клонируем репозиторий для {pkgname}") + git.Repo.clone_from(aur_url, tmpdirname) + + print(f"Файлы для {pkgname} загружены в {tmpdirname}") + + for root, dirs, files in os.walk(tmpdirname): + dirs[:] = [d for d in dirs if d not in [".git"]] + files = [ + file + for file in files + if file not in ["PKGBUILD", ".SRCINFO"] + ] + + for file in files: + file_path = os.path.join(root, file) + + relative_path = os.path.relpath(file_path, tmpdirname) + destination_path = os.path.join(os.getcwd(), relative_path) + + os.makedirs( + os.path.dirname(destination_path), exist_ok=True + ) + + shutil.copy(file_path, destination_path) + + with open(os.path.join(tmpdirname, "PKGBUILD"), "rb") as f: + return f.read() + + return + except Exception as e: + print(f"Ошибка при скачивании репозитория: {e}") + sys.exit(1) + + def download_pkgbuild(pkgname): - aur_url = ( - f"https://aur.archlinux.org/cgit/aur.git/plain/PKGBUILD?h={pkgname}" - ) - try: - response = requests.get(aur_url, timeout=300) - response.raise_for_status() - return response.content - except requests.RequestException as e: - print(f"Ошибка загрузки PKGBUILD из AUR: {e}") - sys.exit(1) + return download_pkgbuild_and_all_files(pkgname) def process_file(content, tree, replacers): @@ -32,6 +68,13 @@ def process_file(content, tree, replacers): return content +HEADER = """# +# WARNING: Automatic converted from PKGBUILD and may contains errors +# + +""" + + def create_from_pkgbuild(content, output_file): global parser_ts BASH_LANGUAGE = Language(tsbash.language()) @@ -43,10 +86,14 @@ def create_from_pkgbuild(content, output_file): SimpleReplacer, ArchReplacer, SourcesReplacer, + # LocalSourcesReplacer, + # ChecksumsReplacer, ] new_content = process_file(content, tree, replacers) + new_content = bytes(HEADER, encoding="utf-8") + new_content + try: with open(output_file, "wb") as f: f.write(new_content) diff --git a/poetry.lock b/poetry.lock index 82a97be..34fd133 100644 --- a/poetry.lock +++ b/poetry.lock @@ -272,6 +272,38 @@ mccabe = ">=0.7.0,<0.8.0" pycodestyle = ">=2.12.0,<2.13.0" pyflakes = ">=3.2.0,<3.3.0" +[[package]] +name = "gitdb" +version = "4.0.12" +description = "Git Object Database" +optional = false +python-versions = ">=3.7" +files = [ + {file = "gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf"}, + {file = "gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571"}, +] + +[package.dependencies] +smmap = ">=3.0.1,<6" + +[[package]] +name = "gitpython" +version = "3.1.44" +description = "GitPython is a Python library used to interact with Git repositories" +optional = false +python-versions = ">=3.7" +files = [ + {file = "GitPython-3.1.44-py3-none-any.whl", hash = "sha256:9e0e10cda9bed1ee64bc9a6de50e7e38a9c9943241cd7f585f6df3ed28011110"}, + {file = "gitpython-3.1.44.tar.gz", hash = "sha256:c87e30b26253bf5418b01b0660f818967f3c503193838337fe5e573331249269"}, +] + +[package.dependencies] +gitdb = ">=4.0.1,<5" + +[package.extras] +doc = ["sphinx (>=7.1.2,<7.2)", "sphinx-autodoc-typehints", "sphinx_rtd_theme"] +test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions"] + [[package]] name = "identify" version = "2.6.4" @@ -597,6 +629,17 @@ files = [ {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, ] +[[package]] +name = "smmap" +version = "5.0.2" +description = "A pure Python implementation of a sliding window memory map manager" +optional = false +python-versions = ">=3.7" +files = [ + {file = "smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e"}, + {file = "smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5"}, +] + [[package]] name = "stevedore" version = "5.4.0" @@ -753,4 +796,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "fdfdf68234bf3f97b5eb66fb3d38a5069c489004693376e4a379bd821b460068" +content-hash = "3a3d39a47a858b743b5b52bffc42b105a750c5b94d67fdf468a8204ba0903aa3" diff --git a/pyproject.toml b/pyproject.toml index 9738702..ddef756 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ tree-sitter = "^0.23.2" tree-sitter-bash = "^0.23.3" requests = "^2.32.3" typer = "^0.15.1" +gitpython = "^3.1.44" [tool.poetry.group.dev.dependencies] black = "^24.10.0"