From ebe7ec6f4b70ca8fd34cd1c00b2237cd2e6cfa1b Mon Sep 17 00:00:00 2001 From: Julius Woodall Date: Sun, 20 Sep 2026 19:38:29 -0400 Subject: [PATCH] Add python_git: a leak-safe GitPython alternative Every git invocation runs through a single blocking subprocess.run call in python_git/runner.py that always waits for the process to exit and closes its pipes before returning, so there is no long-lived Popen handle for a caller to forget to close (the root cause of GitPython's known fd/process leaks). Repo exposes status/log/diff/add/commit/push/pull/fetch, branch and remote management, and clone, backed by dataclasses in models.py. Covered by tests/test_repo.py, including an fd-count regression test. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 5 + .idea/.gitignore | 5 + .../inspectionProfiles/profiles_settings.xml | 6 + .idea/misc.xml | 4 + .idea/modules.xml | 8 + .idea/python-git.iml | 14 ++ .idea/vcs.xml | 6 + pyproject.toml | 15 ++ python_git/__init__.py | 14 ++ python_git/exceptions.py | 18 +++ python_git/models.py | 32 ++++ python_git/repo.py | 149 ++++++++++++++++++ python_git/runner.py | 53 +++++++ tests/test_repo.py | 119 ++++++++++++++ uv.lock | 79 ++++++++++ 15 files changed, 527 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/python-git.iml create mode 100644 .idea/vcs.xml create mode 100644 pyproject.toml create mode 100644 python_git/__init__.py create mode 100644 python_git/exceptions.py create mode 100644 python_git/models.py create mode 100644 python_git/repo.py create mode 100644 python_git/runner.py create mode 100644 tests/test_repo.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4b7d4f5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +.pytest_cache/ +.venv/ +*.egg-info/ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..b58b603 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..0320c93 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..bc08629 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/python-git.iml b/.idea/python-git.iml new file mode 100644 index 0000000..e5f042a --- /dev/null +++ b/.idea/python-git.iml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b9af939 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "python-git" +version = "0.1.0" +requires-python = ">=3.13" +dependencies = [] + +[dependency-groups] +dev = ["pytest>=8.0"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["python_git"] diff --git a/python_git/__init__.py b/python_git/__init__.py new file mode 100644 index 0000000..57dede7 --- /dev/null +++ b/python_git/__init__.py @@ -0,0 +1,14 @@ +from .exceptions import GitCommandError, GitError, InvalidGitRepositoryError +from .models import Branch, Commit, FileStatus, Remote +from .repo import Repo + +__all__ = [ + "Repo", + "GitError", + "GitCommandError", + "InvalidGitRepositoryError", + "FileStatus", + "Commit", + "Branch", + "Remote", +] diff --git a/python_git/exceptions.py b/python_git/exceptions.py new file mode 100644 index 0000000..a291c7f --- /dev/null +++ b/python_git/exceptions.py @@ -0,0 +1,18 @@ +class GitError(Exception): + """Base exception for all errors raised by python_git.""" + + +class GitCommandError(GitError): + """A git subprocess exited with a non-zero status.""" + + def __init__(self, cmd: list[str], returncode: int, stderr: str): + self.cmd = cmd + self.returncode = returncode + self.stderr = stderr + super().__init__( + f"'{' '.join(cmd)}' failed (exit {returncode}): {stderr.strip()}" + ) + + +class InvalidGitRepositoryError(GitError): + """The given path is not inside a git working tree.""" diff --git a/python_git/models.py b/python_git/models.py new file mode 100644 index 0000000..5990049 --- /dev/null +++ b/python_git/models.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class FileStatus: + path: str + index_status: str + worktree_status: str + orig_path: str | None = None + + +@dataclass(frozen=True) +class Commit: + hash: str + author_name: str + author_email: str + date: str + message: str + + +@dataclass(frozen=True) +class Branch: + name: str + is_current: bool + + +@dataclass(frozen=True) +class Remote: + name: str + url: str diff --git a/python_git/repo.py b/python_git/repo.py new file mode 100644 index 0000000..7cf95c8 --- /dev/null +++ b/python_git/repo.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path + +from .exceptions import GitCommandError, InvalidGitRepositoryError +from .models import Branch, Commit, FileStatus, Remote +from .runner import DEFAULT_TIMEOUT, run_git + +_LOG_FIELD_SEP = "\x1f" +_LOG_RECORD_SEP = "\x1e" + + +class Repo: + def __init__(self, path: str | Path, timeout: float = DEFAULT_TIMEOUT): + self.path = Path(path) + self.timeout = timeout + + if not self.path.exists(): + raise InvalidGitRepositoryError(f"{self.path} does not exist") + + result = run_git( + "rev-parse", "--is-inside-work-tree", + cwd=str(self.path), timeout=timeout, check=False, + ) + if result.returncode != 0 or result.stdout.strip() != "true": + raise InvalidGitRepositoryError(f"{self.path} is not a git repository") + + def _run(self, *args: str, check: bool = True) -> subprocess.CompletedProcess: + return run_git(*args, cwd=str(self.path), timeout=self.timeout, check=check) + + @classmethod + def clone(cls, url: str, to_path: str | Path, timeout: float = DEFAULT_TIMEOUT) -> "Repo": + run_git("clone", url, str(to_path), timeout=timeout) + return cls(to_path, timeout=timeout) + + def status(self) -> list[FileStatus]: + tokens = self._run("status", "--porcelain=v2", "-z").stdout.split("\0") + entries: list[FileStatus] = [] + i = 0 + while i < len(tokens): + token = tokens[i] + if not token: + i += 1 + continue + kind = token[0] + if kind == "1": + parts = token.split(" ", 8) + entries.append(FileStatus(path=parts[8], index_status=parts[1][0], worktree_status=parts[1][1])) + i += 1 + elif kind == "2": + parts = token.split(" ", 9) + entries.append(FileStatus( + path=parts[9], index_status=parts[1][0], worktree_status=parts[1][1], + orig_path=tokens[i + 1], + )) + i += 2 + elif kind == "u": + parts = token.split(" ", 10) + entries.append(FileStatus(path=parts[10], index_status=parts[1][0], worktree_status=parts[1][1])) + i += 1 + elif kind in ("?", "!"): + entries.append(FileStatus(path=token[2:], index_status=kind, worktree_status=kind)) + i += 1 + else: + i += 1 + return entries + + def is_dirty(self) -> bool: + return bool(self.status()) + + def log(self, max_count: int | None = None, ref: str = "HEAD") -> list[Commit]: + args = ["log", "--date=iso", f"--pretty=format:%H{_LOG_FIELD_SEP}%an{_LOG_FIELD_SEP}%ae{_LOG_FIELD_SEP}%ad{_LOG_FIELD_SEP}%s{_LOG_RECORD_SEP}"] + if max_count is not None: + args.append(f"--max-count={max_count}") + args.append(ref) + + result = self._run(*args, check=False) + if result.returncode != 0: + if "does not have any commits yet" in result.stderr or "unknown revision" in result.stderr: + return [] + raise GitCommandError(args, result.returncode, result.stderr) + + commits = [] + for record in result.stdout.split(_LOG_RECORD_SEP): + record = record.strip("\n") + if not record: + continue + h, an, ae, ad, s = record.split(_LOG_FIELD_SEP) + commits.append(Commit(hash=h, author_name=an, author_email=ae, date=ad, message=s)) + return commits + + def diff(self, *paths: str, staged: bool = False) -> str: + args = ["diff", "--staged"] if staged else ["diff"] + if paths: + args.extend(["--", *paths]) + return self._run(*args).stdout + + def add(self, *paths: str) -> None: + if not paths: + raise ValueError("add() requires at least one path") + self._run("add", "--", *paths) + + def commit(self, message: str, all: bool = False) -> str: + args = ["commit", "-a", "-m", message] if all else ["commit", "-m", message] + return self._run(*args).stdout + + def push(self, remote: str = "origin", branch: str | None = None) -> str: + args = ["push", remote, branch] if branch else ["push", remote] + return self._run(*args).stdout + + def pull(self, remote: str = "origin", branch: str | None = None) -> str: + args = ["pull", remote, branch] if branch else ["pull", remote] + return self._run(*args).stdout + + def fetch(self, remote: str = "origin") -> str: + return self._run("fetch", remote).stdout + + def current_branch(self) -> str: + return self._run("rev-parse", "--abbrev-ref", "HEAD").stdout.strip() + + def list_branches(self) -> list[Branch]: + result = self._run("branch", f"--format=%(HEAD){_LOG_FIELD_SEP}%(refname:short)") + branches = [] + for line in result.stdout.splitlines(): + if not line: + continue + head, name = line.split(_LOG_FIELD_SEP) + branches.append(Branch(name=name, is_current=(head == "*"))) + return branches + + def create_branch(self, name: str) -> None: + self._run("branch", name) + + def checkout(self, name: str) -> None: + self._run("checkout", name) + + def list_remotes(self) -> list[Remote]: + seen: dict[str, str] = {} + for line in self._run("remote", "-v").stdout.splitlines(): + if not line: + continue + name, rest = line.split("\t", 1) + url = rest.rsplit(" ", 1)[0] + seen[name] = url + return [Remote(name=name, url=url) for name, url in seen.items()] + + def add_remote(self, name: str, url: str) -> None: + self._run("remote", "add", name, url) diff --git a/python_git/runner.py b/python_git/runner.py new file mode 100644 index 0000000..247bfa1 --- /dev/null +++ b/python_git/runner.py @@ -0,0 +1,53 @@ +"""The single chokepoint for running the ``git`` binary. + +Every call goes through :func:`run_git`, which always uses the blocking +``subprocess.run`` (never a bare ``Popen`` kept alive across calls). The +process is started, waited on to completion, and its pipes are closed before +the function returns — there is no long-lived subprocess handle for a caller +to forget to close, which is the root cause of GitPython's process/fd leaks. +""" + +from __future__ import annotations + +import shutil +import subprocess +from functools import lru_cache + +from .exceptions import GitCommandError, GitError + +DEFAULT_TIMEOUT = 30 + + +@lru_cache(maxsize=1) +def git_executable() -> str: + path = shutil.which("git") + if path is None: + raise GitError("git executable not found. Please install git.") + return path + + +def run_git( + *args: str, + cwd: str | None = None, + timeout: float = DEFAULT_TIMEOUT, + check: bool = True, +) -> subprocess.CompletedProcess: + cmd = [git_executable(), *args] + try: + result = subprocess.run( + cmd, + cwd=cwd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + shell=False, + ) + except subprocess.TimeoutExpired as exc: + raise GitCommandError(cmd, -1, f"timed out after {timeout}s: {exc}") from exc + + if check and result.returncode != 0: + raise GitCommandError(cmd, result.returncode, result.stderr) + + return result diff --git a/tests/test_repo.py b/tests/test_repo.py new file mode 100644 index 0000000..7b7cfa0 --- /dev/null +++ b/tests/test_repo.py @@ -0,0 +1,119 @@ +import os +import subprocess +from pathlib import Path + +import pytest + +from python_git import GitCommandError, InvalidGitRepositoryError, Repo + + +def _init_repo(path: Path) -> Repo: + subprocess.run(["git", "init", "-q", str(path)], check=True) + subprocess.run(["git", "-C", str(path), "config", "user.email", "test@example.com"], check=True) + subprocess.run(["git", "-C", str(path), "config", "user.name", "Test User"], check=True) + return Repo(path) + + +def test_invalid_repository_raises(tmp_path): + with pytest.raises(InvalidGitRepositoryError): + Repo(tmp_path) + + +def test_missing_path_raises(tmp_path): + with pytest.raises(InvalidGitRepositoryError): + Repo(tmp_path / "does-not-exist") + + +def test_status_reports_untracked_and_spaced_filenames(tmp_path): + repo = _init_repo(tmp_path) + (tmp_path / "a file.txt").write_text("hello\n") + + statuses = repo.status() + + assert len(statuses) == 1 + assert statuses[0].path == "a file.txt" + assert statuses[0].index_status == "?" + + +def test_add_commit_log_roundtrip(tmp_path): + repo = _init_repo(tmp_path) + (tmp_path / "file.txt").write_text("hello\n") + repo.add("file.txt") + repo.commit("initial commit") + + commits = repo.log() + + assert len(commits) == 1 + assert commits[0].message == "initial commit" + assert commits[0].author_email == "test@example.com" + assert not repo.is_dirty() + + +def test_log_on_empty_repo_returns_empty_list(tmp_path): + repo = _init_repo(tmp_path) + assert repo.log() == [] + + +def test_diff_shows_modification(tmp_path): + repo = _init_repo(tmp_path) + (tmp_path / "file.txt").write_text("hello\n") + repo.add("file.txt") + repo.commit("initial") + (tmp_path / "file.txt").write_text("goodbye\n") + + assert "goodbye" in repo.diff() + + +def test_rename_is_tracked(tmp_path): + repo = _init_repo(tmp_path) + content = "hello world this is long enough to be detected as a rename\n" + (tmp_path / "old.txt").write_text(content) + repo.add("old.txt") + repo.commit("add old") + (tmp_path / "old.txt").rename(tmp_path / "new.txt") + repo.add("new.txt", "old.txt") + + renamed = [s for s in repo.status() if s.orig_path] + + assert len(renamed) == 1 + assert renamed[0].path == "new.txt" + assert renamed[0].orig_path == "old.txt" + + +def test_branch_create_and_checkout(tmp_path): + repo = _init_repo(tmp_path) + (tmp_path / "file.txt").write_text("hello\n") + repo.add("file.txt") + repo.commit("initial") + + repo.create_branch("feature") + repo.checkout("feature") + + assert repo.current_branch() == "feature" + branches = {b.name: b.is_current for b in repo.list_branches()} + assert branches["feature"] is True + + +def test_git_command_error_on_nonexistent_branch(tmp_path): + repo = _init_repo(tmp_path) + with pytest.raises(GitCommandError): + repo.checkout("does-not-exist") + + +def test_no_fd_leak_across_many_calls(tmp_path): + repo = _init_repo(tmp_path) + (tmp_path / "file.txt").write_text("hello\n") + repo.add("file.txt") + repo.commit("initial") + + fd_dir = Path("/proc/self/fd") + if not fd_dir.exists(): + pytest.skip("requires /proc/self/fd (Linux)") + + before = len(os.listdir(fd_dir)) + for _ in range(200): + repo.status() + repo.log() + after = len(os.listdir(fd_dir)) + + assert after <= before diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..6a074ba --- /dev/null +++ b/uv.lock @@ -0,0 +1,79 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-git" +version = "0.1.0" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8.0" }]