diff --git a/README.md b/README.md index b114847..03303aa 100644 --- a/README.md +++ b/README.md @@ -3,53 +3,272 @@ -## 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. diff --git a/fastgit/__init__.py b/fastgit/__init__.py index fde5f8a..a3693f5 100644 --- a/fastgit/__init__.py +++ b/fastgit/__init__.py @@ -1,3 +1,4 @@ __version__ = "0.1.1" from .core import * +from .repo import * diff --git a/fastgit/_modidx.py b/fastgit/_modidx.py index b7a2ea1..34ac42f 100644 --- a/fastgit/_modidx.py +++ b/fastgit/_modidx.py @@ -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': {}}} diff --git a/fastgit/core.py b/fastgit/core.py index 9bf5258..c7fdaa8 100644 --- a/fastgit/core.py +++ b/fastgit/core.py @@ -14,7 +14,7 @@ # %% ../nbs/00_core.ipynb #e4c0a290 def callgit(path, *args, uname=None, pre=None): - "Run git in `path`, returning stripped stdout+stderr as a single `str`" + "Run git in `path`, non-interactively (editors suppressed), returning stripped stdout+stderr as a single `str`" assert not (uname and pre), "Pass `uname` or `pre`, not both" if uname: warn("`uname` is deprecated; pass e.g `pre=['/usr/bin/sudo','-u',uname]` instead", DeprecationWarning, stacklevel=2) @@ -22,7 +22,8 @@ def callgit(path, *args, uname=None, pre=None): fp = Path(path).expanduser().resolve() args = ['git', '-C', str(fp)] + list(args) if pre: args = [*pre, *args] - r = subprocess.run(args, capture_output=True, text=True, check=True) + r = subprocess.run(args, capture_output=True, text=True, check=True, + env=os.environ|dict(GIT_EDITOR='true', GIT_SEQUENCE_EDITOR='true')) return (r.stdout + r.stderr).strip() # %% ../nbs/00_core.ipynb #4c0df6c6 diff --git a/fastgit/repo.py b/fastgit/repo.py new file mode 100644 index 0000000..7b4f344 --- /dev/null +++ b/fastgit/repo.py @@ -0,0 +1,367 @@ +"""Commits, refs, diffs, and status as live Python objects + +Docs: https://AnswerDotAI.github.io/fastgit/repo.html.md""" + +# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/01_repo.ipynb. + +# %% auto #0 +__all__ = ['Repo', 'GitObj', 'Commit', 'Commits', 'Ref', 'Refs', 'DiffFile', 'Diff', 'StatusEntry', 'Status', 'BlameLine', + 'Blame', 'Remote', 'Remotes', 'Stash', 'Stashes'] + +# %% ../nbs/01_repo.ipynb #7c57fcf7 +from fastcore.utils import * +from .core import * +import re, subprocess +from datetime import datetime, timezone, timedelta + +# %% ../nbs/01_repo.ipynb #aabe4562 +class Repo(Git): + "A `Git` handle whose common queries return live objects instead of strings" + +# %% ../nbs/01_repo.ipynb #17251ab6 +_commit_f = dict(sha='%H', short='%h', parents='%P', author='%an', email='%ae', date='%aI', msg='%s') + +def _fmt(fields): return '%x1f'.join(fields.values())+'%x1e' + +def _conv(k, v): + if k=='parents': return v.split() + if k=='date': return datetime.fromisoformat(v) + return v + +def _parse(fields, s): + "Parse `%x1f`/`%x1e`-delimited output into one dict per record" + return [{k:_conv(k,v) for k,v in zip(fields, rec.strip('\n').split('\x1f'))} + for rec in (s or '').split('\x1e') if rec.strip()] + +# %% ../nbs/01_repo.ipynb #95961923 +class GitObj(AttrDict): + "Base for git handles: plain-text display, shown via `__repr__`" + def _repr_markdown_(self): return None + def _repr_pretty_(self, p, cycle): p.text(repr(self)) + +class Commit(GitObj): + "A git commit: immutable, addressed by `sha`" + def __repr__(self): return f'{self.short} {self.msg}' + +class Commits(L): + "Commits shown as a `--oneline`-style log" + def __repr__(self): return '\n'.join(repr(o) for o in self) + def _repr_pretty_(self, p, cycle): p.text(repr(self)) + +# %% ../nbs/01_repo.ipynb #6f3d7fdb +@patch +def log(self:Repo, *args, **kwargs): + "`Commits` for `git log` `args`: revision ranges ('main..feat'), flags, paths via `__`" + res = self('log', *args, format=_fmt(_commit_f), mute_errors=True, **kwargs) + return Commits(Commit(d, _g=self) for d in _parse(_commit_f, res)) + +@patch +def at(self:Repo, rev): + "The `Commit` for commit-ish `rev` (sha, ref name, 'HEAD~2', `Ref`, ...); None if unknown" + return first(self.log(str(getattr(rev, 'sha', rev)), n=1)) + +@patch(as_prop=True) +def head(self:Repo): + "The `Commit` at HEAD" + return self.at('HEAD') + +@patch(as_prop=True) +def parent(self:Commit): + "First-parent `Commit`, or None for a root commit" + return self._g.at(self.parents[0]) if self.parents else None + +# %% ../nbs/01_repo.ipynb #e017095b +_ref_f = dict(name='%(refname:short)', sha='%(objectname)', upstream='%(upstream:short)', + track='%(upstream:track)', cur='%(HEAD)', msg='%(subject)') + +class Ref(GitObj): + "A git ref: a mutable named pointer to a commit" + def __repr__(self): + up = f" [{self.upstream}{': '+self.track.strip('[]') if self.track else ''}]" if self.upstream else '' + return f"{'*' if self.cur=='*' else ' '} {self.name} {self.sha[:7]}{up} {self.msg}" + @property + def ahead(self): return int(m.group(1)) if (m:=re.search(r'ahead (\d+)', self.track)) else 0 + @property + def behind(self): return int(m.group(1)) if (m:=re.search(r'behind (\d+)', self.track)) else 0 + @property + def commit(self): return self._g.at(self.sha) + +class Refs(L): + "Refs shown as `branch -vv`-style lines" + def __repr__(self): return '\n'.join(repr(o) for o in self) + def _repr_pretty_(self, p, cycle): p.text(repr(self)) + +# %% ../nbs/01_repo.ipynb #ba48317b +@patch +def _refs(self:Repo, pat): + res = self('for-each-ref', pat, format='%1f'.join(_ref_f.values())+'%1e', mute_errors=True) + return Refs(Ref(d, _g=self) for d in _parse(_ref_f, res)) + +@patch(as_prop=True) +def branches(self:Repo): + "Local branches as `Refs`" + return self._refs('refs/heads') + +@patch(as_prop=True) +def tags(self:Repo): + "Tags as `Refs`" + return self._refs('refs/tags') + +# %% ../nbs/01_repo.ipynb #528e494d +class DiffFile(GitObj): + "One changed file; `adds`/`dels` are None for binary files" + def __repr__(self): return f"{self.path} | " + ('bin' if self.adds is None else f'+{self.adds} -{self.dels}') + +class Diff(L): + "Changed files shown `--stat`-style; the full patch text is in `.patch`" + def __repr__(self): + tot = f"{len(self)} files changed, +{sum(o.adds or 0 for o in self)} -{sum(o.dels or 0 for o in self)}" + return '\n'.join([repr(o) for o in self] + [tot]) + def _repr_pretty_(self, p, cycle): p.text(repr(self)) + @property + def patch(self): + "Full patch text, as `git diff` prints it" + return self._g('diff', *self._args, **self._kw) + +# %% ../nbs/01_repo.ipynb #0c507c03 +@patch +def diff(self:Repo, *args, **kwargs): + "`Diff` for `git diff` `args`: commits, ranges ('a...b'), paths via `__`" + res = self('diff', '--numstat', *args, mute_errors=True, **kwargs) + def _row(ln): + a,d,p = ln.split('\t', 2) + return DiffFile(path=p, adds=None if a=='-' else int(a), dels=None if d=='-' else int(d)) + df = Diff(_row(o) for o in (res or '').splitlines()) + df._g,df._args,df._kw = self,args,kwargs + return df + +@patch +def __sub__(self:Commit, other): + "`b - a` is the patch taking `a` to `b`, i.e. `git diff a b`" + return self._g.diff(getattr(other, 'sha', str(other)), self.sha) + +@patch +def __sub__(self:Ref, other): return self.commit - other + +# %% ../nbs/01_repo.ipynb #a2496168 +class StatusEntry(GitObj): + "One changed path; `xy` is the porcelain staged/unstaged code pair" + def __repr__(self): return f'{self.xy} {self.path}' + + +def _st_entry(ln): + t,rest = ln[0],ln[2:] + if t in '?!': return StatusEntry(xy=t*2, path=rest) + p = rest.split(' ') + if t=='1': return StatusEntry(xy=p[0], path=' '.join(p[7:])) + if t=='2': + path,orig = ' '.join(p[8:]).split('\t') + return StatusEntry(xy=p[0], path=path, orig=orig) + if t=='u': return StatusEntry(xy=p[0], path=' '.join(p[9:]), stages=p[6:9]) + +class Status(L): + "Working-tree state shown `status -sb`-style: branch line, entries, in-progress op" + branch=upstream=oid=op=None + ahead=behind = 0 + def __repr__(self): + ab = f' [ahead {self.ahead}, behind {self.behind}]' if self.ahead or self.behind else '' + up = f'...{self.upstream}' if self.upstream else '' + res = [f'## {self.branch}{up}{ab}'] + [repr(o) for o in self] + if self.op: res.append(f'# {self.op} in progress') + return '\n'.join(res) + def _repr_pretty_(self, p, cycle): p.text(repr(self)) + @property + def clean(self): return not len(self) + @property + def conflicts(self): + "Unmerged entries only" + return L(o for o in self if o.xy=='UU') + +# %% ../nbs/01_repo.ipynb #c04a8dfd +_op_f = dict(MERGE_HEAD='merge', CHERRY_PICK_HEAD='cherry-pick', REVERT_HEAD='revert', BISECT_LOG='bisect') + +@patch(as_prop=True) +def status(self:Repo): + "Current `Status`: branch info, changed/untracked/unmerged entries, any in-progress op" + info,st = {},Status() + for ln in (self('status', '--porcelain=v2', '--branch', mute_errors=True) or '').splitlines(): + if ln.startswith('# branch.'): + k,_,v = ln[9:].partition(' ') + info[k] = v + elif ln: st.append(_st_entry(ln)) + st.branch,st.upstream,st.oid = info.get('head'),info.get('upstream'),info.get('oid') + ab = re.findall(r'\d+', info.get('ab', '')) + st.ahead,st.behind = map(int, ab) if ab else (0, 0) + gd = Path(self('rev-parse', '--git-dir', mute_errors=True) or '.git') + if not gd.is_absolute(): gd = self.d/gd + st.op = first(v for k,v in _op_f.items() if (gd/k).exists()) + rb = first(p for o in ('rebase-merge','rebase-apply') if (p:=gd/o).exists()) + if not st.op and rb: + nm = (rb/'head-name').read_text().strip().removeprefix('refs/heads/') if (rb/'head-name').exists() else '' + st.op = f'rebase of {nm}' if nm else 'rebase' + return st + +# %% ../nbs/01_repo.ipynb #e3c633c9 +@patch +def commit(self:Repo, msg=None, *args, **kwargs): + "Commit staged changes, returning the new head `Commit`" + if msg: kwargs['m'] = msg + self('commit', *args, **kwargs) + return self.head + +@patch +def merge(self:Repo, *args, **kwargs): + "Merge, returning the resulting `Status`: a conflict is a state, not an error" + self('merge', *args, mute_errors=True, **kwargs) + return self.status + +@patch +def rebase(self:Repo, *args, **kwargs): + "Rebase, returning the resulting `Status`" + self('rebase', *args, mute_errors=True, **kwargs) + return self.status + +@patch +def pull(self:Repo, *args, **kwargs): + "Pull, returning the resulting `Status`" + self('pull', *args, mute_errors=True, **kwargs) + return self.status + +# %% ../nbs/01_repo.ipynb #11cf4693 +@patch +def cat(self:Repo, spec): + "Exact file content at `spec` ('rev:path', or ':1:'/':2:'/':3:' + path for conflict stages); no stripping, unlike raw verbs" + args = [*(self.pre or []), 'git', '-C', str(self.d), 'show', str(spec)] + return subprocess.run(args, capture_output=True, text=True, check=True).stdout + +# %% ../nbs/01_repo.ipynb #48fa0ed1 +def _lspec(func=None, lines=None, regex=None): + "A `git -L` range spec from whichever of `func`, `lines`, or `regex` is given" + if func: return f':{func}' + if lines: return f'{lines[0]},{lines[1]}' + if regex: + rs = [regex] if isinstance(regex, str) else regex + return ','.join('/'+o.replace('/', r'\/')+'/' for o in rs) + +def _tz(s): return timezone(timedelta(minutes=(-1 if s[0]=='-' else 1)*(int(s[1:3])*60+int(s[3:5])))) + +class BlameLine(GitObj): + "One blamed line; `sha`/`author`/`date`/`msg` describe the last commit to touch it" + def __repr__(self): return f'{self.sha[:7]} ({self.author} {self.date:%Y-%m-%d %H:%M} {self.lineno:>3}) {self.line}' + @property + def commit(self): return self._g.at(self.sha) + +class Blame(L): + "Blame lines shown as `git blame` shows them" + def __repr__(self): return '\n'.join(repr(o) for o in self) + def _repr_pretty_(self, p, cycle): p.text(repr(self)) + +@patch +def blame(self:Repo, path, *args, func=None, lines=None, regex=None): + "`Blame` for `path` (`args` may add a revision or flags); `func`/`lines`/`regex` pick a `-L` range" + spec = _lspec(func, lines, regex) + cmd = [*(self.pre or []), 'git', '-C', str(self.d), 'blame', '--line-porcelain', + *(['-L', spec] if spec else []), *args, '--', str(path)] + cur,res = {},Blame() + for ln in subprocess.run(cmd, capture_output=True, text=True, check=True).stdout.splitlines(): + if ln.startswith('\t'): res.append(BlameLine(cur, line=ln[1:], _g=self)) + else: + k,_,v = ln.partition(' ') + if re.fullmatch(r'[0-9a-f]{40}', k): cur = dict(sha=k, lineno=int(v.split()[1])) + elif k=='author': cur['author'] = v + elif k=='author-time': cur['ts'] = int(v) + elif k=='author-tz': cur['date'] = datetime.fromtimestamp(cur.pop('ts'), _tz(v)) + elif k=='summary': cur['msg'] = v + return res + +# %% ../nbs/01_repo.ipynb #0a84bdeb +@patch +def trace(self:Repo, path, *args, func=None, lines=None, regex=None, **kwargs): + "Trace the evolution of a range of `path` (`git log -L`): `Commits` newest first, each with its `.patch`" + spec = _lspec(func, lines, regex) + if not spec: raise TypeError('trace needs one of func, lines, or regex') + res = self('log', f'-L{spec}:{path}', *args, format='%x1e'+'%x1f'.join(_commit_f.values()), mute_errors=True, **kwargs) + out = Commits() + for rec in (res or '').split('\x1e'): + if not rec.strip(): continue + head,_,patch = rec.partition('\n') + out.append(Commit({k:_conv(k,v) for k,v in zip(_commit_f, head.split('\x1f'))}, _g=self, patch=patch.strip('\n'))) + return out + +# %% ../nbs/01_repo.ipynb #80c49ba3 +class Remote(GitObj): + "A configured remote" + def __repr__(self): return f'{self.name}\t{self.url}' + +class Remotes(L): + "Remotes shown as `git remote -v`-style lines" + def __repr__(self): return '\n'.join(repr(o) for o in self) + def _repr_pretty_(self, p, cycle): p.text(repr(self)) + +@patch(as_prop=True) +def remotes(self:Repo): + "Configured remotes as `Remotes`" + res = {} + for ln in (self('remote', '-v', mute_errors=True) or '').splitlines(): + nm,rest = ln.split('\t') + res.setdefault(nm, Remote(name=nm, url=rest.split()[0], _g=self)) + return Remotes(res.values()) + +# %% ../nbs/01_repo.ipynb #c19ceaa8 +@patch +def fetch(self:Repo, *args, **kwargs): + "Fetch, returning the local `branches` with refreshed tracking info" + self('fetch', *args, **kwargs) + return self.branches + +@patch +def push(self:Repo, *args, **kwargs): + "Push, returning the current branch's refreshed `Ref`" + self('push', *args, **kwargs) + return first(o for o in self.branches if o.cur=='*') + +# %% ../nbs/01_repo.ipynb #bc13efa5 +@patch(cls_method=True) +def clone(cls:Repo, url, path=None, **kwargs): + "Clone `url` into `path` (default: the repo's name in the cwd), returning a `Repo` on the checkout" + if path is None: path = re.sub(r'\.git$', '', str(url).rstrip('/').split('/')[-1]) + path = Path(path) + Git(path.parent)('clone', str(url), path.name, **kwargs) + return cls(path) + +# %% ../nbs/01_repo.ipynb #6cc1ce2c +_stash_f = dict(_commit_f, sel='%gd') + +class Stash(Commit): + "A stash entry: a real commit addressed `stash@{n}`" + def __repr__(self): return f'{self.sel}: {self.msg}' + +class Stashes(L): + "Stash entries shown as `stash list`-style lines" + def __repr__(self): return '\n'.join(repr(o) for o in self) + def _repr_pretty_(self, p, cycle): p.text(repr(self)) + +@patch(as_prop=True) +def stashes(self:Repo): + "Stash entries, newest first" + res = self('stash', 'list', format=_fmt(_stash_f), mute_errors=True) + return Stashes(Stash(d, _g=self) for d in _parse(_stash_f, res)) + +@patch +def stash(self:Repo, msg=None, **kwargs): + "Stash worktree changes, returning the resulting `Status`" + if msg: kwargs['m'] = msg + self('stash', 'push', mute_errors=True, **kwargs) + return self.status + +@patch +def pop(self:Stash, **kwargs): + "Re-apply and drop this stash, returning the resulting `Status`" + self._g('stash', 'pop', self.sel, mute_errors=True, **kwargs) + return self._g.status + +@patch +def drop(self:Stash, **kwargs): + "Delete this stash, returning the resulting `Status`" + self._g('stash', 'drop', self.sel, mute_errors=True, **kwargs) + return self._g.status diff --git a/fastgit/skill.py b/fastgit/skill.py new file mode 100644 index 0000000..deb3a69 --- /dev/null +++ b/fastgit/skill.py @@ -0,0 +1,83 @@ +"""Object-oriented local git via `Repo`: commits, refs, status, diffs, merges, rebases, stashes, and remotes as live Python objects with terminal-style reprs. Use this for any local repo work: reading history, comparing revisions, cloning, syncing with remotes, and driving merge/rebase/stash workflows including conflict resolution, with no shelling out to `git`. + +# The model + +git stores snapshots. A `Commit` is immutable, so a handle never goes stale; a `Ref` (branch or tag) is a mutable named pointer to one; log, diff, and status are queries computed on demand. `Repo` is the entry point: + + from fastgit.skill import * + r = Repo('.') # any dir inside the repo works; r.exists checks it's a repo + r.init(b='main') # create a repo in an empty dir (set user.name/email before committing) + r = Repo.clone(url, 'dest') # or clone one; returns a Repo on the fresh checkout + +Every result is designed to be displayed bare: reprs mirror the terminal (`--oneline` log, `branch -vv`, `status -sb`, `--stat`). End the cell with the object and read it. Don't loop over fields to rebuild what the repr already shows. + +# Reading + + r.log() # Commits, newest first; repr is a --oneline log + r.log('main..feat', n=10) # git's own range syntax and flags pass through + r.at('HEAD~2') # one Commit (sha/ref/rev syntax; None if unknown) + r.head # Commit at HEAD; .sha .msg .author .date .parents + c.parent # first-parent Commit (None for a root commit) + r.branches, r.tags # Refs; each has .name .sha .upstream .ahead .behind .commit + r.remotes # Remotes (name and url), like `git remote -v` + r.stashes # Stashes; each is a real Commit addressed stash@{n} + r.cat('rev:path') # exact file content at a revision (raw verbs strip trailing whitespace; cat doesn't) + r.blame('core.py', func='load_cfg') # Blame rows: line -> the last Commit to touch it (via .commit) + r.trace('core.py', func='load_cfg') # git log -L: the range's history as Commits, each with .patch + +`blame` and `trace` share three range keywords: `func='name'` (git's `:funcname` form; the default funcname pattern only sees column-0 definitions, so indented methods need `*.py diff=python` in the repo's `.gitattributes`), `lines=(start,end)`, and `regex=` (a content pattern, or a `(start, end)` pair; no configuration needed). + +# Status + +`r.status` is a `Status`: branch info (`.branch .upstream .ahead .behind`), one entry per changed path, `.clean`, `.conflicts` (the unmerged entries), and `.op` naming any in-progress operation ('merge', 'rebase of feat', 'cherry-pick', ...). Entry `xy` codes are porcelain v2's (`.M` modified-unstaged, `M.` staged, `??` untracked, `UU` conflicted). + +# Diffs + +Since `b = a + patch`, subtraction spells the patch: `b - a` is `git diff a b`. It works on `Commit` and `Ref`, and `r.diff('v1...HEAD')` passes git's range syntax through. The result is file rows (`.path .adds .dels`) shown `--stat`-style, with the full text in `.patch`. + + r.head - r.at('v0.1') # what changed since the tag + print((b - a).patch) # full patch text + +# Write ops: conflict is a status, not an error + +Every op that changes the working tree returns the resulting `Status`, clean or conflicted; nothing raises on conflict. Read the returned status to see which you got. + + c = r.commit('msg') # commit staged changes -> new head Commit + st = r.merge('feat') # Status; st.clean means merged, st.op=='merge' means paused + st = r.rebase('main') # Status, same contract + st = r.pull() # Status + st = r.stash('wip') # Status; r.stashes[0].pop()/.apply()/.drop() -> Status + +Conflict resolution works on the same objects. A conflicted entry has `xy=='UU'` and `.stages`, the [base, ours, theirs] blob shas, and the three versions are readable as `:1:path`/`:2:path`/`:3:path`: + + st = r.merge('feat') + for e in st.conflicts: # the unmerged entries (xy=='UU') + theirs = r.cat(f':3:{e.path}') # exact content; :2: ours, :1: base. NOT r.show, which strips the final newline + # write the resolved file, then: + r.add(e.path) + r.commit('merge feat') # concludes the merge; .parents shows 2 + +To back out of a paused op: `r.merge('--abort')`, `r.rebase('--abort')`. + +# Remotes + +`fetch` moves the remote-tracking refs and nothing else, so it returns the refreshed `branches`; read `.ahead`/`.behind` there. `push` returns the current branch's refreshed `Ref`, whose tracking bracket confirms the push took. + + r.fetch() # -> Refs; look for [origin/main: behind 1] + r.push('-u', 'origin', 'main') # -> Ref; publishes the branch and records its upstream + st = r.pull() # fetch plus merge -> Status (can conflict like any merge) + r.remote('add', 'origin', url) # remote management stays raw verbs + +Everything above `push` is reversible local state; `push` is the one operation that changes what other people and machines see, and a `--force`/`--force-with-lease` push rewrites shared history, discarding remote commits others may have pulled or based work on. When working on someone's behalf, never push on inference: a request to prepare, fix, or update a branch or PR is not a request to publish it. Push only when the specific push has been explicitly asked for, and force-push only when the history rewrite itself has been agreed to - then prefer `--force-with-lease`, which at least refuses to overwrite remote commits you have not seen. When a push is off the table, stop after the commit and report the branch ready to push. + +# Everything else: raw verbs + +`Repo` subclasses `Git`, so any other git command dispatches dynamically and returns git's own output as a str: `r.switch('main')`, `r.restore('.')`, `r.tag('v1')`. Kwargs become flags (`n=1` -> `-n 1`, `no_ff=True` -> `--no-ff`), `__=['path']` puts paths after `--`, and errors print one terse line and return None (pass `raise_exc=True` to raise instead). `r.log`/`r.diff`/`r.status` impose their own machine formats; for custom `--format` output call the verb explicitly: `r('log', format='%H %s')`. +""" + +from fastgit.core import Git, callgit, get_top +from fastgit.repo import (Repo, Commit, Commits, Ref, Refs, Diff, DiffFile, Status, StatusEntry, + Stash, Stashes, Remote, Remotes, Blame, BlameLine) + +__all__ = ['Repo', 'Git', 'callgit', 'get_top', 'Commit', 'Commits', 'Ref', 'Refs', 'Diff', 'DiffFile', + 'Status', 'StatusEntry', 'Stash', 'Stashes', 'Remote', 'Remotes', 'Blame', 'BlameLine'] diff --git a/nbs/00_core.ipynb b/nbs/00_core.ipynb index 48a926a..d9eb687 100644 --- a/nbs/00_core.ipynb +++ b/nbs/00_core.ipynb @@ -62,7 +62,7 @@ "source": [ "#| export\n", "def callgit(path, *args, uname=None, pre=None):\n", - " \"Run git in `path`, returning stripped stdout+stderr as a single `str`\"\n", + " \"Run git in `path`, non-interactively (editors suppressed), returning stripped stdout+stderr as a single `str`\"\n", " assert not (uname and pre), \"Pass `uname` or `pre`, not both\"\n", " if uname:\n", " warn(\"`uname` is deprecated; pass e.g `pre=['/usr/bin/sudo','-u',uname]` instead\", DeprecationWarning, stacklevel=2)\n", @@ -70,7 +70,8 @@ " fp = Path(path).expanduser().resolve()\n", " args = ['git', '-C', str(fp)] + list(args)\n", " if pre: args = [*pre, *args]\n", - " r = subprocess.run(args, capture_output=True, text=True, check=True)\n", + " r = subprocess.run(args, capture_output=True, text=True, check=True,\n", + " env=os.environ|dict(GIT_EDITOR='true', GIT_SEQUENCE_EDITOR='true'))\n", " return (r.stdout + r.stderr).strip()" ] }, @@ -79,7 +80,7 @@ "id": "84c05229", "metadata": {}, "source": [ - "`callgit` returns git's stdout and stderr combined as a single `str`, just as a terminal shows them. Call `.splitlines()` on the result when you need line-oriented output." + "`callgit` returns git's stdout and stderr combined as a single `str`, just as a terminal shows them. Call `.splitlines()` on the result when you need line-oriented output. git runs non-interactively: `GIT_EDITOR` and `GIT_SEQUENCE_EDITOR` are overridden with `true`, so commands that would open an editor (finalizing a commit message after a paused rebase, `rebase -i`) accept their prepared content instead of hanging on captured output." ] }, { @@ -93,7 +94,9 @@ " msg = callgit(td, 'init', '-b', 'main')\n", " assert 'Initialized' in msg\n", " test_eq(callgit(td, 'rev-parse', '--git-dir'), '.git')\n", - " assert '\\n' in callgit(td, 'status')" + " assert '\\n' in callgit(td, 'status')\n", + " for v in ('GIT_EDITOR','VISUAL','EDITOR'): os.environ.pop(v, None)\n", + " test_eq(callgit(td, 'var', 'GIT_EDITOR'), 'true')" ] }, { diff --git a/nbs/01_repo.ipynb b/nbs/01_repo.ipynb new file mode 100644 index 0000000..3fcd015 --- /dev/null +++ b/nbs/01_repo.ipynb @@ -0,0 +1,1410 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "657ff453", + "metadata": {}, + "source": [ + "# Repo\n", + "\n", + "> Commits, refs, diffs, and status as live Python objects\n", + "\n", + "This is the literate source for `fastgit.repo`. The base `Git` class runs any git command and hands back whatever git printed, which is all a one-off call needs. For real work we want the things git talks *about*, so this module gives each one a class: `Commit`, `Ref`, `Status`, `Diff`, and friends. Each displays the way a terminal would show it, collections get their own classes and reprs, and methods chain." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f2203619", + "metadata": {}, + "outputs": [], + "source": [ + "#| default_exp repo" + ] + }, + { + "cell_type": "markdown", + "id": "644c19ba", + "metadata": {}, + "source": [ + "## Setup" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7c57fcf7", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "from fastcore.utils import *\n", + "from fastgit.core import *\n", + "import re, subprocess\n", + "from datetime import datetime, timezone, timedelta" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bf2a41d1", + "metadata": {}, + "outputs": [], + "source": [ + "import tempfile, shutil\n", + "from fastcore.test import test_eq" + ] + }, + { + "cell_type": "markdown", + "id": "f44caf16", + "metadata": {}, + "source": [ + "We start with an empty subclass, and use `@patch` to add each piece next to its explanation and tests. Since `Repo` is a `Git`, every raw verb keeps working on it unchanged.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "aabe4562", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "class Repo(Git):\n", + " \"A `Git` handle whose common queries return live objects instead of strings\"" + ] + }, + { + "cell_type": "markdown", + "id": "c4fa8b88", + "metadata": {}, + "source": [ + "We need a repo to play with. `init` and `config` here are ordinary `Git` verb dispatch: any attribute becomes a git subcommand, and the answer comes back as a string.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e9b54a01", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "tmp = Path(tempfile.mkdtemp())\n", + "r = Repo(tmp)\n", + "r.init(b='main')\n", + "r.config('user.name', 'fastgit')\n", + "r.config('user.email', 'fastgit@example.com')\n", + "r.exists" + ] + }, + { + "cell_type": "markdown", + "id": "126d4678", + "metadata": {}, + "source": [ + "## Commits\n", + "\n", + "git will answer almost any question about a commit if you ask with a `--format` string, much as tmux answers `-F '#{...}'` queries. So we declare the fields we want once, in `_commit_f`. `_fmt` renders that spec as the `--format=` argument, and `_parse` types the reply back into one dict per record. The `%x1f`/`%x1e` separators are control characters, so they never collide with the values.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "17251ab6", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "_commit_f = dict(sha='%H', short='%h', parents='%P', author='%an', email='%ae', date='%aI', msg='%s')\n", + "\n", + "def _fmt(fields): return '%x1f'.join(fields.values())+'%x1e'\n", + "\n", + "def _conv(k, v):\n", + " if k=='parents': return v.split()\n", + " if k=='date': return datetime.fromisoformat(v)\n", + " return v\n", + "\n", + "def _parse(fields, s):\n", + " \"Parse `%x1f`/`%x1e`-delimited output into one dict per record\"\n", + " return [{k:_conv(k,v) for k,v in zip(fields, rec.strip('\\n').split('\\x1f'))}\n", + " for rec in (s or '').split('\\x1e') if rec.strip()]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9bc0c92f", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "datetime.datetime(2026, 7, 23, 10, 0, tzinfo=datetime.timezone.utc)" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "test_eq(_fmt(dict(sha='%H', msg='%s')), '%H%x1f%s%x1e')\n", + "rec = 'abc\\x1fa\\x1f\\x1fme\\x1fm@e\\x1f2026-07-23T10:00:00+00:00\\x1fhi\\x1e\\n'\n", + "p = _parse(_commit_f, rec*2)\n", + "test_eq(len(p), 2)\n", + "test_eq((p[0]['parents'], p[0]['msg']), ([], 'hi'))\n", + "p[0]['date']" + ] + }, + { + "cell_type": "markdown", + "id": "63adffff", + "metadata": {}, + "source": [ + "A commit is content-addressed, so unlike most live handles a `Commit` can never go stale. Its repr is the `--oneline` row you'd see in a terminal, and `Commits` shows a list of them as `git log --oneline` would.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "95961923", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "class GitObj(AttrDict):\n", + " \"Base for git handles: plain-text display, shown via `__repr__`\"\n", + " def _repr_markdown_(self): return None\n", + " def _repr_pretty_(self, p, cycle): p.text(repr(self))\n", + "\n", + "class Commit(GitObj):\n", + " \"A git commit: immutable, addressed by `sha`\"\n", + " def __repr__(self): return f'{self.short} {self.msg}'\n", + "\n", + "class Commits(L):\n", + " \"Commits shown as a `--oneline`-style log\"\n", + " def __repr__(self): return '\\n'.join(repr(o) for o in self)\n", + " def _repr_pretty_(self, p, cycle): p.text(repr(self))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6f3d7fdb", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "@patch\n", + "def log(self:Repo, *args, **kwargs):\n", + " \"`Commits` for `git log` `args`: revision ranges ('main..feat'), flags, paths via `__`\"\n", + " res = self('log', *args, format=_fmt(_commit_f), mute_errors=True, **kwargs)\n", + " return Commits(Commit(d, _g=self) for d in _parse(_commit_f, res))\n", + "\n", + "@patch\n", + "def at(self:Repo, rev):\n", + " \"The `Commit` for commit-ish `rev` (sha, ref name, 'HEAD~2', `Ref`, ...); None if unknown\"\n", + " return first(self.log(str(getattr(rev, 'sha', rev)), n=1))\n", + "\n", + "@patch(as_prop=True)\n", + "def head(self:Repo):\n", + " \"The `Commit` at HEAD\"\n", + " return self.at('HEAD')\n", + "\n", + "@patch(as_prop=True)\n", + "def parent(self:Commit):\n", + " \"First-parent `Commit`, or None for a root commit\"\n", + " return self._g.at(self.parents[0]) if self.parents else None" + ] + }, + { + "cell_type": "markdown", + "id": "64b5bebe", + "metadata": {}, + "source": [ + "Let's make some history to query. We use the raw `commit` verb for now; a friendlier `commit` arrives with the write ops later.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f0253606", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "d3f38e4 add b\n", + "08c95dc add a" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "(tmp/'a.txt').write_text('hello\\nworld\\n')\n", + "r.add('.')\n", + "r.commit(m='add a')\n", + "(tmp/'b.txt').write_text('data\\n')\n", + "r.add('.')\n", + "r.commit(m='add b')\n", + "r.log()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cb15e177", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "d3f38e4 add b" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "test_eq(r.log().attrgot('msg'), ['add b', 'add a'])\n", + "c1 = r.at('HEAD~1')\n", + "test_eq(c1.msg, 'add a')\n", + "test_eq(r.head.parent.sha, c1.sha)\n", + "assert c1.parent is None\n", + "test_eq(r.log('HEAD~1..')[0].msg, 'add b')\n", + "r.head" + ] + }, + { + "cell_type": "markdown", + "id": "544be676", + "metadata": {}, + "source": [ + "## Refs\n", + "\n", + "A branch is a pointer: a file under `refs/heads` holding one sha. Everything else you think of as \"the branch\" is ancestry walked from that sha, which `log` already handles. `for-each-ref` queries pointers the way `log --format` queries commits, so the parsing is shared, and a `Ref` reprs as the line `git branch -vv` would print, tracking summary included.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e017095b", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "_ref_f = dict(name='%(refname:short)', sha='%(objectname)', upstream='%(upstream:short)',\n", + " track='%(upstream:track)', cur='%(HEAD)', msg='%(subject)')\n", + "\n", + "class Ref(GitObj):\n", + " \"A git ref: a mutable named pointer to a commit\"\n", + " def __repr__(self):\n", + " up = f\" [{self.upstream}{': '+self.track.strip('[]') if self.track else ''}]\" if self.upstream else ''\n", + " return f\"{'*' if self.cur=='*' else ' '} {self.name} {self.sha[:7]}{up} {self.msg}\"\n", + " @property\n", + " def ahead(self): return int(m.group(1)) if (m:=re.search(r'ahead (\\d+)', self.track)) else 0\n", + " @property\n", + " def behind(self): return int(m.group(1)) if (m:=re.search(r'behind (\\d+)', self.track)) else 0\n", + " @property\n", + " def commit(self): return self._g.at(self.sha)\n", + "\n", + "class Refs(L):\n", + " \"Refs shown as `branch -vv`-style lines\"\n", + " def __repr__(self): return '\\n'.join(repr(o) for o in self)\n", + " def _repr_pretty_(self, p, cycle): p.text(repr(self))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ba48317b", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "@patch\n", + "def _refs(self:Repo, pat):\n", + " res = self('for-each-ref', pat, format='%1f'.join(_ref_f.values())+'%1e', mute_errors=True)\n", + " return Refs(Ref(d, _g=self) for d in _parse(_ref_f, res))\n", + "\n", + "@patch(as_prop=True)\n", + "def branches(self:Repo):\n", + " \"Local branches as `Refs`\"\n", + " return self._refs('refs/heads')\n", + "\n", + "@patch(as_prop=True)\n", + "def tags(self:Repo):\n", + " \"Tags as `Refs`\"\n", + " return self._refs('refs/tags')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7f087095", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + " feat d3f38e4 add b\n", + "* main d3f38e4 add b" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "r.branch('feat')\n", + "r.tag('v0.1')\n", + "r.branches" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "273e63e6", + "metadata": {}, + "outputs": [], + "source": [ + "test_eq(r.branches.attrgot('name').sorted(), ['feat', 'main'])\n", + "test_eq(first(o for o in r.branches if o.cur=='*').name, 'main')\n", + "test_eq(r.tags[0].commit.sha, r.head.sha)\n", + "test_eq(Ref(track='[ahead 2, behind 1]').ahead, 2)\n", + "test_eq(Ref(track='').behind, 0)" + ] + }, + { + "cell_type": "markdown", + "id": "db4778bf", + "metadata": {}, + "source": [ + "## Diffs\n", + "\n", + "Commits hold snapshots, so a patch is something git computes by comparing two trees. Since `b = a + patch`, we spell that computation `b - a`, and `__sub__` runs `git diff a b`. The result shows file rows like `--stat` does, and keeps the patch text itself one property away. Ranges in git's own syntax pass straight through `Repo.diff`, so `r.diff('a...b')` works as at the CLI.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "528e494d", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "class DiffFile(GitObj):\n", + " \"One changed file; `adds`/`dels` are None for binary files\"\n", + " def __repr__(self): return f\"{self.path} | \" + ('bin' if self.adds is None else f'+{self.adds} -{self.dels}')\n", + "\n", + "class Diff(L):\n", + " \"Changed files shown `--stat`-style; the full patch text is in `.patch`\"\n", + " def __repr__(self):\n", + " tot = f\"{len(self)} files changed, +{sum(o.adds or 0 for o in self)} -{sum(o.dels or 0 for o in self)}\"\n", + " return '\\n'.join([repr(o) for o in self] + [tot])\n", + " def _repr_pretty_(self, p, cycle): p.text(repr(self))\n", + " @property\n", + " def patch(self):\n", + " \"Full patch text, as `git diff` prints it\"\n", + " return self._g('diff', *self._args, **self._kw)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0c507c03", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "@patch\n", + "def diff(self:Repo, *args, **kwargs):\n", + " \"`Diff` for `git diff` `args`: commits, ranges ('a...b'), paths via `__`\"\n", + " res = self('diff', '--numstat', *args, mute_errors=True, **kwargs)\n", + " def _row(ln):\n", + " a,d,p = ln.split('\\t', 2)\n", + " return DiffFile(path=p, adds=None if a=='-' else int(a), dels=None if d=='-' else int(d))\n", + " df = Diff(_row(o) for o in (res or '').splitlines())\n", + " df._g,df._args,df._kw = self,args,kwargs\n", + " return df\n", + "\n", + "@patch\n", + "def __sub__(self:Commit, other):\n", + " \"`b - a` is the patch taking `a` to `b`, i.e. `git diff a b`\"\n", + " return self._g.diff(getattr(other, 'sha', str(other)), self.sha)\n", + "\n", + "@patch\n", + "def __sub__(self:Ref, other): return self.commit - other" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "700e62b9", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "a.txt | +1 -0\n", + "b.txt | +1 -0\n", + "2 files changed, +2 -0" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "(tmp/'a.txt').write_text('hello\\nthere\\nworld\\n')\n", + "r.add('.')\n", + "r.commit(m='tweak a')\n", + "d = r.head - c1\n", + "d" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e88b8765", + "metadata": {}, + "outputs": [], + "source": [ + "test_eq(len(d), 2)\n", + "test_eq((d[0].path, d[0].adds, d[0].dels), ('a.txt', 1, 0))\n", + "assert 'there' in d.patch\n", + "test_eq(len(r.diff(f'{c1.sha}...HEAD')), 2)" + ] + }, + { + "cell_type": "markdown", + "id": "12db101f", + "metadata": {}, + "source": [ + "## Status\n", + "\n", + "Status compares three things at once: HEAD's tree, the index, and the working directory, plus any files git isn't tracking at all. `--porcelain=v2` reports all of it in one stable format, so one entry class covers every case. The `xy` codes are v2's, where `.` marks the unchanged side: `.M` is modified but unstaged, `M.` staged, `??` untracked, `UU` conflicted. Unmerged entries also carry `stages`, the base/ours/theirs blob shas, which we'll need for conflict resolution shortly.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a2496168", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "class StatusEntry(GitObj):\n", + " \"One changed path; `xy` is the porcelain staged/unstaged code pair\"\n", + " def __repr__(self): return f'{self.xy} {self.path}'\n", + "\n", + "\n", + "def _st_entry(ln):\n", + " t,rest = ln[0],ln[2:]\n", + " if t in '?!': return StatusEntry(xy=t*2, path=rest)\n", + " p = rest.split(' ')\n", + " if t=='1': return StatusEntry(xy=p[0], path=' '.join(p[7:]))\n", + " if t=='2':\n", + " path,orig = ' '.join(p[8:]).split('\\t')\n", + " return StatusEntry(xy=p[0], path=path, orig=orig)\n", + " if t=='u': return StatusEntry(xy=p[0], path=' '.join(p[9:]), stages=p[6:9])\n", + "\n", + "class Status(L):\n", + " \"Working-tree state shown `status -sb`-style: branch line, entries, in-progress op\"\n", + " branch=upstream=oid=op=None\n", + " ahead=behind = 0\n", + " def __repr__(self):\n", + " ab = f' [ahead {self.ahead}, behind {self.behind}]' if self.ahead or self.behind else ''\n", + " up = f'...{self.upstream}' if self.upstream else ''\n", + " res = [f'## {self.branch}{up}{ab}'] + [repr(o) for o in self]\n", + " if self.op: res.append(f'# {self.op} in progress')\n", + " return '\\n'.join(res)\n", + " def _repr_pretty_(self, p, cycle): p.text(repr(self))\n", + " @property\n", + " def clean(self): return not len(self)\n", + " @property\n", + " def conflicts(self):\n", + " \"Unmerged entries only\"\n", + " return L(o for o in self if o.xy=='UU')" + ] + }, + { + "cell_type": "markdown", + "id": "ea45457b", + "metadata": {}, + "source": [ + "Run `git status` in a terminal mid-merge and it says so; the paused operation is part of what status means. We read the same state the porcelain does, from the marker files and rebase directories inside `.git`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c04a8dfd", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "_op_f = dict(MERGE_HEAD='merge', CHERRY_PICK_HEAD='cherry-pick', REVERT_HEAD='revert', BISECT_LOG='bisect')\n", + "\n", + "@patch(as_prop=True)\n", + "def status(self:Repo):\n", + " \"Current `Status`: branch info, changed/untracked/unmerged entries, any in-progress op\"\n", + " info,st = {},Status()\n", + " for ln in (self('status', '--porcelain=v2', '--branch', mute_errors=True) or '').splitlines():\n", + " if ln.startswith('# branch.'):\n", + " k,_,v = ln[9:].partition(' ')\n", + " info[k] = v\n", + " elif ln: st.append(_st_entry(ln))\n", + " st.branch,st.upstream,st.oid = info.get('head'),info.get('upstream'),info.get('oid')\n", + " ab = re.findall(r'\\d+', info.get('ab', ''))\n", + " st.ahead,st.behind = map(int, ab) if ab else (0, 0)\n", + " gd = Path(self('rev-parse', '--git-dir', mute_errors=True) or '.git')\n", + " if not gd.is_absolute(): gd = self.d/gd\n", + " st.op = first(v for k,v in _op_f.items() if (gd/k).exists())\n", + " rb = first(p for o in ('rebase-merge','rebase-apply') if (p:=gd/o).exists())\n", + " if not st.op and rb:\n", + " nm = (rb/'head-name').read_text().strip().removeprefix('refs/heads/') if (rb/'head-name').exists() else ''\n", + " st.op = f'rebase of {nm}' if nm else 'rebase'\n", + " return st" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5e915071", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "## main\n", + ".M a.txt\n", + "?? new.txt" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "(tmp/'a.txt').write_text('hello\\nthere\\nworld!\\n')\n", + "(tmp/'new.txt').write_text('untracked\\n')\n", + "st = r.status\n", + "st" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "36d56aee", + "metadata": {}, + "outputs": [], + "source": [ + "test_eq(sorted(o.xy for o in st), ['.M', '??'])\n", + "assert not st.clean\n", + "r.add('-A')\n", + "r.commit(m='more')\n", + "assert r.status.clean" + ] + }, + { + "cell_type": "markdown", + "id": "39e0974f", + "metadata": {}, + "source": [ + "## Write ops\n", + "\n", + "An op that changes the working tree can pause halfway: a merge or rebase that hits a conflict stops and waits for a resolution, and git considers that normal. So nothing here raises on conflict. `merge`, `rebase`, `pull`, and `stash` always return the resulting `Status`, clean or paused, and reading it tells you which you got. `commit` can't pause, so it returns the new head `Commit` instead.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e3c633c9", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "@patch\n", + "def commit(self:Repo, msg=None, *args, **kwargs):\n", + " \"Commit staged changes, returning the new head `Commit`\"\n", + " if msg: kwargs['m'] = msg\n", + " self('commit', *args, **kwargs)\n", + " return self.head\n", + "\n", + "@patch\n", + "def merge(self:Repo, *args, **kwargs):\n", + " \"Merge, returning the resulting `Status`: a conflict is a state, not an error\"\n", + " self('merge', *args, mute_errors=True, **kwargs)\n", + " return self.status\n", + "\n", + "@patch\n", + "def rebase(self:Repo, *args, **kwargs):\n", + " \"Rebase, returning the resulting `Status`\"\n", + " self('rebase', *args, mute_errors=True, **kwargs)\n", + " return self.status\n", + "\n", + "@patch\n", + "def pull(self:Repo, *args, **kwargs):\n", + " \"Pull, returning the resulting `Status`\"\n", + " self('pull', *args, mute_errors=True, **kwargs)\n", + " return self.status" + ] + }, + { + "cell_type": "markdown", + "id": "e1ce3eaf", + "metadata": {}, + "source": [ + "`feat` still points where `main` was two commits ago, and both lines have since edited the same region of `a.txt`, so merging it conflicts. The returned status shows the unmerged path and the paused merge:\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cbd19a9a", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "## main\n", + "UU a.txt\n", + "# merge in progress" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "r.switch('feat')\n", + "(tmp/'a.txt').write_text('hello\\nfeat\\nworld\\n')\n", + "r.add('.')\n", + "r.commit('feat change')\n", + "r.switch('main')\n", + "st = r.merge('feat')\n", + "st" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "72b98a1c", + "metadata": {}, + "outputs": [], + "source": [ + "test_eq(st.op, 'merge')\n", + "assert not st.clean\n", + "test_eq(st[0].xy, 'UU')\n", + "test_eq(len(st[0].stages), 3)\n", + "test_eq(st.conflicts, [st[0]])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11cf4693", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "@patch\n", + "def cat(self:Repo, spec):\n", + " \"Exact file content at `spec` ('rev:path', or ':1:'/':2:'/':3:' + path for conflict stages); no stripping, unlike raw verbs\"\n", + " args = [*(self.pre or []), 'git', '-C', str(self.d), 'show', str(spec)]\n", + " return subprocess.run(args, capture_output=True, text=True, check=True).stdout" + ] + }, + { + "cell_type": "markdown", + "id": "b2c7df6e", + "metadata": {}, + "source": [ + "To resolve, we need each side's exact content. During a conflict git exposes the three versions as `:1:path` (base), `:2:path` (ours), and `:3:path` (theirs), and `cat` reads them byte-for-byte. `show` would almost work, but `callgit` strips trailing whitespace from every raw verb's reply, which is right for a terminal and wrong for a file: it would eat the final newline. With `st.conflicts` narrowing to the unmerged entries, resolution is ordinary git: write the file, `add` it, `commit`. The two parents on the new head prove the merge concluded.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fcd33f55", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "66b7277 merge feat" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "test_eq(r.cat(':2:a.txt'), 'hello\\nthere\\nworld!\\n')\n", + "test_eq(r.cat(':3:a.txt'), 'hello\\nfeat\\nworld\\n')\n", + "(tmp/'a.txt').write_text('hello\\nthere\\nfeat\\nworld!\\n')\n", + "r.add('a.txt')\n", + "mc = r.commit('merge feat')\n", + "test_eq(len(mc.parents), 2)\n", + "assert r.status.clean\n", + "mc" + ] + }, + { + "cell_type": "markdown", + "id": "1c669e50", + "metadata": {}, + "source": [ + "`rebase` keeps the same contract. `feat` itself is already merged, so rebasing it would fast-forward; instead we cut a branch from before the merge, edit the same region again, and replay it onto `main`. Mid-rebase, `op` names the branch being replayed, and `--abort` backs out cleanly.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2eef9661", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "## (detached)\n", + "UU a.txt\n", + "# rebase of topic in progress" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "r.switch('-c', 'topic', 'HEAD~1')\n", + "(tmp/'a.txt').write_text('hello\\nTOPIC\\nworld!\\n')\n", + "r.add('.')\n", + "r.commit('topic change')\n", + "st = r.rebase('main')\n", + "st" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e21e5003", + "metadata": {}, + "outputs": [], + "source": [ + "test_eq(st.op, 'rebase of topic')\n", + "assert not st.clean\n", + "assert r.rebase('--abort').clean\n", + "test_eq(r.current_branch, 'topic')\n", + "r.switch('main')\n", + "assert r.status.clean" + ] + }, + { + "cell_type": "markdown", + "id": "9d1e9497", + "metadata": {}, + "source": [ + "## Blame\n", + "\n", + "`blame` answers \"which commit last touched each line?\". `--line-porcelain` is the stable form, one metadata block per line, and each row keeps its commit's sha, so the full `Commit` is one hop away. The repr is the terminal's: sha, author, date, line number, content. The `-L` range forms become keyword arguments, built by `_lspec`: `lines=(start,end)`, `func='name'` (git's `:funcname` form), and `regex=` (a content pattern, or a `(start, end)` pair)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "48fa0ed1", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "def _lspec(func=None, lines=None, regex=None):\n", + " \"A `git -L` range spec from whichever of `func`, `lines`, or `regex` is given\"\n", + " if func: return f':{func}'\n", + " if lines: return f'{lines[0]},{lines[1]}'\n", + " if regex:\n", + " rs = [regex] if isinstance(regex, str) else regex\n", + " return ','.join('/'+o.replace('/', r'\\/')+'/' for o in rs)\n", + "\n", + "def _tz(s): return timezone(timedelta(minutes=(-1 if s[0]=='-' else 1)*(int(s[1:3])*60+int(s[3:5]))))\n", + "\n", + "class BlameLine(GitObj):\n", + " \"One blamed line; `sha`/`author`/`date`/`msg` describe the last commit to touch it\"\n", + " def __repr__(self): return f'{self.sha[:7]} ({self.author} {self.date:%Y-%m-%d %H:%M} {self.lineno:>3}) {self.line}'\n", + " @property\n", + " def commit(self): return self._g.at(self.sha)\n", + "\n", + "class Blame(L):\n", + " \"Blame lines shown as `git blame` shows them\"\n", + " def __repr__(self): return '\\n'.join(repr(o) for o in self)\n", + " def _repr_pretty_(self, p, cycle): p.text(repr(self))\n", + "\n", + "@patch\n", + "def blame(self:Repo, path, *args, func=None, lines=None, regex=None):\n", + " \"`Blame` for `path` (`args` may add a revision or flags); `func`/`lines`/`regex` pick a `-L` range\"\n", + " spec = _lspec(func, lines, regex)\n", + " cmd = [*(self.pre or []), 'git', '-C', str(self.d), 'blame', '--line-porcelain',\n", + " *(['-L', spec] if spec else []), *args, '--', str(path)]\n", + " cur,res = {},Blame()\n", + " for ln in subprocess.run(cmd, capture_output=True, text=True, check=True).stdout.splitlines():\n", + " if ln.startswith('\\t'): res.append(BlameLine(cur, line=ln[1:], _g=self))\n", + " else:\n", + " k,_,v = ln.partition(' ')\n", + " if re.fullmatch(r'[0-9a-f]{40}', k): cur = dict(sha=k, lineno=int(v.split()[1]))\n", + " elif k=='author': cur['author'] = v\n", + " elif k=='author-time': cur['ts'] = int(v)\n", + " elif k=='author-tz': cur['date'] = datetime.fromtimestamp(cur.pop('ts'), _tz(v))\n", + " elif k=='summary': cur['msg'] = v\n", + " return res" + ] + }, + { + "cell_type": "markdown", + "id": "a143697a", + "metadata": {}, + "source": [ + "Our `a.txt` is four lines from four different commits, one of them merged in from another branch, and blame sees straight through the merge:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "71aee785", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "08c95dc (fastgit 2026-07-24 12:11 1) hello\n", + "94fbe56 (fastgit 2026-07-24 12:11 2) there\n", + "b5c9ad8 (fastgit 2026-07-24 12:11 3) feat\n", + "d8f2453 (fastgit 2026-07-24 12:11 4) world!" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "r.blame('a.txt')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6895f60c", + "metadata": {}, + "outputs": [], + "source": [ + "test_eq(r.blame('a.txt').attrgot('line'), ['hello', 'there', 'feat', 'world!'])\n", + "test_eq(r.blame('a.txt')[2].commit.msg, 'feat change')\n", + "test_eq(len({o.sha for o in r.blame('a.txt')}), 4)" + ] + }, + { + "cell_type": "markdown", + "id": "3df915e8", + "metadata": {}, + "source": [ + "`func=` finds a definition by name. git matches the name against *funcname lines*, and its default pattern only recognizes definitions at column 0, which covers top-level `def`s and `@patch` methods; to find indented methods by name too, add `*.py diff=python` to the repo's `.gitattributes` and git's Python-aware pattern takes over. `regex=` needs no configuration at all, since it matches line content directly:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "03f66aab", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "08f79e7 (fastgit 2026-07-24 12:11 1) def c2f(c):\n", + "e1c1834 (fastgit 2026-07-24 12:11 2) \"Celsius to Fahrenheit\"\n", + "08f79e7 (fastgit 2026-07-24 12:11 3) return c*9/5+32\n", + "08f79e7 (fastgit 2026-07-24 12:11 4) " + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "(tmp/'weather.py').write_text('def c2f(c):\\n return c*9/5+32\\n\\ndef f2c(f):\\n return (f-32)*5/9\\n')\n", + "r.add('.')\n", + "r.commit('add conversions')\n", + "(tmp/'weather.py').write_text('def c2f(c):\\n \"Celsius to Fahrenheit\"\\n return c*9/5+32\\n\\ndef f2c(f):\\n return (f-32)*5/9\\n')\n", + "r.add('.')\n", + "r.commit('document c2f')\n", + "r.blame('weather.py', func='c2f')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "de1ce952", + "metadata": {}, + "outputs": [], + "source": [ + "b = r.blame('weather.py', func='c2f')\n", + "test_eq(b[1].commit.msg, 'document c2f')\n", + "test_eq(len(r.blame('weather.py', lines=(1,1))), 1)\n", + "test_eq(r.blame('weather.py', regex='def f2c')[0].line, 'def f2c(f):')" + ] + }, + { + "cell_type": "markdown", + "id": "6f878829", + "metadata": {}, + "source": [ + "Blame tells you who last touched each line; `trace` tells the whole story. `git log -L` replays every commit that changed a range, patch by patch. The range keywords are the same, and the result is ordinary `Commits`, each carrying the `.patch` that changed the range:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0a84bdeb", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "@patch\n", + "def trace(self:Repo, path, *args, func=None, lines=None, regex=None, **kwargs):\n", + " \"Trace the evolution of a range of `path` (`git log -L`): `Commits` newest first, each with its `.patch`\"\n", + " spec = _lspec(func, lines, regex)\n", + " if not spec: raise TypeError('trace needs one of func, lines, or regex')\n", + " res = self('log', f'-L{spec}:{path}', *args, format='%x1e'+'%x1f'.join(_commit_f.values()), mute_errors=True, **kwargs)\n", + " out = Commits()\n", + " for rec in (res or '').split('\\x1e'):\n", + " if not rec.strip(): continue\n", + " head,_,patch = rec.partition('\\n')\n", + " out.append(Commit({k:_conv(k,v) for k,v in zip(_commit_f, head.split('\\x1f'))}, _g=self, patch=patch.strip('\\n')))\n", + " return out" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b4874ecb", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "e1c1834 document c2f\n", + "08f79e7 add conversions" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "t = r.trace('weather.py', func='c2f')\n", + "t" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9a6400c5", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "diff --git a/weather.py b/weather.py\n", + "index 7431045..9cbb8e3 100644\n", + "--- a/weather.py\n", + "+++ b/weather.py\n", + "@@ -1,3 +1,4 @@\n", + " def c2f(c):\n", + "+ \"Celsius to Fahrenheit\"\n", + " return c*9/5+32\n", + " \n" + ] + } + ], + "source": [ + "test_eq(t.attrgot('msg'), ['document c2f', 'add conversions'])\n", + "assert 'Celsius' in t[0].patch\n", + "print(t[0].patch)" + ] + }, + { + "cell_type": "markdown", + "id": "a8c7bca7", + "metadata": {}, + "source": [ + "## Remotes\n", + "\n", + "None of this has needed a network, and the remote ops don't either: a bare repo in another temp dir is a perfectly good `origin`. `Remote` itself is a small noun, shown the way `git remote -v` lists it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "80c49ba3", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "class Remote(GitObj):\n", + " \"A configured remote\"\n", + " def __repr__(self): return f'{self.name}\\t{self.url}'\n", + "\n", + "class Remotes(L):\n", + " \"Remotes shown as `git remote -v`-style lines\"\n", + " def __repr__(self): return '\\n'.join(repr(o) for o in self)\n", + " def _repr_pretty_(self, p, cycle): p.text(repr(self))\n", + "\n", + "@patch(as_prop=True)\n", + "def remotes(self:Repo):\n", + " \"Configured remotes as `Remotes`\"\n", + " res = {}\n", + " for ln in (self('remote', '-v', mute_errors=True) or '').splitlines():\n", + " nm,rest = ln.split('\\t')\n", + " res.setdefault(nm, Remote(name=nm, url=rest.split()[0], _g=self))\n", + " return Remotes(res.values())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "94c29943", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "origin\t/tmp/tmp73enby1b/origin.git" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "bare = Path(tempfile.mkdtemp())/'origin.git'\n", + "Git(bare.parent)('init', '--bare', '-b', 'main', bare.name)\n", + "r.remote('add', 'origin', str(bare))\n", + "r.remotes" + ] + }, + { + "cell_type": "markdown", + "id": "63255ece", + "metadata": {}, + "source": [ + "`push -u` publishes the branch and records its upstream. Rich `push` returns the current branch's refreshed `Ref`, so the new tracking bracket appears right in the output; `fetch` returns the refreshed `branches` for the same reason, since what it moves is the tracking refs." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c19ceaa8", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "@patch\n", + "def fetch(self:Repo, *args, **kwargs):\n", + " \"Fetch, returning the local `branches` with refreshed tracking info\"\n", + " self('fetch', *args, **kwargs)\n", + " return self.branches\n", + "\n", + "@patch\n", + "def push(self:Repo, *args, **kwargs):\n", + " \"Push, returning the current branch's refreshed `Ref`\"\n", + " self('push', *args, **kwargs)\n", + " return first(o for o in self.branches if o.cur=='*')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bd1a312d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "* main e1c1834 [origin/main] document c2f" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "r.push('-u', 'origin', 'main')" + ] + }, + { + "cell_type": "markdown", + "id": "28b74af6", + "metadata": {}, + "source": [ + "`clone` must be a classmethod, since until it runs there is no repo to hold a handle on:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bc13efa5", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "@patch(cls_method=True)\n", + "def clone(cls:Repo, url, path=None, **kwargs):\n", + " \"Clone `url` into `path` (default: the repo's name in the cwd), returning a `Repo` on the checkout\"\n", + " if path is None: path = re.sub(r'\\.git$', '', str(url).rstrip('/').split('/')[-1])\n", + " path = Path(path)\n", + " Git(path.parent)('clone', str(url), path.name, **kwargs)\n", + " return cls(path)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b9c08908", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "e1c1834 document c2f\n", + "08f79e7 add conversions\n", + "66b7277 merge feat\n", + "d8f2453 more\n", + "b5c9ad8 feat change\n", + "94fbe56 tweak a\n", + "d3f38e4 add b\n", + "08c95dc add a" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "tmp2 = Path(tempfile.mkdtemp())/'copy'\n", + "r2 = Repo.clone(bare, tmp2)\n", + "r2.log()" + ] + }, + { + "cell_type": "markdown", + "id": "77fffa82", + "metadata": {}, + "source": [ + "A commit pushed from the clone leaves our first checkout behind. `fetch` shows the gap, and `pull` closes it:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bf8d65ec", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + " feat b5c9ad8 feat change\n", + "* main e1c1834 [origin/main: behind 1] document c2f\n", + " topic 8e64746 topic change" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "r2.config('user.name', 'fastgit')\n", + "r2.config('user.email', 'fastgit@example.com')\n", + "(tmp2/'c.txt').write_text('remote work\\n')\n", + "r2.add('.')\n", + "r2.commit('from the clone')\n", + "r2.push()\n", + "r.fetch()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8fc46a14", + "metadata": {}, + "outputs": [], + "source": [ + "test_eq(first(o for o in r.branches if o.cur=='*').behind, 1)\n", + "assert r.pull().clean\n", + "test_eq(r.head.msg, 'from the clone')\n", + "test_eq(r.remotes[0].name, 'origin')" + ] + }, + { + "cell_type": "markdown", + "id": "c80c8dcb", + "metadata": {}, + "source": [ + "## Stashes\n", + "\n", + "A stash entry is a real commit whose parents link the stashed worktree and index states; `stash@{n}` is a reflog address for it. So `Stash` subclasses `Commit`, adding the selector and the `stash list` line format.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6cc1ce2c", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "_stash_f = dict(_commit_f, sel='%gd')\n", + "\n", + "class Stash(Commit):\n", + " \"A stash entry: a real commit addressed `stash@{n}`\"\n", + " def __repr__(self): return f'{self.sel}: {self.msg}'\n", + "\n", + "class Stashes(L):\n", + " \"Stash entries shown as `stash list`-style lines\"\n", + " def __repr__(self): return '\\n'.join(repr(o) for o in self)\n", + " def _repr_pretty_(self, p, cycle): p.text(repr(self))\n", + "\n", + "@patch(as_prop=True)\n", + "def stashes(self:Repo):\n", + " \"Stash entries, newest first\"\n", + " res = self('stash', 'list', format=_fmt(_stash_f), mute_errors=True)\n", + " return Stashes(Stash(d, _g=self) for d in _parse(_stash_f, res))\n", + "\n", + "@patch\n", + "def stash(self:Repo, msg=None, **kwargs):\n", + " \"Stash worktree changes, returning the resulting `Status`\"\n", + " if msg: kwargs['m'] = msg\n", + " self('stash', 'push', mute_errors=True, **kwargs)\n", + " return self.status\n", + "\n", + "@patch\n", + "def pop(self:Stash, **kwargs):\n", + " \"Re-apply and drop this stash, returning the resulting `Status`\"\n", + " self._g('stash', 'pop', self.sel, mute_errors=True, **kwargs)\n", + " return self._g.status\n", + "\n", + "@patch\n", + "def drop(self:Stash, **kwargs):\n", + " \"Delete this stash, returning the resulting `Status`\"\n", + " self._g('stash', 'drop', self.sel, mute_errors=True, **kwargs)\n", + " return self._g.status" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4278703c", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "stash@{0}: On main: wip work" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "(tmp/'a.txt').write_text('wip\\n')\n", + "assert r.stash('wip work').clean\n", + "r.stashes" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1638479b", + "metadata": {}, + "outputs": [], + "source": [ + "st = r.stashes[0].pop()\n", + "test_eq(st[0].xy, '.M')\n", + "test_eq(r.stashes, [])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "183d6b3e", + "metadata": {}, + "outputs": [], + "source": [ + "#| hide\n", + "for p in (tmp, bare.parent, tmp2.parent): shutil.rmtree(p)\n" + ] + }, + { + "cell_type": "markdown", + "id": "dd3eeee7", + "metadata": {}, + "source": [ + "## Export -" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4dc84336", + "metadata": {}, + "outputs": [], + "source": [ + "#| hide\n", + "from nbdev import nbdev_export\n", + "nbdev_export()" + ] + } + ], + "metadata": { + "solveit": { + "default_code": false, + "mode": "learning", + "use_thinking": false, + "use_tools": true, + "ver": 2 + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/nbs/index.ipynb b/nbs/index.ipynb index b90a7ca..633e268 100644 --- a/nbs/index.ipynb +++ b/nbs/index.ipynb @@ -1,139 +1,426 @@ { "cells": [ + { + "cell_type": "markdown", + "id": "be7ba21c", + "metadata": {}, + "source": [ + "# fastgit\n", + "\n", + "> Use git from python, fast" + ] + }, + { + "cell_type": "markdown", + "id": "aaa6a154", + "metadata": {}, + "source": [ + "fastgit wraps the `git` CLI. The base `Git` class turns every git subcommand into a method call and returns whatever git printed, and its `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." + ] + }, + { + "cell_type": "markdown", + "id": "db326ad5", + "metadata": {}, + "source": [ + "> **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." + ] + }, + { + "cell_type": "markdown", + "id": "6f750c6e", + "metadata": {}, + "source": [ + "## Install" + ] + }, + { + "cell_type": "markdown", + "id": "7215c8b0", + "metadata": {}, + "source": [ + "```sh\n", + "pip install fastgit\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "98dd99bd", + "metadata": {}, + "source": [ + "## Getting started" + ] + }, { "cell_type": "code", "execution_count": null, - "id": "77126577", + "id": "656b6002", "metadata": {}, "outputs": [], "source": [ - "#| hide\n", - "from fastgit import *" + "from fastgit import *\n", + "from pathlib import Path\n", + "import tempfile, shutil" ] }, { "cell_type": "markdown", - "id": "fcf0f237", + "id": "da95d50f", "metadata": {}, "source": [ - "# fastgit\n", - "\n", - "> Use git from python, fast" + "Everything below happens in a temporary directory, so it's safe to run anywhere. `Repo` (like `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." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ee6abd8c", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'Initialized empty Git repository in /tmp/tmp111odbt6/.git/'" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "td = tempfile.mkdtemp()\n", + "r = Repo(td)\n", + "r.init(b='main')" ] }, { "cell_type": "markdown", - "id": "36904264", + "id": "ce4683f9", "metadata": {}, "source": [ - "## Usage" + "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." ] }, { "cell_type": "markdown", - "id": "54f027b5", + "id": "b25015f0", "metadata": {}, "source": [ - "### Installation" + "## Commits" ] }, { "cell_type": "markdown", - "id": "36f56792", + "id": "fd95351e", "metadata": {}, "source": [ - "Install latest from [pypi][pypi]\n", - "\n", - "```sh\n", - "$ pip install fastgit\n", - "```\n", - "\n", - "[pypi]: https://pypi.org/project/fastgit/" + "git stores snapshots: a commit points at a complete tree, and log, diff, and status are views computed from those snapshots. On `Repo`, `commit` returns the new head `Commit`, whose repr is its `--oneline` row. (Committing needs an identity, hence the `config` calls.)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e94bc651", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "d05bd08 start the list" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "r.config('user.name', 'fastgit')\n", + "r.config('user.email', 'fastgit@example.com')\n", + "(r.d/'shop.txt').write_text('bread\\nmilk\\n')\n", + "r.add('.')\n", + "r.commit('start the list')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "46707126", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "ba8bf40 need eggs\n", + "d05bd08 start the list" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "(r.d/'shop.txt').write_text('bread\\nmilk\\neggs\\n')\n", + "r.add('.')\n", + "c = r.commit('need eggs')\n", + "r.log()" ] }, { "cell_type": "markdown", - "id": "f8b2af25", + "id": "d65cb577", + "metadata": {}, + "source": [ + "`log` takes git's own range syntax and flags (`r.log('main..feat')`, `n=10`). `at` resolves any rev to a single `Commit`, and `c.parent` walks up the graph:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "317576d6", "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "d05bd08 start the list" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ - "### How to use" + "r.at('HEAD~1')" ] }, { "cell_type": "markdown", - "id": "52420745", + "id": "81e1c30e", "metadata": {}, "source": [ - "In this example we run `git init` on a directory, add a *.gitignore*, and commit it." + "## Diffs" + ] + }, + { + "cell_type": "markdown", + "id": "f794f2a1", + "metadata": {}, + "source": [ + "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`, file rows shown `--stat`-style, with the full text one property away:" ] }, { "cell_type": "code", "execution_count": null, - "id": "1c2738c4", + "id": "5247fde9", "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "text/plain": [ + "shop.txt | +1 -0\n", + "1 files changed, +1 -0" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ - "import shutil, tempfile" + "c - c.parent" ] }, { "cell_type": "code", "execution_count": null, - "id": "bc6b01dd", + "id": "faf4666e", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "diff --git a/shop.txt b/shop.txt\n", + "index 26d3bde..5c4c692 100644\n", + "--- a/shop.txt\n", + "+++ b/shop.txt\n", + "@@ -1,2 +1,3 @@\n", + " bread\n", + " milk\n", + "+eggs\n" + ] + } + ], + "source": [ + "print((c - c.parent).patch)" + ] + }, + { + "cell_type": "markdown", + "id": "b4d5d48f", + "metadata": {}, + "source": [ + "## Status" + ] + }, + { + "cell_type": "markdown", + "id": "e24851e1", + "metadata": {}, + "source": [ + "`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:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "835f4c88", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "## main\n", + ".M shop.txt\n", + "?? notes.txt" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ - "def _git_init(g):\n", - " if g.exists: return # Return early if git already initialised\n", - " g.init(b='main')\n", - " g.config('user.name', 'fastgit')\n", - " g.config('user.email', 'fastgit@example.com')\n", - " (g.d/\".gitignore\").mk_write(\"*.bak\")\n", - " g.add(\".gitignore\")\n", - " g.commit(m=\"add .gitignore\")" + "(r.d/'shop.txt').write_text('bread\\nmilk\\neggs\\njam\\n')\n", + "(r.d/'notes.txt').write_text('todo\\n')\n", + "r.status" ] }, { "cell_type": "code", "execution_count": null, - "id": "451c7aa8", + "id": "a69d4dc6", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "r.add('-A')\n", + "r.commit('add jam and notes')\n", + "r.status.clean" + ] + }, + { + "cell_type": "markdown", + "id": "46a416ad", + "metadata": {}, + "source": [ + "## Merges: a conflict is a status, not an error" + ] + }, + { + "cell_type": "markdown", + "id": "adcbd48e", + "metadata": {}, + "source": [ + "Every op that changes the working tree (`merge`, `rebase`, `pull`, `stash`) returns the resulting `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:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b9597c65", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "## main\n", + "UU shop.txt\n", + "# merge in progress" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "r.switch('-c', 'feat')\n", + "(r.d/'shop.txt').write_text('bread\\nmilk\\neggs\\njam\\nbutter\\n')\n", + "r.add('.')\n", + "r.commit('feat: butter')\n", + "r.switch('main')\n", + "(r.d/'shop.txt').write_text('bread\\nmilk\\neggs\\njam\\ncheese\\n')\n", + "r.add('.')\n", + "r.commit('main: cheese')\n", + "r.merge('feat')" + ] + }, + { + "cell_type": "markdown", + "id": "a3646af3", + "metadata": {}, + "source": [ + "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:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1ec94e03", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "main\n" + "bread\n", + "milk\n", + "eggs\n", + "jam\n", + "butter\n", + "\n" ] } ], "source": [ - "td = tempfile.mkdtemp()\n", - "g = Git(td)\n", - "_git_init(g)\n", - "assert 'add .gitignore' in g.last_commit\n", - "print(g.branch('--show-current'))" + "print(r.cat(':3:shop.txt'))" ] }, { "cell_type": "markdown", - "id": "306619e8", + "id": "6f594d4a", "metadata": {}, "source": [ - "You can also pass path arguments after `--` using the `__` parameter:" + "Resolution is ordinary git: write the file, `add`, `commit`. The two parents on the new head are the proof the merge concluded:" ] }, { "cell_type": "code", "execution_count": null, - "id": "32a5a2af", + "id": "db9a8823", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "'22a9a5d add .gitignore'" + "2" ] }, "execution_count": null, @@ -142,21 +429,365 @@ } ], "source": [ - "g.log('--oneline', __=['.gitignore'])" + "(r.d/'shop.txt').write_text('bread\\nmilk\\neggs\\njam\\nbutter\\ncheese\\n')\n", + "r.add('.')\n", + "mc = r.commit('merge feat')\n", + "len(mc.parents)" + ] + }, + { + "cell_type": "markdown", + "id": "3339ad7a", + "metadata": {}, + "source": [ + "## Blame and trace" + ] + }, + { + "cell_type": "markdown", + "id": "61c8d4a0", + "metadata": {}, + "source": [ + "`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:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0cab7d73", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "d05bd08 (fastgit 2026-07-24 13:15 1) bread\n", + "d05bd08 (fastgit 2026-07-24 13:15 2) milk\n", + "ba8bf40 (fastgit 2026-07-24 13:15 3) eggs\n", + "df130c1 (fastgit 2026-07-24 13:15 4) jam\n", + "163d8a7 (fastgit 2026-07-24 13:15 5) butter\n", + "77975d6 (fastgit 2026-07-24 13:15 6) cheese" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "r.blame('shop.txt')" + ] + }, + { + "cell_type": "markdown", + "id": "06e81c32", + "metadata": {}, + "source": [ + "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`, each carrying the `.patch` that changed it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11780a03", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "5d40b66 (fastgit 2026-07-24 13:15 1) def total(xs):\n", + "0723d5c (fastgit 2026-07-24 13:15 2) return round(sum(xs), 2)" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "(r.d/'prices.py').write_text('def total(xs):\\n return sum(xs)\\n')\n", + "r.add('.')\n", + "r.commit('add total')\n", + "(r.d/'prices.py').write_text('def total(xs):\\n return round(sum(xs), 2)\\n')\n", + "r.add('.')\n", + "r.commit('round totals')\n", + "r.blame('prices.py', func='total')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bd49c9b5", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "0723d5c round totals\n", + "5d40b66 add total" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "t = r.trace('prices.py', func='total')\n", + "t" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2994d941", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "diff --git a/prices.py b/prices.py\n", + "index 8ce3223..bc210a6 100644\n", + "--- a/prices.py\n", + "+++ b/prices.py\n", + "@@ -1,2 +1,2 @@\n", + " def total(xs):\n", + "- return sum(xs)\n", + "+ return round(sum(xs), 2)\n" + ] + } + ], + "source": [ + "print(t[0].patch)" + ] + }, + { + "cell_type": "markdown", + "id": "a529444b", + "metadata": {}, + "source": [ + "## Remotes" + ] + }, + { + "cell_type": "markdown", + "id": "e078ad33", + "metadata": {}, + "source": [ + "A bare directory is a perfectly good remote, so none of this needs a network. `push` returns the current branch's refreshed `Ref`, and after `push -u` its repr carries the tracking bracket, the same confirmation you'd look for in a terminal:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c54a080b", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "* main 0723d5c [origin/main] round totals" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "bare = Path(tempfile.mkdtemp())/'origin.git'\n", + "Git(bare.parent)('init', '--bare', '-b', 'main', bare.name)\n", + "r.remote('add', 'origin', str(bare))\n", + "r.push('-u', 'origin', 'main')" + ] + }, + { + "cell_type": "markdown", + "id": "61cbf239", + "metadata": {}, + "source": [ + "`Repo.clone` is a classmethod, since until it runs there's no repo to hold a handle on:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c245d457", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "0723d5c round totals\n", + "5d40b66 add total\n", + "b64a211 merge feat" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "r2 = Repo.clone(bare, Path(tempfile.mkdtemp())/'copy')\n", + "r2.log(n=3)" + ] + }, + { + "cell_type": "markdown", + "id": "2690221d", + "metadata": {}, + "source": [ + "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:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f1f34380", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + " feat 163d8a7 feat: butter\n", + "* main 0723d5c [origin/main: behind 1] round totals" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "r2.config('user.name', 'fastgit')\n", + "r2.config('user.email', 'fastgit@example.com')\n", + "(r2.d/'shop.txt').write_text('bread\\n')\n", + "r2.add('.')\n", + "r2.commit('simplify radically')\n", + "r2.push()\n", + "r.fetch()" ] }, { "cell_type": "code", "execution_count": null, - "id": "aff78522", + "id": "cfd96fff", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "r.pull().clean" + ] + }, + { + "cell_type": "markdown", + "id": "917fd922", + "metadata": {}, + "source": [ + "## Stashes" + ] + }, + { + "cell_type": "markdown", + "id": "362d2759", + "metadata": {}, + "source": [ + "A stash entry is a real commit under the hood, addressed `stash@{n}`. `stash` returns the now-clean `Status`, and `pop` returns the status with your changes back:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bdaa59a9", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "stash@{0}: On main: scribble" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "(r.d/'notes.txt').write_text('urgent scribble\\n')\n", + "r.stash('scribble')\n", + "r.stashes" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8b4b0d6d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "## main...origin/main\n", + ".M notes.txt" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "r.stashes[0].pop()" + ] + }, + { + "cell_type": "markdown", + "id": "ea45cce0", + "metadata": {}, + "source": [ + "## Learn more" + ] + }, + { + "cell_type": "markdown", + "id": "6bfca28a", + "metadata": {}, + "source": [ + "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." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4a37cfab", "metadata": {}, "outputs": [], "source": [ - "shutil.rmtree(td)" + "#| hide\n", + "for p in (Path(td), bare.parent, r2.d.parent): shutil.rmtree(p)" ] } ], - "metadata": {}, + "metadata": { + "solveit": { + "default_code": true, + "mode": "learning", + "use_thinking": true, + "use_tools": false, + "ver": 2 + } + }, "nbformat": 4, "nbformat_minor": 5 } diff --git a/pyproject.toml b/pyproject.toml index 55b8873..ab2a7e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,9 @@ Documentation = "https://AnswerDotAI.github.io/fastgit" [project.entry-points.nbdev] fastgit = "fastgit._modidx:d" +[project.entry-points.pyskills] +fastgit = "fastgit.skill" + [tool.setuptools.dynamic] version = {attr = "fastgit.__version__"}