GitPython
GitPython gives you Python objects for the things git works with: a Repo, its branches, commits, trees, blobs, remotes, and the index. You can stage files and commit through repo.index, walk history with repo.iter_commits(), read a file's contents at any revision without checking it out, and diff two commits, all as ordinary Python. Underneath, most of this shells out to the git binary you already have installed and parses its output, with a smaller pure-Python layer (gitdb) for reading object and pack files directly. The escape hatch matters as much as the object model: repo.git.anything(...) maps directly to a git subcommand, so anything the wrapper does not model is still one line away.
The most convenient way to script git from Python, and fine for short-lived tools where you control every argument. Treat it with care in anything long-running or exposed to user input, and reach for pygit2 when you need speed or a git-free environment.
Use it if
- You are scripting git operations in Python and want objects and attributes instead of parsing porcelain output from subprocess by hand
- You need to read history or file contents at a revision: iter_commits, commit.tree, and blob.data_stream give you that without checking anything out
- You are writing a short-lived tool such as a CI step, a release script, or a repo analysis job, where process lifetime is measured in seconds
- You want a documented fallback for anything unmodelled: repo.git.<subcommand>(...) forwards to the real git command with keyword arguments translated into flags
- You run a long-lived process: the README states plainly that GitPython leaks system resources and is not suited for daemons, because cleanup was written when __del__ ran deterministically; the suggested workaround is to isolate it in a subprocess you kill periodically
- You handle any user-controlled repository URL, ref name, or option value: the project published 26 security advisories, 23 of them during 2026, nearly all about git option forwarding and argument injection reaching things like core.hooksPath and --template, which turn into code execution
- You need git without the git binary: GitPython requires a git executable on PATH for most operations, so slim containers, serverless images, and sandboxes need git installed first; pygit2 and dulwich do not
- You expected active development: the README declares maintenance mode with no feature work, no bug fixes unless they are safety-related or contributed, and issue response times of up to a month, and the original author points readers at gitoxide, which has no Python bindings
- You are walking tens of thousands of commits or objects: most operations spawn a git process and parse text, so throughput is far below libgit2 bindings for bulk analysis
Setup reality
pip install GitPython pulls in gitdb and smmap and nothing else, and it is pure Python with no compilation. The real prerequisite is outside pip: a git executable must be on PATH, or GitPython raises on import while trying to locate it. Point it at a specific binary with GIT_PYTHON_GIT_EXECUTABLE, defer the check with GIT_PYTHON_REFRESH=quiet, or call git.refresh(path) yourself once you know where git lives. After that, the two things that catch people are resource handling and safety flags. Repo objects hold file handles and subprocess state, so use them as context managers or call repo.close(); the docs are explicit that this library was not written for processes that stay up. Since 3.1.30 the clone and archive paths gate dangerous inputs behind allow_unsafe_protocols and allow_unsafe_options, which default to False, and a long run of 2026 advisories tightened the same guards across config writing, checkout, read-tree, rev-list, and diff. Practical consequence: pin a recent version, upgrade promptly, and never build a git argument out of a string a user supplied. Note also that the PyPI name is GitPython while the import is `import git`, which trips up requirements files and grep alike.
Patterns
Open a repository from anywhere inside itopen-repository
import git
with git.Repo(".", search_parent_directories=True) as repo:
print(repo.working_tree_dir)
print(repo.active_branch.name)
print(repo.head.commit.hexsha[:8])search_parent_directories walks up like git itself does, so the script works from a subdirectory. Use the context manager or call repo.close(): Repo holds handles and the library is documented as leaking resources in long-lived processes.
Clone, including a shallow cloneclone-repository
from git import Repo
repo = Repo.clone_from(
"https://github.com/gitpython-developers/GitPython",
"/tmp/gitpython",
branch="main",
depth=1,
)Never interpolate a user-supplied URL here. allow_unsafe_protocols and allow_unsafe_options default to False for a reason: ext:: URLs and options such as --upload-pack or --template have each been the subject of remote code execution advisories in this project.
Stage files and create a commitstage-and-commit
from pathlib import Path
from git import Repo
repo = Repo("/tmp/work")
(Path(repo.working_tree_dir) / "notes.md").write_text("hello\n")
repo.index.add(["notes.md"])
commit = repo.index.commit("Add notes")
print(commit.hexsha, commit.author.name)index.add takes a list, not a string; passing a bare string iterates its characters in some paths and produces confusing errors. It also does not honour .gitignore the way `git add` does, so pass explicit paths or fall back to repo.git.add(...).
Find out whether the tree is cleancheck-working-tree-state
repo = git.Repo(path)
if repo.is_dirty(untracked_files=True):
print("modified:", [d.a_path for d in repo.index.diff(None)])
print("staged: ", [d.a_path for d in repo.index.diff("HEAD")])
print("untracked:", repo.untracked_files)is_dirty() ignores untracked files unless you ask for them, which is the opposite of what most scripts want before a release step. index.diff(None) compares index to working tree; index.diff('HEAD') compares index to the last commit.
Iterate commits, optionally for one pathwalk-history
for commit in repo.iter_commits("main", max_count=20, paths="src/"):
print(commit.committed_datetime.date(), commit.author.email, commit.summary)iter_commits returns a generator and each Commit is lazy, so touching attributes can trigger more work. Always pass max_count or a rev range on large repositories; there is no cheap way to count first.
Read a file's contents at any commitread-file-at-revision
commit = repo.commit("HEAD~5")
blob = commit.tree / "src" / "app.py"
text = blob.data_stream.read().decode("utf-8")
print(len(text.splitlines()), "lines at", commit.hexsha[:8])This never touches the working tree, so it is safe to run against a repository someone else is editing. A missing path raises KeyError, not FileNotFoundError. Use commit.tree.traverse() to walk every blob recursively.
Create and switch branchesbranches-and-checkout
feature = repo.create_head("feature/login", "HEAD")
feature.checkout()
# back again
repo.heads.main.checkout()
print(repo.active_branch.name)repo.active_branch raises TypeError when HEAD is detached, which is the normal state in most CI checkouts; guard with repo.head.is_detached before reading it.
Fetch, pull, and push through a remoteremotes-fetch-push
origin = repo.remotes.origin
origin.fetch(prune=True)
for info in origin.push(refspec="HEAD:refs/heads/main"):
if info.flags & info.ERROR:
raise RuntimeError(info.summary)push() does not raise on rejection; it returns PushInfo objects and you have to inspect flags yourself, which is how silent CI failures happen. Authentication is whatever git would use, so configure credentials or an SSH agent outside GitPython.
Call any git subcommand directlyraw-git-command
print(repo.git.log("--oneline", max_count=5))
print(repo.git.checkout("-b", "hotfix"))
print(repo.git.describe(tags=True, always=True))Keyword arguments become flags: max_count=5 turns into --max-count=5 and single-character keys become -x value. This is the escape hatch for everything unmodelled, and also the sharpest edge, since building any part of these arguments from untrusted input is exactly the pattern behind this project's argument-injection advisories.
Diff two commits and inspect the changesdiff-two-revisions
old = repo.commit("HEAD~3")
new = repo.commit("HEAD")
for d in old.diff(new, create_patch=False):
print(d.change_type, d.a_path, "->", d.b_path)
# unified patch text
print(repo.git.diff("HEAD~3", "HEAD", "--stat"))Diff direction is a classic mistake: old.diff(new) reports what it takes to go from old to new, so an added file shows change_type 'A'. create_patch=True is much slower because it materializes the patch text for every entry.
Run commands with a specific SSH key or identitycustom-git-environment
ssh = "ssh -i /keys/deploy_ed25519 -o IdentitiesOnly=yes"
with repo.git.custom_environment(GIT_SSH_COMMAND=ssh):
repo.remotes.origin.fetch()
repo.config_writer().set_value("user", "email", "bot@example.com").release()custom_environment is a context manager and only applies inside the block; update_environment() sets it for the life of the object. Always call release() on a config_writer, otherwise the lock file stays behind and later git commands fail.
Handle a missing or non-standard git executablelocate-git-binary
import os
os.environ["GIT_PYTHON_REFRESH"] = "quiet" # do not fail at import
import git
try:
git.refresh("/usr/local/bin/git")
except git.exc.GitCommandNotFound:
raise SystemExit("git is not installed; GitPython needs the binary")By default importing git probes for the executable and raises immediately if it is absent, which breaks module import in slim containers. GIT_PYTHON_GIT_EXECUTABLE sets the path up front; GIT_PYTHON_REFRESH=quiet defers the failure to first use.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| pygit2 | PyPI | You need speed or want to work without a git binary; these are libgit2 bindings, so bulk history walking and object access are much faster. |
| dulwich | PyPI | You want a pure Python git implementation with no external binary and no C extension, for example inside a restricted runtime. |
| sh | PyPI | You only run a handful of git commands and would rather call the binary directly with explicit argument lists than adopt an object model. |