Runtime

Wrapping git and Other Tools from a Python CLI

Build a small typed Python module over git: one runner, porcelain and -z output, parsed results, clear errors and tests that never need a real repository.

Updated

Release helpers, changelog generators, monorepo "what changed?" scripts and pre-deploy checks all end up asking git questions: which branch am I on, is the tree clean, which files changed since the last tag. The first version scatters subprocess.run(["git", ...]) through the commands and parses whatever text comes back. Six months later the parsing breaks on a filename with a space, a user with a German locale gets different output, and every test needs a real repository. This guide shows the alternative — a small, typed wrapper module that is the only code in your CLI allowed to call git. The same pattern works for docker, kubectl, terraform or any tool you call often. It builds on the ideas in the subprocess topic.

Prerequisites

The shape: typed functions over one runner

The wrapper has two layers. At the bottom, a single private function runs git with every subprocess decision made once: working directory, environment, encoding, error handling. Above it, small public functions each answer one question and return Python types — str, bool, list[Path] — never raw output. Commands only ever see the public functions.

A thin typed layer over git Layers of a git wrapper: CLI commands call typed Python functions, which call a single runner that invokes git with porcelain output and parses the result. A thin typed layer over git CLI commands Typer mytool release, mytool changed Typed functions your API current_branch() -> str, changed_files() -> list[Path] One runner subprocess git(*args) — cwd, env, check, encoding in one place git itself external porcelain / -z output only Every git call goes through one function, so tests can replace one thing.

This buys three things. Consistency: every call gets the same environment and error handling, so a fix applies everywhere. Readability: if not git.is_clean(): reads like the requirement it implements. Testability: tests replace one function, _git, instead of patching subprocess in a dozen places.

The recipe

# src/mytool/git.py
"""The only module in mytool that runs git."""
from __future__ import annotations

import os
import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path


class GitError(Exception):
    def __init__(self, args: tuple[str, ...], returncode: int, stderr: str) -> None:
        self.git_args = args
        self.returncode = returncode
        self.stderr = stderr.strip()
        super().__init__(f"git {' '.join(args)} failed ({returncode}): {self.stderr}")


_ENV_OVERRIDES = {
    "LC_ALL": "C",                 # stable, untranslated messages
    "GIT_TERMINAL_PROMPT": "0",    # never block on a credential prompt
    "GIT_OPTIONAL_LOCKS": "0",     # read-only commands take no index lock
}


def _git(*args: str, cwd: Path | None = None, ok_codes: tuple[int, ...] = (0,)) -> str:
    exe = shutil.which("git")
    if exe is None:
        raise GitError(args, 127, "git is not installed or not on PATH")
    proc = subprocess.run(
        [exe, *args],
        cwd=cwd,
        env={**os.environ, **_ENV_OVERRIDES},
        stdin=subprocess.DEVNULL,
        capture_output=True,
        text=True,
        encoding="utf-8",
        errors="surrogateescape",
        timeout=60,
    )
    if proc.returncode not in ok_codes:
        raise GitError(args, proc.returncode, proc.stderr)
    return proc.stdout


def repo_root(cwd: Path | None = None) -> Path:
    return Path(_git("rev-parse", "--show-toplevel", cwd=cwd).strip())


def current_branch(cwd: Path | None = None) -> str | None:
    """Branch name, or None on a detached HEAD."""
    name = _git("rev-parse", "--abbrev-ref", "HEAD", cwd=cwd).strip()
    return None if name == "HEAD" else name


def is_clean(cwd: Path | None = None) -> bool:
    return _git("status", "--porcelain=v1", "-z", cwd=cwd) == ""


@dataclass(frozen=True)
class Change:
    status: str
    path: Path


