A GitLens-style commit graph for any local git repository, in the browser.
Plain HTML, CSS and JavaScript — no frameworks, no CDNs, no build step, no
pip install, no network access of any kind. It works fully offline.
git, and Python 3.7 or newer — nothing else. There is nothing to install
and nothing to pin: server.py imports only the standard library (argparse,
http.server, json, os, re, subprocess, sys, threading,
urllib.parse, webbrowser) and the front end has no dependencies at all. A
requirements.txt for this project would be empty, so there isn't one.
3.7 is the floor because that is when http.server.ThreadingHTTPServer was
added; it is the only version-sensitive thing in the code. Verified working on
3.7.3, 3.8.2 and 3.9.6, against git 2.50.1. Keep the source 3.7-compatible:
no f-strings are needed, no walrus operator, no dict | dict, no builtin
generics in annotations, no functools.cache.
environment.yml is there only if you would rather have conda pin the
interpreter and git for you:
conda env create -f environment.yml && conda activate git-graph-explorerAny system Python 3.7+ with git on PATH works just as well — no virtualenv
required.
python3 server.pyThat opens http://127.0.0.1:8787 in your browser. Press Browse… to pick a folder in the usual file dialog, or type the path in the box and press Open. Picking any subfolder of a repository works — git resolves it to the repository root.
To skip the typing, name the repository up front:
python3 server.py --repo <PATH_TO_GIT_REPO>| option | |
|---|---|
--port 8787 |
port to listen on |
--repo PATH |
folder to preselect in the UI |
--no-browser |
do not open a browser window |
--no-picker |
disable the native folder dialog |
-v |
log each request to stderr |
server.py holds no state, so editing index.html, app.css or app.js only
needs a browser refresh. Editing server.py needs a restart.
| Browse… | pick a folder in the operating system's own file dialog |
| Path box | type or paste a path; remembers what you have opened |
| Branch picker | choose one branch or tag, or All branches |
| Click a commit | expands its changed files underneath, GitLens-style |
| Click a folder | collapses / expands it (tree view, see below) |
expand all · collapse all |
header of the tree view |
| Click a file | shows its unified diff on the right |
| Click the commit again | collapses the file list |
| Search box | commit id, message, body, author or ref name |
Enter / Shift+Enter |
jump to the next / previous match |
hide non-matching |
filter the list instead of dimming it |
j k or arrows |
move between commits |
/ |
focus the search box |
Esc |
clear the search |
r |
reload history |
| Click the short id | copy the full commit id |
| Drag the divider | resize the two panes |
The graph shows all refs (git log --all) by default, so every local and
remote branch is visible. The picker in the toolbar narrows it to a single
branch or tag. Lanes are coloured by position; a merge commit is drawn as a
hollow dot, and HEAD gets a ring around it.
The picker only changes what is drawn — it never runs git checkout, so
your working tree and current branch are left exactly as they were. Nothing in
this tool writes to the repository; every git command it runs is read-only.
When a commit's files span more than one folder they are shown as a tree:
folders first, then files, both A–Z; a chain of single-child folders is merged
into one node (src/core/io); each folder shows the summed +/− of its
text files. Folders start expanded. A folder you collapse stays collapsed in
other commits until the page is reloaded. Files that all share one folder keep
the flat list, with the folder in faint text. The diff opened on select is the
first file in list order.
History is loaded 2000 commits at a time — Load older commits appears at the bottom of the list when there are more. Search covers the commits that are loaded, not the ones still on disk.
A page opened from file:// cannot run git or read a folder you choose — the
browser sandbox forbids both. A pure-JavaScript reader was considered and
rejected: a normal repository keeps its objects in packfiles, so it would have
needed an idx-v2 parser, OFS_DELTA/REF_DELTA resolution and its own diff
algorithm, all to reimplement what git already does correctly.
server.py is therefore the smallest possible bridge: standard library only,
it shells out to git and serves the three static files.
A web page cannot tell anyone which folder you chose. <input type="file" webkitdirectory> and showDirectoryPicker() both hand JavaScript the folder's
name and the paths of files relative to it — never the absolute path the
server needs. That is a deliberate sandbox rule, not an oversight.
So /api/pick asks the server, which runs on your machine, to open the real
dialog and report back the path. It tries, in order:
| platform | dialog |
|---|---|
| macOS | osascript → AppleScript choose folder (the Finder dialog) |
| Windows | PowerShell FolderBrowserDialog |
| Linux | zenity, then kdialog |
| anywhere | tkinter.filedialog — ships with Python, so it always exists |
Two details make that chain trustworthy:
- A dialog that cannot open is not a cancel. Some machines make
osascriptfail instantly with AppleScript's-128, which is the same code a real cancel produces. A "cancel" arriving in underMIN_DIALOG_SECONDS(0.4 s) is therefore treated as this tool does not work here and the next one is tried; nobody dismisses a dialog that fast. Without this the fallback chain would never run. - Tk runs in a subprocess, never in the server process: on macOS Tk demands the main thread, and requests are served on worker threads.
Only one dialog can be open at a time, the rest of the app keeps working while it is open, and cancelling is silent — no error, nothing changed.
Since the dialog appears on whatever machine runs server.py, Browse… is
only useful when that is your own desktop. Over SSH, or in a container, use the
path box (or --no-picker to hide the button's behaviour entirely). Note also
that the dialog can open behind the browser window.
server.py |
stdlib-only HTTP server; all the git plumbing |
index.html |
the page |
app.css |
dark theme; the row/lane geometry lives in :root |
app.js |
lane layout, virtual list, file tree, search, diff rendering |
environment.yml |
optional conda env; pins the interpreter and git, no packages |
ROW_H, FILE_H, LANE_W and LANE_PAD at the top of app.js must match
the matching custom properties in app.css — the virtual list computes pixel
offsets from the JavaScript copies while the browser lays rows out from the CSS
ones, so they silently drift apart if only one is changed.
Every endpoint is GET, returns JSON, and takes path (the repository folder).
Errors come back as {"error": "a sentence you can show a user"} with status
400, so the UI can put the text straight in its banner.
| endpoint | returns |
|---|---|
/api/default |
{path} — the --repo folder, if one was given |
/api/pick |
{path, via}, or {cancelled: true}; opens the native folder dialog. Takes start |
/api/repo |
{root, name, branch, head, total} |
/api/refs |
{refs: [{name, kind, sha, date, current}]}, kind is local/remote/tag |
/api/log |
{commits, skip, limit, ref, has_more}; takes limit, skip, ref |
/api/commit |
{files: [{status, score, path, old, add, del, binary}], add, del, merge}; takes sha |
/api/diff |
{patch, truncated}; takes sha and file |
layout() in app.js walks the commits newest-first, keeping lanes[k] = the
sha that lane k is waiting for. For each commit it takes a lane, collapses any
other lane waiting for the same commit, then hands its lane on to its first
parent and gives extra parents (a merge) a lane of their own. Each row records
three things, and graphSvg() turns them into one small inline <svg>:
up— lanes entering the row from above, curving into the dotdown— lanes leaving the dot downwards, one per distinct parent lanethrough— lanes crossing the row untouched, drawn as straight verticals
The invariant that keeps the picture honest is continuity: the lanes leaving the bottom of a row are exactly the lanes entering the top of the next one, so no line ever dangles.
The list is virtualised — only the visible rows plus a small buffer exist in the
DOM. Because at most one commit is expanded at a time, row offsets stay O(1):
rowTop() adds the file list's height to every row below the selected one, and
posAt() inverts it.
The file list's height is state.nodes.length × FILE_H, where state.nodes
is the list of visible rows (tree header, folders, files) built by
flatNodes() from state.tree (buildTree(), or null for a flat list).
Anything that changes what is visible — toggling a folder, expand/collapse all,
a new commit's files via setFiles() — must rebuild state.nodes before
render(), or rows below the expanded commit overlap or leave a gap. Tree rows
all use --file-h, so no new geometry constant exists to keep in sync.
Each of these was found the hard way; the code carries a comment where it matters.
- Merge diffs.
git diff-tree -m --first-parentlooks like it shows a merge against its first parent. It does not — it emits the diff against every parent concatenated, so merges list files they never touched and their line counts double. Use the explicit two-tree form<sha>^1 <sha>, and--rootfor a root commit, which has no^1. Seediff_cmd()inserver.py. --rootis not optional. Without it a root commit shows no files at all.- Renames. Under
--raw -z, anRorCstatus is followed by two NUL-terminated paths (old, then new); every other status by one. git log -z. Records are NUL-separated and fields0x1f-separated, because commit bodies contain newlines.%Bis the whole message; its first line is the subject.for-each-refseparators. A tab is safe:git check-ref-formatforbids whitespace in ref names.- Diff header lines.
---and+++are only headers before the first@@. Inside a hunk a removed line reading--renders as---, sopaintDiff()switches on aninHunkflag rather than on the prefix alone. - Encoding. git output is not guaranteed UTF-8; it is decoded with
errors="replace".
The server executes git in a folder supplied over HTTP, so it is deliberately
narrow:
- binds
127.0.0.1only, never0.0.0.0; - rejects any request whose
Hostheader is not localhost (DNS-rebinding guard); - every folder goes through
git rev-parse --absolute-git-dirbefore anything else runs; - git is always invoked with an argument list, never
shell=True; - commit ids and ref names are validated, so a value like
--output=/tmp/xcannot be read as an option, and--precedes user-supplied paths; - static files come from a fixed whitelist of names, so no path traversal;
- each git call has a timeout.
- Lane colours follow lane position, not branch identity, so a trunk can change colour where branches converge. The lines stay continuous and correct; it is cosmetic.
- No working-tree or staged view — committed history only.
- Browse… needs a desktop on the machine running the server; see above.
- Search only covers loaded commits (see Load older commits above).
- Single-file diffs only; there is no whole-commit combined patch view.
- Per-file diffs are capped at 400 KB, and the UI says so when it truncates.