Fast, safe, pure-Rust Git for Python — bindings to the gitoxide engine, and a modern alternative to GitPython.
gitoxide (the gix crate) is a next-generation, pure-Rust implementation of
Git. This project exposes that engine to Python through PyO3
and ships as pre-built wheels via maturin, so you get:
| GitPython | pygit2 | gitoxide-python | |
|---|---|---|---|
| Backend | shells out to the git CLI |
C libgit2 |
pure-Rust gix |
| Walk 10,000 commits | 323 ms (a git subprocess, then parsing) |
389 ms | 129 ms |
| Install | needs git on PATH |
needs a C toolchain / system libgit2 |
pip install, self-contained wheels |
| Memory safety | n/a (subprocess) | C | Rust |
Nothing here shells out to the git CLI. A repository that configures a
clean/smudge filter (Git LFS, say) will still have that filter program executed
when blame reads such a file — exactly as git itself would.
Status: alpha. The binding surface is small but real. It currently covers read-oriented workflows (open/discover, HEAD, history walk, refs, branches, tags, blob reads) — the operations DevOps tooling reaches for most. See Scope for what is and isn't wrapped yet.
pip install gitoxidePre-built wheels are published for Linux (manylinux, x86_64 + aarch64), macOS
(x86_64 + Apple Silicon), and Windows — no Rust toolchain or system libgit2
required.
import gitoxide
repo = gitoxide.open(".") # or gitoxide.discover(".")
print(repo.git_dir) # .../my-project/.git
print(repo.workdir) # .../my-project (None if bare)
print(repo.is_bare) # False
print(repo.head_name) # 'main' (None if detached)
# The commit at HEAD
head = repo.head_commit()
print(head.short_id, head.summary)
print(head.author.name, head.author.email, head.author.time)
# Walk history (reverse-chronological), like `git log`
for commit in repo.commits(max_count=10):
print(commit.short_id, commit.author.name, commit.summary)
# Resolve a revspec to an object id
print(repo.rev_parse("HEAD~2"))
print(repo.rev_parse("main"))
# Branches, tags, and all references
print(repo.branches()) # ['main', 'dev', ...]
print(repo.tags()) # ['v1.0.0', ...]
for ref in repo.references():
print(ref.name, "->", ref.target)
# Read a file's content at a revision
readme = repo.read_blob("HEAD:README.md")
print(readme.decode())
# Blame a file: which commit last touched each line
for hunk in repo.blame("README.md"):
print(f"{hunk.start_line}-{hunk.end_line}\t{hunk.short_id}")Median time per operation on a full clone of python/cpython — 130,654 commits, 531 references — on an Apple M2:
| Operation | GitPython | pygit2 | gitoxide-python |
|---|---|---|---|
| Walk 10,000 commits | 323 ms | 389 ms | 129 ms |
| List 531 references | 74.4 ms | 1.31 ms | 3.45 ms |
| Read a 8.7 KB blob | 179 µs | 188 µs | 48.8 µs |
Resolve HEAD~100 |
3.02 ms | 28.2 µs | 602 µs |
| Read the HEAD commit | 87.3 µs | 22.6 µs | 34.2 µs |
| Open the repository | 129 µs | 111 µs | 146 µs |
| Blame a 475-line file | 1.07 s | 28.4 s | 1.85 s |
Faster than GitPython on five of these seven, a tie on open, and a loss on
blame — where GitPython is really git's own C implementation in a subprocess,
and gix's younger blame has not caught it yet. Against pygit2 the split runs the
other way: history walking and blame are several times faster here, while
pygit2 still wins the single-object lookups it has spent fifteen years tuning.
The three libraries are checked against each other on every operation — same
commit ids, same blob bytes, same line count — so a fast wrong answer can't win.
benchmarks/ has the exact call made against each library, the
caveats, and a one-command way to reproduce it on your own machine.
Common operations, side by side:
| GitPython | gitoxide-python |
|---|---|
from git import Repo |
import gitoxide |
repo = Repo(path) |
repo = gitoxide.open(path) |
Repo(path, search_parent_directories=True) |
gitoxide.discover(path) |
repo.head.commit |
repo.head_commit() |
repo.active_branch.name |
repo.head_name |
repo.iter_commits("main", max_count=10) |
repo.commits("main", max_count=10) |
repo.commit("HEAD~2") |
repo.commit("HEAD~2") |
repo.rev_parse("main").hexsha |
repo.rev_parse("main") |
[b.name for b in repo.branches] |
repo.branches() |
[t.name for t in repo.tags] |
repo.tags() |
c.hexsha, c.summary, c.author.name |
c.id, c.summary, c.author.name |
The key difference is what happens underneath: GitPython's iter_commits spawns
a git rev-list subprocess; gitoxide-python walks the object database
in-process in Rust.
gitoxide.open(path) -> Repositorygitoxide.discover(path) -> Repository— searchpathand its parentsgitoxide.init(path, bare=False) -> Repositorygitoxide.gix_version() -> str
Properties: git_dir, workdir, is_bare, is_shallow, head_id,
head_name, head_is_detached.
Note:
head_nameandhead_is_detachedaccess theHEADreference and may raiseGitoxideErrorif it is inaccessible (e.g., corrupted repository). Other properties never raise.
Methods: head_commit(), rev_parse(spec), commit(rev),
commits(rev=None, max_count=None), references(), branches(), tags(),
read_blob(rev), blame(path, rev=None).
id, short_id, tree_id, message, summary, author, committer,
parents.
name, email, time (Unix seconds), offset (UTC offset seconds).
name, shorthand, target.
start_line, end_line, line_count, orig_start_line, commit_id,
short_id. Line numbers are 1-based and inclusive, matching git blame.
Consecutive lines from the same commit are grouped into one hunk.
All errors (from any function or method) are raised as
gitoxide.GitoxideError.
This is a binding, not a reimplementation: it exposes a slice of what the gix
engine already does. Today that slice is:
- Wrapped: open / discover / init, HEAD, history walk, rev-parse, references, branches, tags, blob reads, and blame.
- Not wrapped yet: diff & status, tree listing, writing commits and the index, and clone / remote operations.
The engine supports much more; these are simply the parts this binding hasn't
surfaced yet. Issues and pull requests that expose more of gix are welcome —
see Contributing.
The binding layer lives in a single src/lib.rs and maps closely
onto the gix API, so adding a method is usually a small, self-contained change.
Building from source requires a Rust toolchain (this is also the path used when installing the sdist on a platform without a pre-built wheel):
python -m venv .venv && source .venv/bin/activate
pip install maturin pytest
maturin develop # build the extension into the venv
pytest -q # run the test suite
cargo fmt --all # format Rust
cargo clippy --all-targets -- -D warningsThe tests generate a throwaway git repository on the fly (see
tests/conftest.py), so they need git on PATH but
touch nothing outside a temp directory.
Licensed under either of Apache License 2.0 or MIT license at your option, to match the upstream gitoxide project.