def changed_files(since: str, cwd: Path | None = None) -> list[Change]:
    """Files changed between `since` and HEAD, NUL-separated so any name is safe."""
    out = _git("diff", "--name-status", "-z", "--no-renames", f"{since}...HEAD", "--", cwd=cwd)
    fields = out.split("\0")
    return [
        Change(status=fields[i], path=Path(fields[i + 1]))
        for i in range(0, len(fields) - 1, 2)
    ]


def latest_tag(cwd: Path | None = None) -> str | None:
    try:
        return _git("describe", "--tags", "--abbrev=0", cwd=cwd).strip()
    except GitError as exc:
        if "No names found" in exc.stderr or "No tags can describe" in exc.stderr:
            return None
        raise

Why these details

LC_ALL=C. Git translates many messages. If your code ever matches on stderr — as latest_tag does — it must see English. The C locale guarantees that, and also stabilises date and sort formats.

GIT_TERMINAL_PROMPT=0. A git fetch against a private remote with no cached credentials would otherwise prompt for a username and block forever. With this set it fails immediately with an error you can report.

GIT_OPTIONAL_LOCKS=0. git status normally refreshes the index and takes a lock to do so. If your tool runs while the user's editor or IDE is also running git, optional locks cause spurious "index.lock exists" failures. Read-only wrappers should not take them.

-z and porcelain formats. Git's human output quotes unusual filenames with C-style escapes and changes between versions. The porcelain formats are documented as stable for scripts, and -z separates records with NUL bytes, which cannot appear in paths. Splitting on "\0" is then correct for every possible filename, including ones with spaces, newlines or non-UTF-8 bytes (which surrogateescape preserves rather than mangling).

Ask git for machine output Terminal output comparing human git status output with the stable porcelain format intended for scripts. Ask git for machine output bash $ git status --porcelain=v1 -z | tr "\0" "\n" M src/mytool/cli.py ?? notes/todo.md R old_name.py # stable across git versions and locales; human output is neither Parse the formats git promises to keep stable, and use -z so odd filenames cannot break parsing.

ok_codes. Some git commands use non-zero codes as answers. git diff --quiet exits 1 when there are differences. Rather than catching an exception for an expected outcome, allow that code explicitly: _git("diff", "--quiet", ok_codes=(0, 1)).

Using it from commands

# src/mytool/cli.py
import typer

from mytool import git

app = typer.Typer()


@app.callback()
def main() -> None:
    """Release helpers."""


@app.command()
def preflight() -> None:
    """Check the repository is ready to release."""
    try:
        branch = git.current_branch()
        clean = git.is_clean()
        tag = git.latest_tag()
    except git.GitError as exc:
        typer.secho(f"error: {exc.stderr or exc}", fg="red", err=True)
        raise typer.Exit(1)

    problems = []
    if branch != "main":
        problems.append(f"on branch {branch or '(detached HEAD)'}, expected main")
    if not clean:
        problems.append("working tree has uncommitted changes")
    for p in problems:
        typer.secho(f"✗ {p}", fg="red", err=True)
    if problems:
        raise typer.Exit(1)
    typer.echo(f"✓ ready to release (previous tag: {tag or 'none'})")


if __name__ == "__main__":
    app()

The command reads like a checklist, and it reports all problems at once instead of stopping at the first — a small courtesy that saves a round trip for the user.

Shell out, or use a library?

Libraries such as dulwich (pure Python) and pygit2 (libgit2 bindings) avoid the subprocess entirely. They are the right choice when git may not be installed — a slim container image, or end users on locked-down machines. For developer tooling, calling the real git is usually better: it respects the user's configuration, credential helpers, hooks, safe.directory settings and any new repository format features, and its behaviour matches what the user sees when they run git themselves.

Shell out to git or use a library? A decision between invoking the git executable and using a Python library such as pygit2 or dulwich, based on whether git is guaranteed to be installed. Shell out to git or use a library? Will git always be installed where this runs? Yes — developer machines and CI Call git matches what users see No — a slim container or end users dulwich / pygit2 a dependency instead Calling the real binary means your tool respects the user's own config, hooks and credentials.

