Skip to content
shellhacks.dev
Python

How to Use Python With Git Using GitPython or subprocess

Use Git from Python with GitPython or subprocess: read status and logs, create commits, handle errors and encodings, and choose between them for scripts.

6 min read
Summarize in AI
Illustration for "How to Use Python With Git Using GitPython or subprocess": a terminal session, a git branch graph, an exit status badge.

A deploy script, a release tool or a repository audit often needs facts from Git: the current branch, the changed files, the latest tag. Python can get them by running the git command or through a library. The examples use Python 3.12, GitPython 3.1 and Git 2.45; a shell version of the same commands is in finding the repository root in scripts.

subprocess with machine-readable output

Python
import subprocess
from pathlib import Path

def git(*args, cwd=".", check=True):
    return subprocess.run(
        ["git", *args], cwd=cwd, check=check,
        capture_output=True, text=True,
    ).stdout

branch = git("branch", "--show-current").strip()
tag = git("describe", "--tags", "--abbrev=0", check=False).strip() or None
files = [l[3:] for l in git("status", "--porcelain=v1").splitlines()]
print(branch, tag, files)
Text
main v1.4.2 ['src/app.py', 'notes.txt']

Arguments are a list, so nothing is run through a shell and no quoting is needed. check=True raises CalledProcessError on a non-zero exit, and text=True decodes output with the locale's encoding. The --porcelain status format is documented as stable across versions, unlike the human format, which changes with configuration and language. Use -z with it to cope with file names containing spaces or newlines, splitting on NUL. See capturing subprocess output for the options.

Log, diff and rev-parse

Python
log = git("log", "-5", "--format=%H%x1f%an%x1f%s%x1e")
commits = [r.split("\x1f") for r in log.split("\x1e") if r.strip()]
sha, author, subject = commits[0]
root = Path(git("rev-parse", "--show-toplevel").strip())
changed = git("diff", "--name-only", "origin/main...HEAD").splitlines()

Custom --format strings with ASCII separator characters (%x1f, %x1e) make parsing reliable even when subjects contain commas or tabs. origin/main...HEAD with three dots compares against the merge base, which is what a CI "changed files" check wants. The plumbing and format specifiers are in Git plumbing commands.

GitPython

Python
from git import Repo

repo = Repo(".")
print(repo.active_branch.name, repo.is_dirty(untracked_files=True))
for c in repo.iter_commits("main", max_count=3):
    print(c.hexsha[:8], c.author.name, c.summary)

repo.index.add(["README.md"])
repo.index.commit("Update README")
repo.git.push("origin", "main")      # any git command, via subprocess

GitPython gives objects for commits, trees and diffs, and repo.git.NAME(...) runs any Git command. Most of the library still runs the git executable underneath, so it inherits Git's behaviour and speed. Its object API can be slow on large repositories, since each lookup may start a process, and it must be kept up to date because it handles untrusted input paths. pygit2 binds libgit2 directly and is faster for bulk reads, but some features, such as hooks and certain config options, behave differently from the command line.

Handle errors and encodings

Python
try:
    git("checkout", "no-such-branch")
except subprocess.CalledProcessError as e:
    print("git failed:", e.returncode, e.stderr.strip())

Print e.stderr, because Git writes the explanation there. File names are bytes, so for repositories with non-UTF-8 names use text=False and decode with os.fsdecode. Pass -c core.quotepath=false to see UTF-8 names unescaped. A failing call is explained in CalledProcessError and exit status.

Reading state without fragile parsing

Git's plumbing commands exist to be parsed, and the output of the friendly commands is not stable between versions. For a script, prefer rev-parse, for-each-ref, ls-files, diff --name-status -z and status --porcelain=v2 -z. The -z flag separates records with NUL bytes, which handles file names that contain spaces, quotes or newlines, and Python can split the result on "\0". Porcelain version 2 adds the branch name and the upstream, ahead and behind counts in header lines, so one call returns what would otherwise need four.

Python
out = git("status", "--porcelain=v2", "--branch", "-z")
info = {}
for rec in out.split("\0"):
    if rec.startswith("# branch.head"):
        info["branch"] = rec.split()[2]
    elif rec.startswith("# branch.ab"):
        ahead, behind = rec.split()[2:4]
        info["ahead"], info["behind"] = int(ahead), -int(behind)
print(info)

The sign on the behind count is negative in Git's output, so the script negates it. Detached HEAD shows as (detached) in the branch field, and a script that deploys from a branch name should check for it explicitly.

Repositories in odd states

Scripts meet repositories in conditions a developer would never leave: a shallow clone in CI where git describe finds no tags, a worktree where the .git entry is a file, a repository with a merge in progress, or a directory that is not a repository at all. Handle each with a deliberate check instead of letting an exception escape. git rev-parse --is-inside-work-tree answers the last one with an exit status, git fetch --unshallow --tags repairs the first, and the presence of MERGE_HEAD inside the git directory, found with git rev-parse --git-dir, signals the third. CI systems often check out a detached commit, so ask for the branch from an environment variable the CI sets and not from Git.

Environment and safety

Git reads configuration from several places, and user settings change output. A user alias, a pager, a coloured output setting or a different log.date can alter what a script parses. Pass -c color.ui=false and set GIT_PAGER=cat or call git --no-pager to get stable output, and set LC_ALL=C when message text matters. For code that runs on untrusted repositories, remember that a repository can carry configuration and hooks, and since Git 2.35.2 the safe.directory check refuses to operate on a repository owned by another user, with a "dubious ownership" message. Do not disable it globally to silence the error; add the specific path.

Choosing between a library and the executable

Calling git costs a process per command, a few milliseconds each, which is fine for a release script and noticeable when inspecting thousands of objects. A library such as pygit2, which binds libgit2, reads objects in-process and is much faster for bulk work like walking history, at the price of a native dependency and behaviour that follows libgit2 rather than Git for some corner cases. For most automation the executable is the better trade, because it is what developers run by hand, so a failing command can be copied into a terminal and reproduced exactly.

Testing code that calls Git

Tests for Git-facing code should use real repositories created in a temporary directory, not mocks. A fixture that runs git init, sets user.name and user.email locally, makes two commits and a tag takes a few lines and a few milliseconds, and exercises the same commands production uses. Mocking the subprocess call tests only that the mock was called. Set GIT_CONFIG_GLOBAL=/dev/null and GIT_CONFIG_NOSYSTEM=1 in the test environment so a developer's own settings, such as a signing requirement or a default branch name, cannot change the outcome. Run the same suite on the Git version that CI uses, since porcelain output has changed in small ways across releases.

Wrapping common operations

As a script grows, collect the Git calls in one small module with functions named for what they return, such as current_branch(), changed_files(base) and latest_tag(). Callers then never see argument lists, the parsing is in one place, and a change of approach, say moving to a library for speed, touches a single file. Give each function a docstring that states what it returns when the repository is in an unusual state, such as no tags, an empty repository or a detached head, and test those states directly.

Where this does not apply

If the script only needs Git for one value, the shell is simpler; see the current branch in a script. Libraries make sense when you need many operations on one repository or want to avoid depending on the Git executable. Never build a command from untrusted input with shell=True, as in subprocess shell injection. Documentation: GitPython and the Git manual for porcelain format version 1.

Also inGit
Taggedgitpython
Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.