./steinsgit.py /path/to/repo --days 90 --port 8787 ./steinsgit.py /path/to/repo -v # log every request ./steinsgit.py /path/to/repo --no-cache # ignore saved data
There is nothing to install and nothing to build. Python 3.9 and git are
enough. The claude CLI is only needed for the analysis buttons.
The same command exists three ways, all of them calling
steinsgit/cli.py: the ./steinsgit.py script for working
in a checkout, python -m steinsgit, and the installed
steinsgit console script that pip, uv tool
install and uvx create from pyproject.toml.
Nothing else in the tree is an entry point.
uvx git+https://github.com/ameroyer/steinsgit /path/to/repo # no checkout, no install uv build # wheel + sdist into dist/ uv cache clean steinsgit # before reinstalling one version twice
That last line is not optional when you are testing the packaged build:
installing a rebuilt wheel whose version has not changed serves the previous
one out of uv's cache, --refresh included, and you will be reading
the behaviour of the code you replaced. Installing from a git URL is keyed on
the commit and does not have that problem.
steinsgit/web/, so that a wheel carries them. server.py
and export.py both resolve them relative to the package directory;
anything new they need has to go in there too, or it will work from a checkout
and 404 once installed.A Python process reads git, holds the result in memory, and serves a single page. The page draws everything on one canvas and talks back over a small JSON API. No framework on either side.
git -> gitdata.py -> layout.py -> session.py -> routes.py
| | |
divergence.py store.py server.py
|
browser
meter / render / core / claude /
panels / dialogs / app
Three rules hold the design together:
worktree.py, and only when a button is pressed.GET / index.html, then 7 scripts in order GET /api/state the whole graph in one response
Session.snapshot() does the work:
Divergence needs four git calls per branch, so they run on a thread pool. Those threads wait on child processes, so the pool is sized past the core count.
| FILE | DOES |
|---|---|
| cli.py | Argument parsing, startup banner, and the console script. |
| gitdata.py | Runs git and parses it. One Repo class. Never writes. |
| layout.py | Turns commits into columns and vertical positions. |
| divergence.py | The six measurements, the bands, and per-commit change size. |
| session.py | One repository held in memory. Owns settings and the cache. |
| store.py | SQLite key-value store with size caps and migrations. |
| server.py | Sockets, headers, events, static files, origin checks. |
| routes.py | Every API handler. Mixed into the request handler. |
| oracle.py | Starts claude as a subprocess and translates its output. |
| worktree.py | The only code that writes. Makes branches and worktrees. |
| forge.py | Turns a remote URL into GitHub, GitLab or Bitbucket links. |
| export.py | Builds one HTML file with everything inlined. |
| core.js | The SG namespace: state, elements, helpers. |
| claude.js | Streaming calls, token counting, the output pane. |
| panels.js | The right-hand inspector. |
| dialogs.js | Modals: help, settings, rescan, merge, export, first run. |
| app.js | Boot, load, selection, canvas callbacks. |
| render.js | The canvas. Self-contained, no dependency on SG. |
| meter.js | The nixie tubes. |
The browser files share one object. core.js creates it, each file
adds to it, and cross-file calls go through SG.. Load order is fixed
in index.html.
layout.py runs three passes.
Walk each branch down its first parents and claim commits nobody has claimed. The default branch goes first, so a feature branch can never take over the trunk. Anything left, reachable only through a merge's second parent, is claimed in a second sweep.
Sort by time, then space commits out by the gap between them, compressed with a log so a busy afternoon stays readable and a quiet month does not become a desert. Then run a topological pass so a child is never below its own parent, which a rebase or a wrong clock would otherwise cause.
World Y is negative going up. The oldest commit sits at y = 0 and
the renderer maps world Y straight to screen Y with no flipping.
Branches are packed into columns in divergence order: the canvas places a column by how far its branches have diverged, so the occupants of one column have to be alike, or one outlier drags every quiet branch sharing it out to the edge. Within a band, a branch prefers the free column nearest the branch it forked from, and two branches share a column only if their time ranges do not touch. Padding above a tip is much larger than below it, because the name plate hangs there.
One SQLite file at .steinsgit/knowledge.db, one table, keyed by
kind and key.
| KIND | KEY | HOLDS |
|---|---|---|
| snapshot | version + all ref tips + window | the whole graph |
| divergence | version + base SHA + head SHA | one reading |
| mergetree | version + both tip SHAs + engine | a merge test |
| commit | version + commit SHA | commit detail |
| explain | commit: its SHA. branch: version + name + tip SHA | a one-line summary |
| oracle | version + model + main SHA + tip SHA (or both tips for a merge) | a full analysis |
| worldline | branch name | what a merge test was made from |
| meta | fixed | settings, running spend total |
Because keys are SHAs there is no expiry and no invalidation code. If a branch moves, the key changes. Commit and branch summaries have separate keys on purpose: nearly every branch tip is also a commit, and filing both under the SHA made the two overwrite each other.
oracle.py runs the CLI as a subprocess and converts its
stream-json output into small events the browser understands.
run() free text, streamed: branch and merge analysis explain_stream() structured output against a JSON schema, streamed
Events reach the browser as server-sent events:
status connected, model name tool a tool call, with a short argument usage running token count while work is happening text a fragment of the answer merge the deterministic merge result, sent before the model replies result final usage, cost, duration done stream closed
Two details worth keeping:
git merge-tree --write-tree runs
first and its result is sent immediately, so the panel is useful even if
the model call fails.A run has three steps - explain commits, describe branches, review branches -
picked separately because a review is one call per branch on the expensive
model and is nearly all of the cost. Two endpoints keep the page honest:
/api/plan says what is already written, what is left and what it
should cost, priced from this repository's own past calls; and
/api/targets returns the exact lists a run would touch, so what
was priced and what runs cannot be two different lists. The run caps
(explainLimit, branchLimit) trim those lists - newest
commits, most recently touched branches - without narrowing what is loaded.
The in-flight cost estimate needs no price table: the rate comes from the calls
the run itself has already been billed for. STOP lets go of the call in flight
rather than killing it; the server reads it to the end and saves it, so
stopping costs that one call and nothing after it.
--permission-prompts none anything needing permission is denied --allowedTools ... Read, Grep, Glob, read-only git --disallowedTools ... Write, Edit, git commit/merge/push...
Tested by asking it to edit a file. It tries Edit, then
Write, then tries to escape through Bash(cat > ...).
All three are refused.
Several endpoints create branches or spend money, and there is no login. Every
request - the API, the page, and the exports, which carry everything the
analysis wrote - is checked against Host, Origin and
Sec-Fetch-Site. That stops a page on another site calling these
URLs in the background, and stops DNS rebinding.
Author email is never read from git. The co-author trailer is read only to answer yes or no to "was a model involved"; the name that matched is kept, the address is stripped on the spot. Credentials inside a remote URL are stripped before that URL is shown. Exports leave out the absolute path.
The browser files are classic scripts with no imports, so any JavaScript
engine can run them: node --check for syntax, then a harness that
stubs document, a counting canvas context and
EventSource, evaluates the seven files in page order, and feeds in
a real /api/state payload dumped from a live
Session. Write the harness for the thing you just changed; it is
twenty lines of stubs, not a framework.
A good harness checks the things that are easy to get wrong:
Add a branch in Routes._api_get or do_POST, then the
handler beside its neighbours. self.sess is the session and
self._json replies. Nothing else to register.
Add it to WEIGHTS and SCALE in
divergence.py, compute it in compute(), and make the
weights sum to 1. Then bump the divergence cache key.
Write a render function in panels.js, export it on
SG, and call it from showBranch or
showCommit. Escape everything with SG.esc.
render.js knows nothing about the rest of the front end. Add a
draw function, call it from draw() in paint order, and use
sx(lane) and sy(worldY) for coordinates. If it moves,
set animating so the loop keeps painting.
snapshot() during a scan. The lock is not
reentrant and it will deadlock. Pass the graph in instead.MERGE_HEAD and
MERGE_MSG for older ones.