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
- Python 3.10+ and git 2.20 or newer on your
PATH. - A Typer or Click CLI with a package layout (
src/mytool/...), as in how to structure a large Python CLI project. - pytest, for the tests.
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.
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).
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.
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 stashorgit checkoutshould say so, and should support a--dry-runas 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.