Migration This is my new public git source viewer. Source coming soon. Please contact with concerns/bugs. Contact me

git-viewer

The source for this page

default branch main
updated
section 2_AI
38fa4e6c many many changes Ryan Schanzenbacher ·

38fa4e6cd9bb9dbf0ae23ef02e50b1c75cd84ebb · 11 entries

NameTypeSize
.gitignore file 4 B
AGENTS.md file 15.5 KB
Dockerfile file 411 B
README.md file 14.8 KB
go.mod file 269 B
go.sum file 1.7 KB
gpgverif file 1.8 KB
internal dir —
main.go file 5.6 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 }}

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

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. Authentik decides who may reach the handler at all.

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.

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.

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

Add a key by adding a field to core.RepoInfo and a line in LoadRepoInfo.

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