git-viewer
The source for this page
8d1bb5bfdd2cba82488775b46308efed740132a8
· 11 entries
| Name | Type | Size |
|---|---|---|
| .gitignore | file | 4 B |
| AGENTS.md | file | 15.5 KB |
| Dockerfile | file | 411 B |
| README.md | file | 20.6 KB |
| go.mod | file | 298 B |
| go.sum | file | 1.9 KB |
| gpgverif | file | 1.8 KB |
| internal | dir | — |
| main.go | file | 10.9 KB |
| static | dir | — |
| templates | dir | — |
gsg — a modular git browser
cgit’s weight, Hugo’s composability, served dynamically. A module
computes one piece of a view; a route binds a renderer to a URL pattern.
Adding a feature means adding a module and calling it from a template —
internal/core never changes.
One dependency (goldmark, which itself has none) plus the git binary. Go 1.22+ (uses net/http
pattern wildcards, so there is no router dependency either).
go build -o gsg .
./gsg -root /srv/git -addr :8080 -name "rschanz.org"
./gsg -root /srv/git -templates ./templates # re-parse the theme per request
./gsg -root /srv/git -notices /etc/gsg/notices.yaml -admin-addr 127.0.0.1:8081
The idea
type Module func(c *Ctx, args ...string) (any, error)
Templates reach modules through one funcmap entry:
{{ with mod "meta" }}<h1>{{ .Title }}</h1>{{ end }}
{{ $d := mod "diff" }}
{{ with modOr "readme" }}{{ .HTML }}{{ end }}
- Lazy. A template that calls
mod "authors"is what causes authors to be computed. Views that don’t ask don’t pay. - Memoized twice. Once per request (
Ctx.cache), and once process-wide against the resolved commit SHA (core.LRU). - No manifest. Dependencies are declared by use. There is no module list to keep in sync with the templates.
internal/modules/example_authors.go is a live demonstration: 30 lines, an
init(), and one {{ with mod "authors" }} block. Nothing else moved.
Pages and endpoints are the same mechanism
A route is {pattern, renderer}. core.View renders a template,
core.JSON serializes an endpoint. Both take a *Ctx, both call modules.
{Name: "commit", Immutable: true,
Pattern: "GET /{repo}/commit/{rev}",
Render: core.View{Template: "commit.html"}},
{Name: "api-commit", Immutable: true,
Pattern: "GET /{repo}/api/commit/{rev}",
Render: core.JSON{Build: api.CommitEndpoint}},
Endpoints use DTOs, not module values
internal/api defines explicit structs and never marshals a module’s return
value. Go marshals every exported field by default, so serializing a module
type means the day someone adds Body to gitx.Commit, commit messages are
public. The allowlist is one readable file.
GET /api/repos.json site index
GET /{repo}/api/repo.json slug, title, description, owner, section,
clone_url, default_branch, last_commit
GET /{repo}/api/commit/{sha} sha, short, authored, parents, and
file/line counts — no subject, no body
Routes
| Route | URL |
|---|---|
| index | / |
| repo | /{repo}/ — default branch, last update, latest commit, README |
| files | /{repo}/tree/{ref}/{path} |
| file | /{repo}/blob/{ref}/{path} |
| raw bytes | /{repo}/raw/{ref}/{path} |
| log | /{repo}/log/{ref} |
| refs | /{repo}/refs/ |
| commit | /{repo}/commit/{sha} |
| compare | /{repo}/compare/{base}..{head} or {base}...{head} |
| branch graph | /{repo}/graph/ |
Compare
a..b is the literal endpoint difference. a...b diffs from the merge
base, which is what you want when reviewing a branch — it excludes changes
that landed on the base since the branch forked. Verified against
git merge-base on a diverged fixture: topic..main lists topic.txt as
removed, topic...main does not.
Raw files
A repository can contain an .html file with a <script> tag. Serving that
as text/html on your own origin turns any push into stored XSS against the
whole site — which is why GitHub puts raw content on
raw.githubusercontent.com, a separate origin. Same-host serving is only
safe if the response cannot be interpreted as a document, so /raw/ sends
text/plain (or application/octet-stream for binaries, plus
Content-Disposition: attachment), with X-Content-Type-Options: nosniff
and Content-Security-Policy: default-src \'none\'; sandbox as a backstop.
The exception is a validated raster image (PNG, JPEG, GIF, WebP, BMP, ICO,
AVIF): when the extension and the magic bytes agree, /raw/ declares the
real image/* type inline so the blob page can preview it. An extension by
itself is not enough — an .png whose contents are HTML still downloads as
application/octet-stream. SVG is deliberately excluded: it is an XML
document that can execute script, so it stays text/plain.
If you would rather have real isolation, serve /raw/ from a second
hostname and the CSP becomes belt-and-braces rather than the only line.
Images and structured config files
An image blob shows an inline preview with a download link to the same
/raw/ URL; images over 10 MiB skip the preview and offer the download only.
JSON and YAML blobs get per-level indent guides, fold controls, key/value
search, and YAML anchor/alias links. The search box also accepts a value
path: typing obj1.property3.items[4].val resolves it against the parsed
document and highlights that line and, for a container, its subtree. A
query containing . or [ is tried as a path first and falls back to
plain substring search when it does not resolve, so dotted text stays
searchable. Paths quote keys that need it (obj1["a.b"]) and attach indices
directly; SVG is never previewed, and files past 512 KiB skip the parsing
tooling entirely.
Branch graph
modules/graph.go lays out the DAG in lanes in one pass over
git log --all --topo-order, which guarantees a child is emitted before its
parents. active holds, per lane, the SHA that lane is waiting to draw; a
commit claims the lane expecting it (or a fresh one), hands that lane to its
first parent, and opens a new lane per additional parent — which is what
makes a merge fork visually.
The module emits the per-row SVG because the geometry is the data, but
every stroke refers to a CSS custom property (--lane-0 … --lane-5), so
the theme still owns the palette. Row height in the SVG must stay in sync
with table.graph td { height } in the stylesheet.
Caching, which is the whole reason this works dynamically
Every module result is a pure function of (repo, commit SHA, path, args).
Refs move; SHAs do not. So the process-wide cache is keyed on the resolved
SHA and needs no invalidation logic at all — a stale entry is impossible
by construction.
Ctx.Vars are part of the cache key, not just the commit. They were not
originally, and the bug was exactly the kind this cache should be immune to:
/compare/a..b and /compare/a...b resolve to the same head commit, so the
second request was served the first one's diff. Anything that changes what a
module computes has to be in the key.
ETags are derived from the resolved SHA plus a digest of every template file, not from the response body. A conditional request costs one ref lookup instead of a render, and restyling the theme invalidates every ETag at once (without the template digest, a restyled page keeps serving 304s).
Two URL classes, two policies:
| URL shape | Example | Cache-Control |
|---|---|---|
| SHA-addressed | /demo/commit/64b90fff… |
max-age=31536000, immutable |
| ref-addressed | /demo/tree/main |
no-cache (revalidate via ETag) |
Ref-vs-path ambiguity
/{repo}/tree/{rest...} fuses them, and gitx.SplitRefPath separates them
by longest matching prefix against the repo’s actual ref list, so
feature/x/src/main.go resolves correctly. Splitting at the first / is
wrong because branch names contain slashes; cgit dodges this with ?h=
query params, GitHub does what this does.
Serving is an attack surface; static generation was not
- Ref input never reaches
git rev-parse. rev-parse accepts an expression language —:/regexsearches history,@{...}reads the reflog — and git subcommands treat a leading-as an option.gitx.Resolveaccepts either a hex SHA that already exists or an exact match against the repo’s own ref list. Verified:--upload-pack=…,:/secret,HEAD@{0}and-nall return 404 with no side effects. - Every git call is
exec.CommandContextwith a per-request deadline (-timeout, default 10s) and a scrubbed environment (GIT_CONFIG_NOSYSTEM=1, noHOME, no terminal prompt). - Output is capped at
MaxGit(8 MiB) per command; a diff that exceeds it renders truncated rather than eating the heap.
Layout
main.go flags, repo discovery, embedded assets
internal/gitx/ the ONLY package that knows git exists
internal/core/core.go Ctx, RepoInfo, module registry, Store
internal/core/route.go Route, View, JSON
internal/core/server.go request -> Ctx, ETags, cache policy
internal/core/cache.go LRU keyed on commit SHAs
internal/core/funcs.go template funcmap (mod, modOr, dict, date, bytes)
internal/modules/ meta refs log tree readme repos commit diff blob notices
internal/notices/ notices YAML store + admin UI (imports no core)
internal/api/ JSON DTOs — the serialization allowlist
internal/site/routes.go the route table (the file you edit most)
templates/ layouts/ + views/
gitx shells out to plumbing, one process per query, git log in a single
pass. Swapping in go-git means rewriting that one package.
Sections
gsg.section groups repositories on the index. The sections module builds
on repos rather than re-walking the repo set, so the index pays for its log
lookups once. When no repo sets a section, it returns a single unnamed group
and the template renders a flat list — no config, no headings.
git --git-dir=/srv/git/demo.git config gsg.section "Infrastructure"
Notices
Operator-authored banners at the top of every page, site-wide and per repository. They live in one YAML file that is watched for changes, so editing it never needs a restart or a rebuild:
global:
- type: critical # critical | warning | info | fix
title: Maintenance window
body: |
Saturday 02:00-04:00 UTC.
The site is read-only during the window.
link: https://status.example.org
link_text: status page
repos:
demo:
- type: warning
title: Migrating to a new remote
./gsg -root /srv/git -notices /etc/gsg/notices.yaml
Global notices render above every repository’s; on the site index only the global list appears. The file is re-read on write (atomic-rename saves from an editor included); a parse error keeps the last good content rather than blanking the banners.
Editing from the web
-admin-addr starts a second listener with a small HTML form to add, edit,
and delete notices. It is deliberately separate from the read-only server:
the admin routes are not reachable on the public port even if the reverse
proxy is misconfigured.
./gsg -root /srv/git -notices /etc/gsg/notices.yaml -admin-addr 127.0.0.1:8081
Protect it with Caddy forward_auth and proxy it to the admin listener. gsg
trusts the network in front of it, so the admin port must not be published
directly. In Compose, put 8081 on the internal network only and pass
-admin-allow-remote, because a container address is not loopback. Note
that gsg performs no authentication itself – the forward_auth block below
is the only thing between the internet and the admin form.
example.com {
# Admin: authenticate first, then proxy to the separate admin listener.
# handle, not handle_path: the admin mux matches the full /admin/...
# path, so stripping the prefix would 404 every request.
handle /admin/* {
forward_auth authentik:9000 {
uri /outpost.goauthentik.io/auth/caddy
copy_headers X-Authentik-Username
}
reverse_proxy gsg:8081
}
# Public, read-only.
handle {
reverse_proxy gsg:8080
}
}
The X-Authentik-Username header is used only to attribute the mutation in
the log; it is never consulted for authorization. By default Authentik
decides who may reach the handler at all – anyone forward_auth lets through
/admin/* can edit notices.
Mutations are POST-only, CSRF-protected (double-submit cookie plus
Origin/Sec-Fetch-Site), and logged with the X-Authentik-Username
header, which is used for audit only, never for authorization.
Gating notices editing to a group
/admin/* is also where Login mints the gsg_user cookie, so
forward_auth for the whole prefix is usually left open to everyone you want
able to log in (for share links, hidden-repo visibility, etc). To let more
people log in without also handing them notices editing, add -admin-group:
./gsg -root /srv/git -notices /etc/gsg/notices.yaml \
-admin-addr 127.0.0.1:8081 -admin-group gsg-admins
This checks the X-Authentik-Groups header (override with
-admin-group-header) for gsg-admins among its |-delimited entries,
Authentik’s own format for that header, and answers 403 to any request
under /admin/notices from a caller that isn’t a member – before the
handler does anything else, including setting the CSRF cookie. Add the group
header to the same forward_auth block used above:
forward_auth authentik:9000 {
uri /outpost.goauthentik.io/auth/caddy
copy_headers X-Authentik-Username X-Authentik-Groups
}
Leaving -admin-group unset keeps today’s behavior: anyone forward_auth
admits to /admin/* can edit notices.
Immutable commit pages carry no notices. /{repo}/commit/{rev} is
served Cache-Control: public, max-age=31536000, immutable, so a banner
rendered into it would stay frozen in the reader’s browser. Notices are
suppressed there by design; every other page picks them up on the next
request.
Login
Logging in reveals hidden repos whose gsg.visible-to list includes your
username; everyone else, logged in or not, sees exactly what an anonymous
visitor sees. It reuses the same Authentik forward_auth setup as the
notices admin UI above, and needs nothing extra beyond -admin-addr
(notices editing is not required — -admin-addr alone is enough to enable
login).
./gsg -root /srv/git -admin-addr 127.0.0.1:8081
example.com {
# Login: same trust boundary as /admin/*, one hop mints the cookie
# and redirects back. This must be the exact path, not /auth/*: gsg
# also serves /auth/logout, but on the *public* listener, since
# logging out only clears gsg's own cookie and needs no Authentik
# round trip at all. Routing /auth/* here would send logout to the
# admin listener too, where no such handler exists, and gate it
# behind an Authentik check it doesn't need.
handle /auth/login {
forward_auth authentik:9000 {
uri /outpost.goauthentik.io/auth/caddy
copy_headers X-Authentik-Username
}
reverse_proxy gsg:8081
}
# Public, read-only (also handles /auth/logout, /share/new, /share/view/*).
handle {
reverse_proxy gsg:8080
}
}
Visiting /auth/login (through Caddy, so X-Authentik-Username is set and
trustworthy by the time gsg sees the request) mints a signed, stateless
gsg_user cookie and redirects back to /. The cookie is verified on the
public listener with an in-memory key — nothing is written to disk, and a
process restart signs everyone out. /auth/logout clears it and needs no
round trip through Authentik, since it only has to forget gsg’s own cookie.
There is no “log in” link anywhere in the UI. /auth/login is not
discoverable from the site; people who need it are told the URL out of
band. Once logged in, the nav bar shows who you are and a log-out link.
Share links
Once logged in, /share/new lets you pick from your own hidden repos and
mint a link that grants view access to exactly those repos to anyone who
has it — no login, no Authentik involvement. It’s entirely stateless: the
link is a signed, self-contained token, and gsg keeps no record of what’s
been shared. An optional password can gate it.
git --git-dir=/srv/git/demo.git config gsg.share-max-age "720h"
gsg.share-max-age (Go duration syntax, default 30 days if unset) is both
the ceiling no link’s expiry may exceed and the default when the minting
form doesn’t specify one — you can always ask for less, never more. Sharing
several repos at once uses the smallest ceiling among them.
Passwords are never stored, not even hashed. The password is folded directly into the link’s signature instead of being embedded in it: nobody holding just the URL has anything to brute-force offline, and checking a guess requires asking the server, which rate-limits attempts. This is a soft gate appropriate for a small self-hosted tool, not a cryptographic secret protecting high-value data.
Revocation. A stateless token can’t be individually revoked — there’s no list of what’s been issued. The one lever:
git --git-dir=/srv/git/demo.git config gsg.share-revoked-after "$(date +%s)"
Any share grant for that repo issued at or before this timestamp stops working immediately, including in a browser that already redeemed the link — not just future redemptions. Two things worth knowing before you rely on this:
- There’s no per-recipient state, so revoking means invalidating every outstanding share of that repo, then re-sharing with whoever should still have it.
- Removing a username from
gsg.visible-todoes not revoke a share link that user already minted for that repo. The two mechanisms are independent; onlygsg.share-revoked-afterinvalidates outstanding shares.
README rendering
modules/markdown.go uses goldmark with GFM, Typographer, and automatic
heading IDs — Hugo's own goldmark defaults, so a README renders identically
to the same file on your blog.
WithUnsafe is deliberately not set, which is also Hugo's default. Anyone
who can push can write a README, so raw HTML in markdown is untrusted input;
goldmark drops it. A <script> committed to a README comes out as
<!-- raw HTML omitted -->.
Relative links are rewritten against the file's own directory: links become
the blob view, images become /raw/ (an <img> needs bytes, not an HTML
page). Absolute URLs, site-absolute paths, fragments and mailto: are left
alone, and anything resolving above the repo root is not rewritten.
Per-repository config
Read from each repo’s own git config, cgit-style, with gitweb.* and the
legacy description file as fallbacks, so an existing cgit tree works as-is:
git --git-dir=/srv/git/demo.git config gsg.title "Demo Project"
git --git-dir=/srv/git/demo.git config gsg.description "A demo repository"
git --git-dir=/srv/git/demo.git config gsg.clone "git://rschanz.org/demo"
git --git-dir=/srv/git/demo.git config gsg.hide true
git --git-dir=/srv/git/demo.git config gsg.visible-to "alice,bob"
git --git-dir=/srv/git/demo.git config gsg.share-max-age "720h"
git --git-dir=/srv/git/demo.git config gsg.share-revoked-after "$(date +%s)"
Add a key by adding a field to core.RepoInfo and a line in LoadRepoInfo.
A repo’s slug is its directory name (minus .git), and slugs share the
public listener’s URL namespace with a few reserved top-level paths:
static, auth, and share. A repo named e.g. share.git would collide
with the share-link endpoints below and is not supported.
gsg.visible-to is a comma-separated allowlist of usernames (matched
case-insensitively against a logged-in session, see “Login” below) who may
see this repo even though gsg.hide is set. It’s a separate key from
gsg.owner on purpose: Owner is a free-text display field that falls
back to gitweb.owner for cgit-migrated repos, so treating it as an access
list could silently grant visibility nobody intended.
Theme
Monospace-first: every value on these pages is an identifier, hash, path or
count, so the interface type is the same face as the code, and the sans face
appears only inside rendered README prose. A cool slate ramp, one indigo
accent, and a six-colour lane ramp shared by the commit graph and its branch
chips. Light and dark both defined via prefers-color-scheme.
Diffs mark the gutter rather than flooding whole rows with green and red — the signal stays readable when a hunk is mostly additions.
No webfonts; system stacks only, so there is no third-party request. All
colour lives in :root custom properties in static/style.css.
Known gaps
- No syntax highlighting.
blobreturns numbered plain lines; Chroma drops into the same module. - No pagination on
logorgraph. They cap at 200 and 150 commits. - The graph loads all refs into memory before laying out. Fine to a few thousand commits; beyond that it needs windowing.
- The LRU is bounded by entry count, not bytes. A giant diff and a ref
list cost the same slot. The
Storeinterface is the only thing a size-aware replacement has to satisfy. - Repo discovery is one level deep and happens once at startup; a repo created later needs a restart.
- Module errors surface at render time, not load time. Add a CI pass that renders every view against a fixture repo.