UX considerations

  • Report git's own message. When a call fails, git's stderr is usually precise ("fatal: not a git repository"). Show it rather than a generic "git failed".
  • Check you are in a repository first. Calling repo_root() at the start of commands that need one produces one clear error instead of a confusing failure halfway through.
  • Operate relative to the repository root. Users run tools from subdirectories. Pass cwd=repo_root() to calls whose paths should be root-relative, and print paths relative to where the user is.
  • Never modify state silently. A wrapper that runs git stash or git checkout should say so, and should support a --dry-run as described in adding dry-run and confirmation to destructive commands.

Testing the behaviour

Test the parsing against a real throwaway repository — it is quick to create one in tmp_path — and test commands by replacing the wrapper functions.

# tests/test_git.py
import subprocess
from pathlib import Path

import pytest

from mytool import git


@pytest.fixture
def repo(tmp_path: Path) -> Path:
    def run(*args: str) -> None:
        subprocess.run(["git", *args], cwd=tmp_path, check=True, capture_output=True)

    run("init", "-q", "-b", "main")
    run("config", "user.email", "t@example.com")
    run("config", "user.name", "Test")
    (tmp_path / "a.txt").write_text("a")
    run("add", ".")
    run("commit", "-q", "-m", "first")
    run("tag", "v1.0.0")
    return tmp_path


def test_branch_and_clean(repo):
    assert git.current_branch(repo) == "main"
    assert git.is_clean(repo)
    (repo / "b.txt").write_text("b")
    assert not git.is_clean(repo)


def test_changed_files_handles_awkward_names(repo):
    weird = repo / "odd name\twith tab.txt"
    weird.write_text("x")
    subprocess.run(["git", "add", "."], cwd=repo, check=True)
    subprocess.run(["git", "commit", "-q", "-m", "second"], cwd=repo, check=True)
    changes = git.changed_files("v1.0.0", cwd=repo)
    assert changes == [git.Change("A", Path("odd name\twith tab.txt"))]


def test_latest_tag(repo):
    assert git.latest_tag(repo) == "v1.0.0"


def test_not_a_repo(tmp_path):
    with pytest.raises(git.GitError) as info:
        git.repo_root(tmp_path)
    assert "not a git repository" in info.value.stderr

The tab-in-filename test is the one that justifies -z: without it, git would print the path as "odd name\twith tab.txt" with literal quotes and a backslash escape, and the parser would return the wrong path. Mark these tests to skip when git is unavailable if your CI images vary. For command-level tests, monkeypatch.setattr(git, "current_branch", lambda: "feature") and invoke the CLI with a runner, as in testing Click commands with CliRunner.

Conclusion

Treat every external tool your CLI depends on like a remote API: one client module, one place where calls are made, stable machine-readable formats, typed results and a single error type. For git that means porcelain output, -z separators, a fixed locale and no prompts. The payoff is a command layer that reads like the requirements and a test suite that can exercise it without touching subprocess at all.

Frequently asked questions

Should the wrapper cache results like repo_root()?

Within one command invocation, caching is safe and cheap with functools.cache on functions that take no arguments — but be careful with functions whose answer changes when your tool itself modifies the repository. Cache the root, not the status.

How do I handle a repository in a git worktree or submodule?

rev-parse --show-toplevel returns the worktree or submodule root, which is what most tools want. If you need the main repository's .git directory, ask for --git-common-dir. Both are handled by git, which is one more reason to shell out rather than reading .git yourself.

Why does git complain about "dubious ownership" in CI?

Git refuses to operate on repositories owned by another user unless they are listed in safe.directory. Containers that mount a checkout often trigger it. Report git's message verbatim — it includes the exact config command to run — rather than trying to work around it in your wrapper.

Can I stream the output of long git operations?

Yes: use the streaming runner from streaming subprocess output in real time for clone or fetch, and pass --progress so git reports progress even when stderr is a pipe.