Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
269 changes: 244 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,53 +3,272 @@

<!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! -->

## Usage
fastgit wraps the `git` CLI. The base [`Git`](https://AnswerDotAI.github.io/fastgit/core.html#git) class turns every git subcommand into a method call and returns whatever git printed, and its [`Repo`](https://AnswerDotAI.github.io/fastgit/repo.html#repo) subclass adds live objects for the things git talks about: commits, refs, status, diffs, blame. Reprs mirror the terminal, so displaying an object shows roughly what the matching git command would have shown you.

### Installation
> **NB**: If you are reading this in GitHub’s readme, we recommend you instead read the much more nicely formatted [documentation format](https://AnswerDotAI.github.io/fastgit/) of this tutorial.

Install latest from [pypi](https://pypi.org/project/fastgit/)
## Install

``` sh
$ pip install fastgit
pip install fastgit
```

### How to use
## Getting started

In this example we run `git init` on a directory, add a *.gitignore*, and commit it.
``` python
from fastgit import *
from pathlib import Path
import tempfile, shutil
```

Everything below happens in a temporary directory, so it’s safe to run anywhere. [`Repo`](https://AnswerDotAI.github.io/fastgit/repo.html#repo) (like [`Git`](https://AnswerDotAI.github.io/fastgit/core.html#git)) dispatches any attribute as a git subcommand: `r.init(b='main')` runs `git init -b main`, and the reply is the string git printed.

``` python
import shutil, tempfile
td = tempfile.mkdtemp()
r = Repo(td)
r.init(b='main')
```

'Initialized empty Git repository in /tmp/tmp111odbt6/.git/'

Keyword arguments become flags: one-letter names get one dash (`b='main'` is `-b main`), longer names get two (`no_ff=True` is `--no-ff`; `True` means the flag takes no value), and `__=['path']` puts paths after `--`. When git fails, the method prints git’s one-line complaint and returns None; pass `raise_exc=True` to get an exception instead.

## Commits

git stores snapshots: a commit points at a complete tree, and log, diff, and status are views computed from those snapshots. On [`Repo`](https://AnswerDotAI.github.io/fastgit/repo.html#repo), `commit` returns the new head [`Commit`](https://AnswerDotAI.github.io/fastgit/repo.html#commit), whose repr is its `--oneline` row. (Committing needs an identity, hence the `config` calls.)

``` python
def _git_init(g):
if g.exists: return # Return early if git already initialised
g.init(b='main')
g.config('user.name', 'fastgit')
g.config('user.email', 'fastgit@example.com')
(g.d/".gitignore").mk_write("*.bak")
g.add(".gitignore")
g.commit(m="add .gitignore")
r.config('user.name', 'fastgit')
r.config('user.email', 'fastgit@example.com')
(r.d/'shop.txt').write_text('bread\nmilk\n')
r.add('.')
r.commit('start the list')
```

d05bd08 start the list

``` python
td = tempfile.mkdtemp()
g = Git(td)
_git_init(g)
assert 'add .gitignore' in g.last_commit
print(g.branch('--show-current'))
(r.d/'shop.txt').write_text('bread\nmilk\neggs\n')
r.add('.')
c = r.commit('need eggs')
r.log()
```

ba8bf40 need eggs
d05bd08 start the list

`log` takes git’s own range syntax and flags (`r.log('main..feat')`, `n=10`). `at` resolves any rev to a single [`Commit`](https://AnswerDotAI.github.io/fastgit/repo.html#commit), and `c.parent` walks up the graph:

``` python
r.at('HEAD~1')
```

d05bd08 start the list

## Diffs

Since a commit is a snapshot, a patch is a comparison between two of them, and `b = a + patch` means the patch is `b - a`. Subtraction returns a [`Diff`](https://AnswerDotAI.github.io/fastgit/repo.html#diff), file rows shown `--stat`-style, with the full text one property away:

``` python
c - c.parent
```

shop.txt | +1 -0
1 files changed, +1 -0

``` python
print((c - c.parent).patch)
```

diff --git a/shop.txt b/shop.txt
index 26d3bde..5c4c692 100644
--- a/shop.txt
+++ b/shop.txt
@@ -1,2 +1,3 @@
bread
milk
+eggs

## Status

`status` compares HEAD, the index, and the working directory, including untracked files, shown as `git status -sb` would. The codes are porcelain v2’s: `.M` is modified but unstaged, `M.` staged, `??` untracked:

``` python
(r.d/'shop.txt').write_text('bread\nmilk\neggs\njam\n')
(r.d/'notes.txt').write_text('todo\n')
r.status
```

## main
.M shop.txt
?? notes.txt

``` python
r.add('-A')
r.commit('add jam and notes')
r.status.clean
```

True

## Merges: a conflict is a status, not an error

Every op that changes the working tree (`merge`, `rebase`, `pull`, `stash`) returns the resulting [`Status`](https://AnswerDotAI.github.io/fastgit/repo.html#status), clean or conflicted. Nothing raises on conflict, because git considers a paused merge a normal state; you read the status to see where you stand. Let’s manufacture a conflict:

``` python
r.switch('-c', 'feat')
(r.d/'shop.txt').write_text('bread\nmilk\neggs\njam\nbutter\n')
r.add('.')
r.commit('feat: butter')
r.switch('main')
(r.d/'shop.txt').write_text('bread\nmilk\neggs\njam\ncheese\n')
r.add('.')
r.commit('main: cheese')
r.merge('feat')
```

## main
UU shop.txt
# merge in progress

The conflicted entry’s three versions are readable as `:1:path` (base), `:2:path` (ours), and `:3:path` (theirs). `cat` reads them exactly, byte for byte:

``` python
print(r.cat(':3:shop.txt'))
```

bread
milk
eggs
jam
butter

Resolution is ordinary git: write the file, `add`, `commit`. The two parents on the new head are the proof the merge concluded:

``` python
(r.d/'shop.txt').write_text('bread\nmilk\neggs\njam\nbutter\ncheese\n')
r.add('.')
mc = r.commit('merge feat')
len(mc.parents)
```

2

## Blame and trace

`blame` maps each line to the commit that last touched it, and each row’s `.commit` is the full handle. Our shopping list is now spread over five commits, two of them from different sides of the merge:

``` python
r.blame('shop.txt')
```

d05bd08 (fastgit 2026-07-24 13:15 1) bread
d05bd08 (fastgit 2026-07-24 13:15 2) milk
ba8bf40 (fastgit 2026-07-24 13:15 3) eggs
df130c1 (fastgit 2026-07-24 13:15 4) jam
163d8a7 (fastgit 2026-07-24 13:15 5) butter
77975d6 (fastgit 2026-07-24 13:15 6) cheese

The `-L` range forms come as keywords: `lines=(start,end)`, `func='name'` (git’s `:funcname` form, which finds a definition by name), and `regex=` for content matching. `trace` is `git log -L`, the history of a range: ordinary [`Commits`](https://AnswerDotAI.github.io/fastgit/repo.html#commits), each carrying the `.patch` that changed it.

``` python
(r.d/'prices.py').write_text('def total(xs):\n return sum(xs)\n')
r.add('.')
r.commit('add total')
(r.d/'prices.py').write_text('def total(xs):\n return round(sum(xs), 2)\n')
r.add('.')
r.commit('round totals')
r.blame('prices.py', func='total')
```

main
5d40b66 (fastgit 2026-07-24 13:15 1) def total(xs):
0723d5c (fastgit 2026-07-24 13:15 2) return round(sum(xs), 2)

``` python
t = r.trace('prices.py', func='total')
t
```

You can also pass path arguments after `--` using the `__` parameter:
0723d5c round totals
5d40b66 add total

``` python
g.log('--oneline', __=['.gitignore'])
print(t[0].patch)
```

'22a9a5d add .gitignore'
diff --git a/prices.py b/prices.py
index 8ce3223..bc210a6 100644
--- a/prices.py
+++ b/prices.py
@@ -1,2 +1,2 @@
def total(xs):
- return sum(xs)
+ return round(sum(xs), 2)

## Remotes

A bare directory is a perfectly good remote, so none of this needs a network. `push` returns the current branch’s refreshed [`Ref`](https://AnswerDotAI.github.io/fastgit/repo.html#ref), and after `push -u` its repr carries the tracking bracket, the same confirmation you’d look for in a terminal:

``` python
shutil.rmtree(td)
bare = Path(tempfile.mkdtemp())/'origin.git'
Git(bare.parent)('init', '--bare', '-b', 'main', bare.name)
r.remote('add', 'origin', str(bare))
r.push('-u', 'origin', 'main')
```

* main 0723d5c [origin/main] round totals

[`Repo.clone`](https://AnswerDotAI.github.io/fastgit/repo.html#repo.clone) is a classmethod, since until it runs there’s no repo to hold a handle on:

``` python
r2 = Repo.clone(bare, Path(tempfile.mkdtemp())/'copy')
r2.log(n=3)
```

0723d5c round totals
5d40b66 add total
b64a211 merge feat

When the clone pushes a commit, our first checkout is behind. `fetch` moves the remote-tracking refs and returns the refreshed branches, so the gap shows immediately; `pull` closes it:

``` python
r2.config('user.name', 'fastgit')
r2.config('user.email', 'fastgit@example.com')
(r2.d/'shop.txt').write_text('bread\n')
r2.add('.')
r2.commit('simplify radically')
r2.push()
r.fetch()
```

feat 163d8a7 feat: butter
* main 0723d5c [origin/main: behind 1] round totals

``` python
r.pull().clean
```

True

## Stashes

A stash entry is a real commit under the hood, addressed `stash@{n}`. `stash` returns the now-clean [`Status`](https://AnswerDotAI.github.io/fastgit/repo.html#status), and `pop` returns the status with your changes back:

``` python
(r.d/'notes.txt').write_text('urgent scribble\n')
r.stash('scribble')
r.stashes
```

stash@{0}: On main: scribble

``` python
r.stashes[0].pop()
```

## main...origin/main
.M notes.txt

## Learn more

The [Repo docs](https://AnswerDotAI.github.io/fastgit/repo.html) are the full literate source: refs and tags, rebase (including aborting one mid-conflict), stash details, and the parsing that backs it all. For LLM agents, `fastgit.skill` packages this API as a [pyskills](https://AnswerDotAI.github.io/pyskills/) module.
1 change: 1 addition & 0 deletions fastgit/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
__version__ = "0.1.1"

from .core import *
from .repo import *
81 changes: 80 additions & 1 deletion fastgit/_modidx.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,83 @@
'fastgit.core.Git.last_commit': ('core.html#git.last_commit', 'fastgit/core.py'),
'fastgit.core.Git.top': ('core.html#git.top', 'fastgit/core.py'),
'fastgit.core.callgit': ('core.html#callgit', 'fastgit/core.py'),
'fastgit.core.get_top': ('core.html#get_top', 'fastgit/core.py')}}}
'fastgit.core.get_top': ('core.html#get_top', 'fastgit/core.py')},
'fastgit.repo': { 'fastgit.repo.Blame': ('repo.html#blame', 'fastgit/repo.py'),
'fastgit.repo.Blame.__repr__': ('repo.html#blame.__repr__', 'fastgit/repo.py'),
'fastgit.repo.Blame._repr_pretty_': ('repo.html#blame._repr_pretty_', 'fastgit/repo.py'),
'fastgit.repo.BlameLine': ('repo.html#blameline', 'fastgit/repo.py'),
'fastgit.repo.BlameLine.__repr__': ('repo.html#blameline.__repr__', 'fastgit/repo.py'),
'fastgit.repo.BlameLine.commit': ('repo.html#blameline.commit', 'fastgit/repo.py'),
'fastgit.repo.Commit': ('repo.html#commit', 'fastgit/repo.py'),
'fastgit.repo.Commit.__repr__': ('repo.html#commit.__repr__', 'fastgit/repo.py'),
'fastgit.repo.Commit.__sub__': ('repo.html#commit.__sub__', 'fastgit/repo.py'),
'fastgit.repo.Commit.parent': ('repo.html#commit.parent', 'fastgit/repo.py'),
'fastgit.repo.Commits': ('repo.html#commits', 'fastgit/repo.py'),
'fastgit.repo.Commits.__repr__': ('repo.html#commits.__repr__', 'fastgit/repo.py'),
'fastgit.repo.Commits._repr_pretty_': ('repo.html#commits._repr_pretty_', 'fastgit/repo.py'),
'fastgit.repo.Diff': ('repo.html#diff', 'fastgit/repo.py'),
'fastgit.repo.Diff.__repr__': ('repo.html#diff.__repr__', 'fastgit/repo.py'),
'fastgit.repo.Diff._repr_pretty_': ('repo.html#diff._repr_pretty_', 'fastgit/repo.py'),
'fastgit.repo.Diff.patch': ('repo.html#diff.patch', 'fastgit/repo.py'),
'fastgit.repo.DiffFile': ('repo.html#difffile', 'fastgit/repo.py'),
'fastgit.repo.DiffFile.__repr__': ('repo.html#difffile.__repr__', 'fastgit/repo.py'),
'fastgit.repo.GitObj': ('repo.html#gitobj', 'fastgit/repo.py'),
'fastgit.repo.GitObj._repr_markdown_': ('repo.html#gitobj._repr_markdown_', 'fastgit/repo.py'),
'fastgit.repo.GitObj._repr_pretty_': ('repo.html#gitobj._repr_pretty_', 'fastgit/repo.py'),
'fastgit.repo.Ref': ('repo.html#ref', 'fastgit/repo.py'),
'fastgit.repo.Ref.__repr__': ('repo.html#ref.__repr__', 'fastgit/repo.py'),
'fastgit.repo.Ref.__sub__': ('repo.html#ref.__sub__', 'fastgit/repo.py'),
'fastgit.repo.Ref.ahead': ('repo.html#ref.ahead', 'fastgit/repo.py'),
'fastgit.repo.Ref.behind': ('repo.html#ref.behind', 'fastgit/repo.py'),
'fastgit.repo.Ref.commit': ('repo.html#ref.commit', 'fastgit/repo.py'),
'fastgit.repo.Refs': ('repo.html#refs', 'fastgit/repo.py'),
'fastgit.repo.Refs.__repr__': ('repo.html#refs.__repr__', 'fastgit/repo.py'),
'fastgit.repo.Refs._repr_pretty_': ('repo.html#refs._repr_pretty_', 'fastgit/repo.py'),
'fastgit.repo.Remote': ('repo.html#remote', 'fastgit/repo.py'),
'fastgit.repo.Remote.__repr__': ('repo.html#remote.__repr__', 'fastgit/repo.py'),
'fastgit.repo.Remotes': ('repo.html#remotes', 'fastgit/repo.py'),
'fastgit.repo.Remotes.__repr__': ('repo.html#remotes.__repr__', 'fastgit/repo.py'),
'fastgit.repo.Remotes._repr_pretty_': ('repo.html#remotes._repr_pretty_', 'fastgit/repo.py'),
'fastgit.repo.Repo': ('repo.html#repo', 'fastgit/repo.py'),
'fastgit.repo.Repo._refs': ('repo.html#repo._refs', 'fastgit/repo.py'),
'fastgit.repo.Repo.at': ('repo.html#repo.at', 'fastgit/repo.py'),
'fastgit.repo.Repo.blame': ('repo.html#repo.blame', 'fastgit/repo.py'),
'fastgit.repo.Repo.branches': ('repo.html#repo.branches', 'fastgit/repo.py'),
'fastgit.repo.Repo.cat': ('repo.html#repo.cat', 'fastgit/repo.py'),
'fastgit.repo.Repo.clone': ('repo.html#repo.clone', 'fastgit/repo.py'),
'fastgit.repo.Repo.commit': ('repo.html#repo.commit', 'fastgit/repo.py'),
'fastgit.repo.Repo.diff': ('repo.html#repo.diff', 'fastgit/repo.py'),
'fastgit.repo.Repo.fetch': ('repo.html#repo.fetch', 'fastgit/repo.py'),
'fastgit.repo.Repo.head': ('repo.html#repo.head', 'fastgit/repo.py'),
'fastgit.repo.Repo.log': ('repo.html#repo.log', 'fastgit/repo.py'),
'fastgit.repo.Repo.merge': ('repo.html#repo.merge', 'fastgit/repo.py'),
'fastgit.repo.Repo.pull': ('repo.html#repo.pull', 'fastgit/repo.py'),
'fastgit.repo.Repo.push': ('repo.html#repo.push', 'fastgit/repo.py'),
'fastgit.repo.Repo.rebase': ('repo.html#repo.rebase', 'fastgit/repo.py'),
'fastgit.repo.Repo.remotes': ('repo.html#repo.remotes', 'fastgit/repo.py'),
'fastgit.repo.Repo.stash': ('repo.html#repo.stash', 'fastgit/repo.py'),
'fastgit.repo.Repo.stashes': ('repo.html#repo.stashes', 'fastgit/repo.py'),
'fastgit.repo.Repo.status': ('repo.html#repo.status', 'fastgit/repo.py'),
'fastgit.repo.Repo.tags': ('repo.html#repo.tags', 'fastgit/repo.py'),
'fastgit.repo.Repo.trace': ('repo.html#repo.trace', 'fastgit/repo.py'),
'fastgit.repo.Stash': ('repo.html#stash', 'fastgit/repo.py'),
'fastgit.repo.Stash.__repr__': ('repo.html#stash.__repr__', 'fastgit/repo.py'),
'fastgit.repo.Stash.drop': ('repo.html#stash.drop', 'fastgit/repo.py'),
'fastgit.repo.Stash.pop': ('repo.html#stash.pop', 'fastgit/repo.py'),
'fastgit.repo.Stashes': ('repo.html#stashes', 'fastgit/repo.py'),
'fastgit.repo.Stashes.__repr__': ('repo.html#stashes.__repr__', 'fastgit/repo.py'),
'fastgit.repo.Stashes._repr_pretty_': ('repo.html#stashes._repr_pretty_', 'fastgit/repo.py'),
'fastgit.repo.Status': ('repo.html#status', 'fastgit/repo.py'),
'fastgit.repo.Status.__repr__': ('repo.html#status.__repr__', 'fastgit/repo.py'),
'fastgit.repo.Status._repr_pretty_': ('repo.html#status._repr_pretty_', 'fastgit/repo.py'),
'fastgit.repo.Status.clean': ('repo.html#status.clean', 'fastgit/repo.py'),
'fastgit.repo.Status.conflicts': ('repo.html#status.conflicts', 'fastgit/repo.py'),
'fastgit.repo.StatusEntry': ('repo.html#statusentry', 'fastgit/repo.py'),
'fastgit.repo.StatusEntry.__repr__': ('repo.html#statusentry.__repr__', 'fastgit/repo.py'),
'fastgit.repo._conv': ('repo.html#_conv', 'fastgit/repo.py'),
'fastgit.repo._fmt': ('repo.html#_fmt', 'fastgit/repo.py'),
'fastgit.repo._lspec': ('repo.html#_lspec', 'fastgit/repo.py'),
'fastgit.repo._parse': ('repo.html#_parse', 'fastgit/repo.py'),
'fastgit.repo._st_entry': ('repo.html#_st_entry', 'fastgit/repo.py'),
'fastgit.repo._tz': ('repo.html#_tz', 'fastgit/repo.py')},
'fastgit.skill': {}}}
Loading
Loading