STEINS;GIT

developer guide
1. Run it 2. Shape of the thing 3. What happens on load 4. Module reference 5. How the graph is laid out 6. Caching 7. Calling Claude 8. Safety rules 9. Testing without a browser 10. Adding things 11. Traps

1. RUN IT

./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.

The browser files live inside the package, at 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.

2. SHAPE OF THE THING

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:

3. WHAT HAPPENS ON LOAD

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.

4. MODULE REFERENCE

FILEDOES
cli.pyArgument parsing, startup banner, and the console script.
gitdata.pyRuns git and parses it. One Repo class. Never writes.
layout.pyTurns commits into columns and vertical positions.
divergence.pyThe six measurements, the bands, and per-commit change size.
session.pyOne repository held in memory. Owns settings and the cache.
store.pySQLite key-value store with size caps and migrations.
server.pySockets, headers, events, static files, origin checks.
routes.pyEvery API handler. Mixed into the request handler.
oracle.pyStarts claude as a subprocess and translates its output.
worktree.pyThe only code that writes. Makes branches and worktrees.
forge.pyTurns a remote URL into GitHub, GitLab or Bitbucket links.
export.pyBuilds one HTML file with everything inlined.
core.jsThe SG namespace: state, elements, helpers.
claude.jsStreaming calls, token counting, the output pane.
panels.jsThe right-hand inspector.
dialogs.jsModals: help, settings, rescan, merge, export, first run.
app.jsBoot, load, selection, canvas callbacks.
render.jsThe canvas. Self-contained, no dependency on SG.
meter.jsThe 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.

5. HOW THE GRAPH IS LAID OUT

layout.py runs three passes.

Pass 1: who owns each commit

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.

Pass 2: vertical position

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.

Pass 3: columns

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.

The renderer receives a column index, not an x value. Y is a world-space scale but X is screen space: each column owns a slab of width, and the slab widths are animated weights - the regex filter and the pair focus hand width from faded branches to the ones in front. Positions are running totals outward from the trunk, which is what makes overlapping columns impossible.

6. CACHING

One SQLite file at .steinsgit/knowledge.db, one table, keyed by kind and key.

KINDKEYHOLDS
snapshotversion + all ref tips + windowthe whole graph
divergenceversion + base SHA + head SHAone reading
mergetreeversion + both tip SHAs + enginea merge test
commitversion + commit SHAcommit detail
explaincommit: its SHA. branch: version + name + tip SHAa one-line summary
oracleversion + model + main SHA + tip SHA (or both tips for a merge)a full analysis
worldlinebranch namewhat a merge test was made from
metafixedsettings, 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.

When you change a stored shape, bump its key. Removing a field is not enough. Old rows written by an earlier build will still be served and will put the field back. That is a bug this project has already had once.

7. CALLING CLAUDE

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:

The full run

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.

8. SAFETY RULES

The model cannot write

--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.

The server only answers its own page

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.

Nothing private goes out

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.

9. TESTING WITHOUT A BROWSER

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:

This catches crashes, wrong maths and broken wiring. It says nothing about whether the result looks good. Check colour, spacing and glow by eye.

10. ADDING THINGS

A new endpoint

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.

A new measurement

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.

A new panel

Write a render function in panels.js, export it on SG, and call it from showBranch or showCommit. Escape everything with SG.esc.

Something drawn on the canvas

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.

11. TRAPS