A local git commit-graph viewer: a standalone web service that lists the
git repositories at a served root and renders their commit DAGs. Sibling of
binp-file-explorer in shape and conventions (local-machine service-first;
later embeddable in nightshift-ui as a module).
Before starting any non-trivial task, consult .nightshift/ — the local dev
notebook (gitignored) that is the single source of truth for plans, decisions,
and open questions. Record session outcomes back here — append to log.md,
update backlog.md — so the next agent inherits the context.
| Layer | Choice | Notes |
|---|---|---|
| Runtime | Bun | Primary runtime; Bun.spawn runs git. |
| Server | Elysia | Per ADR-0003. |
| Validation | Zod (v4 API via zod/v4) |
Boundary validation (ADR-0013). Route schemas use Zod, never TypeBox (ADR-0014); schemas double as the OpenAPI spec via z.toJSONSchema. |
| API docs | @elysiajs/openapi |
ADR-0020: discovery at GET /api, Scalar UI at GET /api/docs, spec at GET /api/openapi.json. |
| Frontend | React 19 | |
| Styling | Tailwind CSS v4 | @tailwindcss/vite plugin; palette + lane tokens in src/theme.css (imported by index.css, shared verbatim with a host via git-graph/theme.css, ADR-0027). Light/dark/system theme re-skins by overriding the same custom properties under [data-theme="light"]. |
| Icons | @tabler/icons-react |
ADR-0022 — never Unicode characters as icons. |
| Diff view | @git-diff-view/react + @git-diff-view/shiki |
Pinned exactly at 0.1.7 (pre-1.0). Whole-file tokenization for the diff views (inline unified in the panel, full-tab split for the standalone commit/compare tabs) — beats per-line highlighting (diff2html). First substantial third-party runtime UI dependency; ADR still open (see .nightshift/backlog.md). |
| Build | Vite (client) + Bun bundler (server + CLI) | client → dist/client/, server + bgg CLI → dist/server/. |
| Packaging | Nix flake | flake.nix → the bgg standalone CLI + git-graph alias (packages/apps), a devShell, and a hardened nixosModules.default (services.git-graph). Mirrors binp-file-explorer's bfe flake. |
| Dev env | mise | .mise.toml declares tool versions, env vars, and tasks (ADR-0004). |
Dev ports are offset from binp-file-explorer so both run side by side:
Elysia :3010, Vite :5183. These are the canonical request, not a pin:
mise run dev (→ scripts/dev.ts) resolves them before launch per ADR-0037
§2 — probing each on the bind host, announcing any reassignment, and pinning
the result (PORT, VITE_PORT, VITE_API_TARGET, strategy strict) into both
child processes so a stale session never forces manual port juggling and Vite's
/api proxy follows a moved server. The runtime binds stay strict (ADR-0018).
Scaffolded per ADR-0003 with infra/dev-env conventions adopted from a sibling
project (build scripts, ADR-0020 discovery skeleton, config shape). The
product is a port: the layout algorithm, renderer behaviour, and search UX
come verbatim from an internal single-file prototype (@ 9f74265) that was
verified in-browser against real multi-branch history. The algorithm itself is
pvigier's active-lane sweep, described publicly at
https://pvigier.github.io/2019/05/06/commit-graph-drawing-algorithms.html;
that post, plus shared/graphLayout.test.ts's pinned fixture, is the
reproducible reference — the prototype is not needed to work on this repo.
The repo is deliberately layered so the same code serves all three future consumers (standalone instance · shared package · nightshift-ui module):
shared/— pure data-in/data-out logic with zero DOM or server imports:git.schema.ts— the Zod-typed git boundary (ADR-0013): commit log, commit detail, file diff, branch listing, branch comparison, and the working tree.graphLayout.ts— the algorithm. Verbatim port of the prototype'scomputeLayout(pvigier's active-lane sweep, as used by mhutchie/GitLens/GitKraken). Do not "improve" its behaviour without updating the regression fixture ingraphLayout.test.ts, which pins the prototype-captured output.gitLog.ts—git logwire format + parser (unit separator%x1f).commitDetail.ts—git showwire format + parser for a single commit (header +--raw/--numstatfile block, zipped positionally). Merges use-m --first-parent.fileDiff.ts—git showargument list +languageForPath+splitPatchIntoFileHunksfor a single file's diff (patch + both complete blobs;--no-ext-diff --no-textconvload-bearing).compareDiff.ts—git diffargument shapes +compareRevisionArgumentsfor a branch comparison: a three-dotbase...headmerge-base diff (the "what does this branch add" view a PR shows), falling back to two endpoints when the branches share no history.workingTree.ts—git diffargument shapes +EMPTY_TREE_HASHfor the working tree (uncommitted changes vs HEAD): agit diff HEADsummary reusing the commit-detail parser, plus per-file patch args for tracked files and an--no-indexvariant for untracked ones (--no-ext-diff --no-textconvstay load-bearing).fuzzy.ts— subsequence fuzzy matcher with matched-character segments (ADR-0019).
server/— Elysia service.services/git.tsscans the served root and shells out to git. Everything untrusted is re-validated by membership against git's own listings before it reaches the shell — repository identifiers against the repo listing, file paths against a commit's/ comparison's own file list, and branch refs againstgit for-each-ref. Beyond the log/detail/file-diff routes it servesGET /api/git/branches(with default-branch resolution),/api/git/compare(branch-vs-base file list),/api/git/compare/diff(one file of a comparison), and/api/git/working+/api/git/working/diff(the working tree — uncommitted changes vs HEAD, and one file of it; tracked viagit diff HEAD, untracked viagit ls-files --othersdiffed from/dev/null, membership-guarded path).- Standalone surface (the on-demand instance consumer, mirroring
binp-file-explorer's
bfe):server/cli.ts+server/cli/is thebggexecutable the flake installs —serve(foreground, browser-open) and a backgrounddaemonlifecycle (ADR-0015) over/api/status, each in its own module (argspure-parses argv,pathsresolves the sibling server bundle per ADR-0011,browser,daemon). Port selection and exposure are their own dependency-light services (ADR-0032):services/port.ts(probe),listen.ts(strict|autoin-process walk), andbind-exposure.ts(the loopback-default gate). All three are ADR-0037:autois the default strategy for every launch shape — a barebun server/index.ts,mise run start, and the CLI — allocating in front of the strict bind, never as a silent runtime fallback;strictis reserved for an explicit operator pin (bgg --port,GIT_GRAPH_PORT_STRATEGY=strict, the NixOSportoption). The server reports the bound port (banner,/api/status, the CLI ready-file handshake), and a non-loopback bind refuses to start withoutGIT_GRAPH_ALLOWED_HOSTS(ADR-0037 §4).scripts/dev-ports.tscomposes the sameport.tsfor the dev resolver.
- Standalone surface (the on-demand instance consumer, mirroring
binp-file-explorer's
src/— React client, one bundle with several entry points thatindex.tsxroutes onlocation.pathname: the graph shell (App.tsx) and the standalone diff tabsFileDiffPage(/diff, one file),CommitDiffPage(/commit, a whole commit),ComparePage(/compare, a branch against a base), andWorkingTreePage(/working, uncommitted changes vs HEAD). Every rendering piece is fetch-free —CommitGraph.tsx(commits in, SVG + rows out),CommitDetailPanel.tsx(changed files, copy-hash, parent navigation),FileDiff.tsx(one diff, unified or split by prop),MultiFileDiffView.tsx(file list on top + per-file diffs loaded lazily as each nears the viewport),UncommittedChangesRow.tsx(the working-tree node above HEAD), plus shared tokens incomponents/fileStatus.tsxand chrome inDiffTabFrame.tsx. The shells (App.tsxand the four pages) own all fetching;lib/diffRoutes.tsis the single descriptor for the diff-tab URLs (ADR-0026) that the panel builds and the pages parse.- The working-tree row is barrel-level, not app-level (ADR-0026 /
ADR-0027): it is read on two surfaces — this shell and nightshift-ui's
<GitGraphPanel>— so it lives incomponents/with the same href-or-handler open seamCommitDetailPaneloffers (hreffor a host with a diff tab,onOpenfor one that opens in-app; a handler-driven row renders a<button>, never a link with a dead href). It shipped app-local and so was silently absent from the host, which is the failure the barrel prevents. Its geometry helpers (ROW_HEIGHT,GRAPH_NODE_COLUMN_X,graphContentLeft) are exported from the barrel for the same reason — a host aligning a non-commit row must not guess an inset that drifts as lanes are added. - App-only chrome (localStorage-backed, deliberately kept out of the host
barrel per ADR-0032):
lib/theme.ts(useTheme+ Zod-validated persisted mode, defaultsystem) withThemeToggle.tsx, andlib/detailLayout.ts(useDetailLayout, defaultinline) withDetailLayoutToggle.tsx.CommitDetailPaneltakes avariant(inline|sidebar) +headerActionsseam (ADR-0027) andCommitGraphaselectedDetailinline slot that offsets the SVG for rows below the expansion; the layout algorithm is untouched.
- The working-tree row is barrel-level, not app-level (ADR-0026 /
ADR-0027): it is read on two surfaces — this shell and nightshift-ui's
The render layer is importable by subpath — package.json exports maps
./components (the fetch-free components), ./shared (schema + layout + fuzzy),
./highlighter (lib/highlighter.ts), and ./theme.css, so nightshift-ui can
source-alias them (no proxy, no forked copy).
Via mise (.mise.toml): dev (port-resolving launcher, ADR-0037 §2),
dev:ports (probe/report only), dev:server, dev:client, cli (run bgg
from source), build (client + server + CLI), start, typecheck, test.
The standalone build/run also goes through the flake: nix build /
nix run .# -- ….
bun test must stay green. The layout algorithm is the risky part — its tests
cover branch tips, 2-parent merges, lane reuse after a branch closes, octopus
merges (3+ parents), root commits, disconnected histories, truncated windows,
plus a fixture pinned to the prototype's exact output. server/services/git.test.ts
exercises real git against a scratch repository (merge, tags, empty repo,
truncation, traversal rejection), including branch listing with default-branch
resolution and three-dot branch comparison (an unmerged fixture branch, since a
merged one correctly compares empty), plus the working tree (a dirty-repo
fixture with a modified, a deleted, an untracked text, and an untracked binary
file — list, clean, and per-file diffs including the --no-index untracked case
and the untracked-binary notice). The git show/git diff parsers
(commitDetail.ts, fileDiff.ts) and the route membership guards — path and
ref — are covered too, and client components have DOM tests (bunfig.toml
preloads happy-dom via src/test/setup.ts), including MultiFileDiffView's
lazy load behind a stubbed IntersectionObserver, CommitGraph's inline
selectedDetail slot, UncommittedChangesRow's clean state and its two open
seams (link vs in-app handler), and detailLayout's schema default/fallback (ADR-0029). The standalone surface is
covered too: services/port.test.ts (probe/walk against real binds),
listen.test.ts (strict|auto strategy, announced skips, span exhaustion),
bind-exposure.test.ts (loopback-default / non-loopback-refusal, ADR-0037 §4),
cli/args.test.ts (argv routing + flag parsing), and scripts/dev-ports.test.ts
(env pinning + reassignment announcements).
Currently 170 tests across 16 files.
- ADR-0019: fuzzy matches highlight the matched characters (
<mark>). - ADR-0025: disabled controls stay visible and explain themselves (
title/placeholder). - ADR-0016: no third-party runtime assets — everything is bundled.
- ADR-0022: Tabler vectors, never emoji.
- ADR-0018 / ADR-0037: dynamic port allocation runs in front of the strict bind
(
autodefault, announced walk), never as a silent fallback; a conflict on a pinned port is still fatal. Loopback bind by default; non-loopback refuses withoutGIT_GRAPH_ALLOWED_HOSTS. - ADR-0011 / ADR-0015: the
bggCLI resolves its sibling server bundle by real path, and its background daemon lives under thedaemonsubcommand. - Theme: light/dark/system toggle in every shell's header (default
system), persisted (ADR-0029) and applied via[data-theme]; a pre-paint shim inindex.htmlavoids a flash. - Commit detail (ADR-0031): opens inline beneath the selected row by default, with a persisted panel-header toggle back to the docked right sidebar.
- Diff tabs (ADR-0031): a changed file, a whole commit, and a branch (against the default branch unless a base is chosen) each open in a standalone tab — via cmd/ctrl/middle-click on the file row, or the visible external-link / compare-branch controls on the row, the files-changed heading, and the branch ref pills. The controls stay visible and explain themselves per ADR-0025; the compare control appears only for local branches other than the default.