Initial work from claude
3a50036d96eb…
· Ryan Schanzenbacher ·
@@ -0,0 +1 @@
+gsg
@@ -0,0 +1,306 @@
+# AGENTS.md
+
+Instructions for AI agents and new contributors working in this repository.
+Read this before changing code. The README describes what the program does
+for its users; this file describes why it is built the way it is and which
+properties must survive your change.
+
+## What this is
+
+`gsg` is a self-hosted web frontend for bare git repositories. It serves
+pages dynamically, reads its per-repository configuration out of each repo's
+own git config, and is designed so that adding a page, a data source, or a
+JSON endpoint is a local change rather than a change to the core.
+
+The target deployment is one binary next to a gitolite-managed repository
+root, behind a reverse proxy, on a personal server.
+
+### Goals, in priority order
+
+1. **Extensibility without core churn.** Adding a feature should mean adding
+ one file and calling it from one template. If a change requires editing
+ `internal/core`, that is a signal the design is wrong, not that the core
+ needs another special case.
+2. **Coherent theming.** Every page shares one layout, one stylesheet, one
+ set of partials. Views do not carry their own styling.
+3. **Correctness over speed of delivery.** This serves attacker-influenced
+ input to a public network. A plausible-looking fix that is not understood
+ is worse than an open bug.
+4. **Small dependency surface.** Currently: the Go standard library,
+ `github.com/yuin/goldmark` (which has no dependencies of its own), and the
+ `git` binary. Do not add a dependency without a clear reason that cannot be
+ met by the stdlib.
+
+### Non-goals
+
+- **Not a forge.** No accounts, no issues, no pull requests, no write paths.
+ Every route is `GET` and nothing mutates a repository.
+- **Not a static site generator.** An earlier version generated files ahead
+ of time. That was removed because it made diffs and arbitrary-branch
+ browsing impractical. Do not reintroduce build-time page enumeration.
+- **Not a general git host.** Cloning is served by something else
+ (git-daemon, ssh, gitolite). This is read-only presentation.
+
+## Architecture
+
+The layering is strict. Violating it is the most common way to make this
+codebase worse.
+
+```
+main.go flags, repo discovery, embedded assets
+internal/gitx/ the ONLY package that knows git exists
+internal/core/ Ctx, module registry, routes, server, cache, funcmap
+internal/modules/ the extension point: one module per concern
+internal/api/ JSON DTOs -- the serialization allowlist
+internal/site/ the route table
+templates/ layouts/ + views/
+static/ one stylesheet
+```
+
+**`internal/gitx`** wraps the `git` binary. Nothing above it may invoke git,
+parse git output, or know what a packfile is. Everything it returns is a
+plain Go value. This boundary exists so the implementation can be swapped for
+go-git or git2go by rewriting one package.
+
+**`internal/core`** knows about HTTP, templates, caching and the module
+registry. It must not know git semantics. If you find yourself importing
+`gitx` into `core` for anything beyond the `Handle`/`Repo` plumbing that is
+already there, stop and reconsider.
+
+**`internal/modules`** is where features go. A module is:
+
+```go
+type Module func(c *Ctx, args ...string) (any, error)
+```
+
+It registers itself in `init()` and is reached from templates through a
+single funcmap entry, `mod`. Three properties follow and must be preserved:
+
+- **Lazy.** A template calling `mod "authors"` is what *causes* authors to be
+ computed. A view that does not ask does not pay. This is why removing a
+ block from a template is a complete removal of its cost.
+- **Memoized twice.** Per request in `Ctx.cache`, and process-wide in the
+ SHA-keyed `core.LRU`.
+- **Declared by use.** There is no module manifest to keep in sync with the
+ templates. Do not add one.
+
+**`internal/site/routes.go`** is the file you edit most. A route is a
+`net/http` pattern plus a renderer.
+
+## Invariants
+
+These are not style preferences. Each one is either load-bearing for security
+or was a real bug that got fixed. Do not break them; if a change seems to
+require breaking one, say so explicitly rather than working around it.
+
+### 1. User ref input never reaches `git rev-parse`
+
+`rev-parse` accepts an expression language: `:/regex` searches history,
+`@{...}` reads the reflog, and git subcommands treat a leading `-` as an
+option. `gitx.Resolve` accepts only a hex SHA that already exists, or an
+exact match against the repository's own ref list. Everything else is
+rejected with a 404.
+
+Any new route that takes a ref-like path segment must resolve it through
+`Resolve` in `Server.buildCtx`, never pass it onward as a raw string.
+
+Regression cases that must keep returning 404 with no side effect:
+`--upload-pack=touch /tmp/pwned`, `:/secret`, `HEAD@{0}`, `-n`.
+
+### 2. Modules read `c.Commit`, not `c.Ref`
+
+`c.Ref` is the ref as the user spelled it. `c.Commit` is that ref resolved to
+a SHA once, at the top of the request. A ref can move between resolution and
+render; a module reading `c.Ref` would then produce content that does not
+match the ETag the server already computed. Use `c.Commit` and fall back to
+`c.Ref` only when `c.Commit` is empty (an empty repository).
+
+### 3. Anything that changes a module's output belongs in the cache key
+
+`Ctx.CacheKey` covers repo, commit, path, module name, args, **and
+`Ctx.Vars`**. Vars were missing originally and caused a real bug:
+`/compare/a..b` and `/compare/a...b` resolve to the same *head* commit, so the
+second request was served the first one's diff. If you add a new input to a
+module, add it to the key.
+
+The cache needs no invalidation logic because it is keyed on immutable SHAs.
+Do not add TTLs or manual eviction on the assumption that entries go stale —
+if an entry can go stale, the key is wrong.
+
+### 4. JSON endpoints use DTOs, never module values
+
+`internal/api` defines explicit structs with json tags. Go marshals every
+exported field by default, so serializing a module's return type means the
+day someone adds a field to `gitx.Commit`, it is public. The allowlist lives
+in one readable file on purpose.
+
+Specific product decision: **commit messages are not exposed over the JSON
+API.** `api.Commit` has no `Subject` and no `Body`. Do not add them.
+
+### 5. `/raw/` never serves `text/html`
+
+A repository can contain an `.html` file with a `<script>` tag. Serving it as
+`text/html` on this origin turns any push into stored XSS against the whole
+site — which is why GitHub puts raw content on a separate hostname. Raw
+responses are `text/plain` (or `application/octet-stream` plus
+`Content-Disposition: attachment` for binaries), with
+`X-Content-Type-Options: nosniff` and
+`Content-Security-Policy: default-src 'none'; sandbox`.
+
+If raw content is ever moved to its own hostname, keep these headers anyway.
+
+### 6. goldmark runs without `WithUnsafe`
+
+Anyone who can push can write a README, so raw HTML in markdown is untrusted
+input. goldmark drops it. This also matches Hugo's default, which matters
+because the extension set is deliberately aligned with Hugo's (GFM,
+Typographer, auto heading IDs) so a README renders identically to the same
+file on the author's blog. Keep them aligned.
+
+### 7. `exec` argv cannot contain NUL
+
+Separator bytes in git format strings must be written as git's own hex
+escapes — `%00` for `for-each-ref`, `%x00`/`%x1e` for `git log` — so that git
+emits the byte. Passing a literal `\x00` in an argument fails with
+`fork/exec: invalid argument`. See the constant block at the top of
+`gitx/git.go`; use `sepF`/`sepR` when splitting output and `escF`/`escLogF`/
+`escLogR` when building a format.
+
+### 8. Every git invocation is bounded
+
+`Repo.WithContext` binds a request deadline and an output cap. Serving means
+every git call is attacker-influenced work: without a deadline one `git log`
+on a large repository pins a core, and without a cap one diff of a huge blob
+eats the heap. The environment is scrubbed (`GIT_CONFIG_NOSYSTEM=1`, no
+`HOME`, no terminal prompt) so repository or user config cannot alter
+behaviour. New `gitx` methods must go through `Repo.run`.
+
+### 9. Graph geometry is coupled to CSS
+
+`modules/graph.go` has `rowH = 30`, and `static/style.css` has
+`table.graph td { height: 30px }`. They must match or the rails detach from
+their rows. This is the one fragile seam in the design; if you change either,
+change both.
+
+Colour is *not* coupled: the module emits `var(--lane-N)` and the stylesheet
+owns the palette. Keep it that way — do not hardcode a colour in Go.
+
+### 10. Empty repositories are a normal state
+
+A freshly created bare repo has an unborn HEAD. `Repo.Empty()` is checked at
+`Open`, and `Log`/`Tree` return nothing rather than failing. Templates must
+not index into a possibly-empty slice — use `{{ with mod "log" "1" }}{{ with
+index . 0 }}`, not `{{ index (mod "log" "1") 0 }}`, or the render aborts.
+
+## How to make common changes
+
+### Add a module
+
+One new file in `internal/modules`. Nothing else moves.
+
+```go
+func init() { core.Register("authors", authorsMod) }
+
+func authorsMod(c *core.Ctx, args ...string) (any, error) { ... }
+```
+
+Then call it from any template: `{{ with mod "authors" }}…{{ end }}`. Use
+`modOr` when absence is normal rather than an error (a missing README, for
+example) so the section simply disappears.
+
+`internal/modules/example_authors.go` exists as a live demonstration of
+exactly this. Do not delete it.
+
+### Add a page
+
+A module for the data, a view in `templates/views/`, one entry in
+`internal/site/routes.go`. Views define `main` and are parsed into separate
+template trees, because `html/template` has a single namespace for defined
+blocks and two views both defining `main` would collide. That is why
+`LoadTemplates` parses each view with the layouts rather than globbing
+everything together.
+
+### Add a JSON endpoint
+
+A DTO in `internal/api`, an `Endpoint` function that calls modules and fills
+it, one route with `core.JSON{Build: …}`. Pages and endpoints are the same
+mechanism deliberately — both take a `*Ctx`, both call the same memoized
+modules.
+
+### Add a per-repository config key
+
+A field on `core.RepoInfo` and a line in `LoadRepoInfo`. Follow the existing
+fallback pattern: the `gsg.*` key first, then the cgit/gitweb equivalent, so
+an existing cgit tree keeps working unmodified.
+
+A config key that nothing reads is a bug. `gsg.section` sat unused for a
+while; if you add a key, wire it into a template or an endpoint in the same
+change.
+
+## Building and checking
+
+```sh
+gofmt -l . # must print nothing
+go vet ./... # must be clean
+go build -o gsg .
+```
+
+There is no test suite yet. Until there is, verify changes against a fixture
+tree with at least: a bare repo with several commits, a diverged branch, a
+merge commit, a tag, a repository with no commits, and a non-bare repo. The
+divergence matters — without it, `a..b` and `a...b` produce the same output
+and a compare bug is invisible.
+
+```sh
+./gsg -root /srv/git -addr 127.0.0.1:8080 -templates ./templates
+```
+
+`-templates` re-parses on every request, which is how to iterate on the theme
+without restarting.
+
+Routes worth exercising after any change: `/`, `/{repo}/`, `/{repo}/graph/`,
+`/{repo}/tree/{ref}/{path}`, `/{repo}/blob/…`, `/{repo}/raw/…`,
+`/{repo}/compare/a...b`, `/{repo}/commit/{sha}`, plus the two JSON endpoints
+and the empty repository.
+
+If you add a test suite, start with the invariants above — they are the
+things worth asserting, and several of them are single-request checks.
+
+## Conventions
+
+- Comments explain **why**, not what. The code says what it does; the comment
+ should say what would go wrong otherwise. Several comments in this codebase
+ record a specific bug — preserve those rather than tidying them away.
+- Errors that are the client's fault carry a status:
+ `core.Errorf(http.StatusNotFound, …)`. Anything else becomes a 500 and is
+ logged at error level. A valid commit with a bad path is a 404, not a 500.
+- User-facing copy is plain and directive. Empty states say what to do ("No
+ repositories yet. Create a bare repo under the configured root."), not just
+ that something is absent.
+- The UI type is monospace throughout because every value on these pages is
+ an identifier, hash, path or count. The sans face appears only inside
+ rendered README prose. All colour lives in `:root` custom properties; do
+ not introduce a literal hex value elsewhere in the stylesheet.
+- `gsg` is a placeholder name with no expansion. If it is renamed, the module
+ path, the config section, and the footer string all move.
+
+## Known gaps
+
+Accurate as of now; update this list when you close one.
+
+- No test suite.
+- No syntax highlighting in the blob view — it returns numbered plain lines.
+ Chroma would drop into the `blob` module.
+- No pagination on `log` (caps at 200) or `graph` (caps at 150).
+- The graph loads all refs into memory before layout. Fine to a few thousand
+ commits; beyond that it needs windowing.
+- The LRU is bounded by entry count, not bytes, so a large diff and a ref
+ list occupy one slot each. The `Store` interface is the only thing a
+ size-aware replacement must satisfy.
+- Repository discovery is one level deep and happens once at startup; a repo
+ created afterwards needs a restart.
+- README relative-link rewriting assumes every link is a file. A link to a
+ directory lands on the blob view and 404s. Fixing it properly means a tree
+ lookup per link.
+- Module errors surface at render time, not load time. A CI pass that renders
+ every view against a fixture repository would catch them earlier.
@@ -0,0 +1,256 @@
+# 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
+```
+
+## The idea
+
+```go
+type Module func(c *Ctx, args ...string) (any, error)
+```
+
+Templates reach modules through one funcmap entry:
+
+```gotmpl
+{{ 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.
+
+```go
+{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/` always
+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.
+
+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.
+
+### 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 — `:/regex` searches history, `@{...}` reads the
+ reflog — and git subcommands treat a leading `-` as an option.
+ `gitx.Resolve` accepts 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 `-n` all return 404 with no side effects.
+- **Every git call is `exec.CommandContext`** with a per-request deadline
+ (`-timeout`, default 10s) and a scrubbed environment
+ (`GIT_CONFIG_NOSYSTEM=1`, no `HOME`, 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
+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.
+
+```sh
+git --git-dir=/srv/git/demo.git config gsg.section "Infrastructure"
+```
+
+## 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:
+
+```sh
+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
+
+- **No syntax highlighting.** `blob` returns numbered plain lines; Chroma
+ drops into the same module.
+- **No pagination on `log` or `graph`.** 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 `Store` interface 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.
@@ -0,0 +1,5 @@
+module gsg
+
+go 1.22
+
+require github.com/yuin/goldmark v1.8.5
@@ -0,0 +1,2 @@
+github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
+github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
@@ -0,0 +1,133 @@
+// Package api defines the JSON surface.
+//
+// DESIGN RULE: endpoints return purpose-built DTOs with explicit json
+// tags. They never marshal a module's return value directly. Go marshals
+// every exported field by default, so serializing a module value means one
+// added field silently becomes public. The allowlist lives here, in types
+// you can read in one screen, not in struct tags scattered across modules.
+package api
+
+import (
+ "fmt"
+ "time"
+
+ "gsg/internal/core"
+ "gsg/internal/gitx"
+ "gsg/internal/modules"
+)
+
+// Repo is the per-repository document. Deliberately minimal.
+type Repo struct {
+ Slug string `json:"slug"`
+ Title string `json:"title"`
+ Description string `json:"description,omitempty"`
+ Owner string `json:"owner,omitempty"`
+ Section string `json:"section,omitempty"`
+ CloneURL string `json:"clone_url,omitempty"`
+ DefaultBranch string `json:"default_branch,omitempty"`
+ LastCommit *string `json:"last_commit,omitempty"` // RFC3339, null when empty repo
+}
+
+func repoDoc(info core.RepoInfo, defaultRef string, last time.Time) Repo {
+ d := Repo{
+ Slug: info.Slug,
+ Title: info.Title,
+ Description: info.Description,
+ Owner: info.Owner,
+ Section: info.Section,
+ CloneURL: info.Clone,
+ DefaultBranch: defaultRef,
+ }
+ if !last.IsZero() {
+ s := last.UTC().Format(time.RFC3339)
+ d.LastCommit = &s
+ }
+ return d
+}
+
+// RepoEndpoint -> /{repo}/api/repo.json
+func RepoEndpoint(c *core.Ctx) (any, error) {
+ v, err := c.Mod("meta")
+ if err != nil {
+ return nil, err
+ }
+ m, ok := v.(modules.Meta)
+ if !ok {
+ return nil, fmt.Errorf("api: meta returned %T", v)
+ }
+ return repoDoc(m.RepoInfo, m.DefaultRef, m.LastCommit), nil
+}
+
+// Commit is the per-commit document.
+//
+// NOTE: no Subject, no Body. You said commit messages stay out of the JSON
+// surface, and because this is an explicit DTO rather than a marshalled
+// module value, that decision is enforced by the type -- adding a field to
+// gitx.Commit cannot leak it here.
+type Commit struct {
+ SHA string `json:"sha"`
+ Short string `json:"short"`
+ When string `json:"authored"`
+ Parents []string `json:"parents,omitempty"`
+ Files int `json:"files_changed"`
+ Added int `json:"lines_added"`
+ Deleted int `json:"lines_deleted"`
+}
+
+// CommitEndpoint -> /{repo}/api/commit/{sha}.json
+func CommitEndpoint(c *core.Ctx) (any, error) {
+ cv, err := c.Mod("commit")
+ if err != nil {
+ return nil, err
+ }
+ cm, ok := cv.(gitx.Commit)
+ if !ok {
+ return nil, fmt.Errorf("api: commit returned %T", cv)
+ }
+ doc := Commit{
+ SHA: cm.SHA,
+ Short: cm.Short,
+ When: cm.When.UTC().Format(time.RFC3339),
+ Parents: cm.Parents,
+ }
+ dv, err := c.Mod("diff")
+ if err != nil {
+ return nil, err
+ }
+ if d, ok := dv.(gitx.Diff); ok {
+ doc.Files = len(d.Stats)
+ for _, f := range d.Stats {
+ doc.Added += f.Added
+ doc.Deleted += f.Deleted
+ }
+ }
+ return doc, nil
+}
+
+// Index is the site-wide document.
+type Index struct {
+ Site string `json:"site"`
+ Generated string `json:"generated"`
+ Repos []Repo `json:"repos"`
+}
+
+// IndexEndpoint -> /api/repos.json
+func IndexEndpoint(c *core.Ctx) (any, error) {
+ v, err := c.Mod("repos")
+ if err != nil {
+ return nil, err
+ }
+ list, ok := v.([]modules.RepoSummary)
+ if !ok {
+ return nil, fmt.Errorf("api: repos returned %T", v)
+ }
+ idx := Index{
+ Site: c.Site.Name,
+ Generated: time.Now().UTC().Format(time.RFC3339),
+ Repos: make([]Repo, 0, len(list)),
+ }
+ for _, s := range list {
+ idx.Repos = append(idx.Repos, repoDoc(s.RepoInfo, "", s.LastCommit))
+ }
+ return idx, nil
+}
@@ -0,0 +1,61 @@
+package core
+
+import (
+ "container/list"
+ "sync"
+)
+
+// LRU is a bounded cache of module results keyed on immutable commit SHAs.
+// Bounded by entry COUNT, not bytes -- a diff of a huge commit and a ref
+// list cost the same one slot. If that bites, swap in a size-aware cache;
+// the Store interface is the only thing that has to hold.
+type LRU struct {
+ mu sync.Mutex
+ max int
+ ll *list.List
+ items map[string]*list.Element
+}
+
+type lruEntry struct {
+ key string
+ val any
+}
+
+func NewLRU(max int) *LRU {
+ if max <= 0 {
+ max = 2048
+ }
+ return &LRU{max: max, ll: list.New(), items: map[string]*list.Element{}}
+}
+
+func (c *LRU) Get(key string) (any, bool) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if el, ok := c.items[key]; ok {
+ c.ll.MoveToFront(el)
+ return el.Value.(*lruEntry).val, true
+ }
+ return nil, false
+}
+
+func (c *LRU) Put(key string, val any) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if el, ok := c.items[key]; ok {
+ c.ll.MoveToFront(el)
+ el.Value.(*lruEntry).val = val
+ return
+ }
+ c.items[key] = c.ll.PushFront(&lruEntry{key, val})
+ for c.ll.Len() > c.max {
+ old := c.ll.Back()
+ c.ll.Remove(old)
+ delete(c.items, old.Value.(*lruEntry).key)
+ }
+}
+
+func (c *LRU) Len() int {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.ll.Len()
+}
@@ -0,0 +1,233 @@
+// Package core is the extension point. A module computes one piece of a
+// view; a route binds modules to an output file. Adding a feature means
+// adding a module and using it from a template -- nothing here changes.
+package core
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+ "sync"
+
+ "gsg/internal/gitx"
+)
+
+// ------------------------------------------------------------------ config
+
+type Site struct {
+ Name string
+ BaseURL string
+ Root string // directory containing bare repos
+
+ // Repos is every discovered repository. Site-scoped routes (the
+ // index page, the index endpoint) read it through the `repos` module.
+ Repos []Handle
+}
+
+// Handle pairs an open repository with its configuration.
+type Handle struct {
+ Repo *gitx.Repo
+ Info RepoInfo
+}
+
+// RepoInfo is the per-repository configuration, read from the repo's own
+// git config the way cgit does. Keys, in priority order:
+//
+// [gsg] [gitweb]
+// title = ... owner = ...
+// description = ... description = ...
+// owner = ...
+// clone = git://...
+// hide = true
+// section = infra
+//
+// Set them with: git --git-dir=/srv/git/foo.git config gsg.title "Foo"
+type RepoInfo struct {
+ Slug string
+ Title string
+ Description string
+ Owner string
+ Clone string
+ Section string
+ Hidden bool
+}
+
+func LoadRepoInfo(r *gitx.Repo) RepoInfo {
+ title := r.ConfigFirst("gsg.title")
+ if title == "" {
+ title = r.Slug
+ }
+ desc := r.ConfigFirst("gsg.description", "gitweb.description")
+ if desc == "" {
+ desc = r.DescriptionFile() // cgit-compatible fallback
+ }
+ return RepoInfo{
+ Slug: r.Slug,
+ Title: title,
+ Description: desc,
+ Owner: r.ConfigFirst("gsg.owner", "gitweb.owner"),
+ Clone: r.ConfigFirst("gsg.clone", "gitweb.url"),
+ Section: r.ConfigFirst("gsg.section", "gitweb.category"),
+ Hidden: strings.EqualFold(r.Config("gsg.hide"), "true"),
+ }
+}
+
+// ----------------------------------------------------------------- context
+
+// Ctx is what a module is given: which repo, at which ref, at which path.
+// The cache makes `mod` idempotent within a single page render, so two
+// templates asking for the same module do not pay for it twice.
+type Ctx struct {
+ Site *Site
+ Repo *gitx.Repo
+ Info RepoInfo
+ Ref string // the ref as the user spelled it ("main", "v0.1.0", a sha)
+ Path string
+ Vars map[string]string
+
+ // Commit is Ref resolved to a full SHA. Everything a module computes
+ // at a given Commit is immutable, which is what makes the shared
+ // cache and ETags correct rather than merely convenient.
+ Commit string
+
+ // Store is the process-wide cache, nil in tests. Only consulted when
+ // Commit is set.
+ Store Store
+
+ mu sync.Mutex
+ cache map[string]result
+}
+
+// Store is the cross-request cache. Implemented by internal/core.LRU.
+type Store interface {
+ Get(key string) (any, bool)
+ Put(key string, v any)
+}
+
+type result struct {
+ val any
+ err error
+}
+
+func NewCtx(site *Site, r *gitx.Repo, info RepoInfo, ref string) *Ctx {
+ return &Ctx{Site: site, Repo: r, Info: info, Ref: ref,
+ Vars: map[string]string{}, cache: map[string]result{}}
+}
+
+// CacheKey identifies an immutable module result. Empty when the result
+// cannot be cached (no resolved commit).
+//
+// Vars are part of the key. They were not, originally, and the bug was
+// exactly the kind this cache is supposed to 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, not just the commit it hangs off.
+func (c *Ctx) CacheKey(name string, args []string) string {
+ if c.Commit == "" || c.Store == nil {
+ return ""
+ }
+ var b strings.Builder
+ b.WriteString(c.Info.Slug + "|" + c.Commit + "|" + c.Path + "|" + name + "|")
+ b.WriteString(strings.Join(args, "\x00"))
+ keys := make([]string, 0, len(c.Vars))
+ for k := range c.Vars {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ for _, k := range keys {
+ b.WriteString("|" + k + "=" + c.Vars[k])
+ }
+ return b.String()
+}
+
+// Derive returns a shallow copy for a different path, with a fresh cache.
+func (c *Ctx) Derive(path string, vars map[string]string) *Ctx {
+ n := NewCtx(c.Site, c.Repo, c.Info, c.Ref)
+ n.Path = path
+ for k, v := range c.Vars {
+ n.Vars[k] = v
+ }
+ for k, v := range vars {
+ n.Vars[k] = v
+ }
+ return n
+}
+
+func (c *Ctx) Var(k string) string { return c.Vars[k] }
+
+// ---------------------------------------------------------------- registry
+
+// Module computes one piece of a view. Anything a template or an endpoint
+// might want is a module: readme, tree, log, refs, graph, blame, stats.
+type Module func(c *Ctx, args ...string) (any, error)
+
+var (
+ regMu sync.RWMutex
+ registry = map[string]Module{}
+)
+
+// Register adds a module. Call it from an init() in the modules package.
+func Register(name string, m Module) {
+ regMu.Lock()
+ defer regMu.Unlock()
+ if _, dup := registry[name]; dup {
+ panic("core: duplicate module " + name)
+ }
+ registry[name] = m
+}
+
+func Names() []string {
+ regMu.RLock()
+ defer regMu.RUnlock()
+ out := make([]string, 0, len(registry))
+ for n := range registry {
+ out = append(out, n)
+ }
+ return out
+}
+
+// Mod runs a module, memoized per Ctx. This is the single entry point
+// used by both templates (via the funcmap) and JSON endpoints.
+func (c *Ctx) Mod(name string, args ...string) (any, error) {
+ key := name + "\x00" + strings.Join(args, "\x00")
+
+ c.mu.Lock()
+ if c.cache == nil {
+ c.cache = map[string]result{}
+ }
+ if r, ok := c.cache[key]; ok {
+ c.mu.Unlock()
+ return r.val, r.err
+ }
+ c.mu.Unlock()
+
+ regMu.RLock()
+ m, ok := registry[name]
+ regMu.RUnlock()
+ if !ok {
+ return nil, fmt.Errorf("no such module %q (have: %s)", name, strings.Join(Names(), ", "))
+ }
+
+ // Cross-request cache: a module's output at a fixed commit SHA cannot
+ // change, so this is safe without any invalidation logic. Nothing here
+ // is keyed on a ref name -- refs move, SHAs do not.
+ shared := c.CacheKey(name, args)
+ if shared != "" {
+ if v, ok := c.Store.Get(shared); ok {
+ c.mu.Lock()
+ c.cache[key] = result{v, nil}
+ c.mu.Unlock()
+ return v, nil
+ }
+ }
+
+ val, err := m(c, args...)
+ if err == nil && shared != "" {
+ c.Store.Put(shared, val)
+ }
+
+ c.mu.Lock()
+ c.cache[key] = result{val, err}
+ c.mu.Unlock()
+ return val, err
+}
@@ -0,0 +1,18 @@
+package core
+
+import (
+ "context"
+ "net/http"
+ "time"
+)
+
+type contexter interface{ Ctx() context.Context }
+
+type reqCtx struct{ c context.Context }
+
+func (r reqCtx) Ctx() context.Context { return r.c }
+
+func contextWithTimeout(r *http.Request, d time.Duration) (contexter, context.CancelFunc) {
+ c, cancel := context.WithTimeout(r.Context(), d)
+ return reqCtx{c}, cancel
+}
@@ -0,0 +1,5 @@
+package core
+
+import "errors"
+
+func asHTTPError(err error, target *httpError) bool { return errors.As(err, target) }
@@ -0,0 +1,129 @@
+package core
+
+import (
+ "fmt"
+ "html/template"
+ "strings"
+ "time"
+)
+
+// FuncMap is bound to a single Ctx at render time. `mod` is the only
+// entry point a template needs; everything else is sugar.
+func FuncMap(c *Ctx) template.FuncMap {
+ return template.FuncMap{
+ // mod runs a module. A template that calls it is what *causes*
+ // the work -- views that don't ask, don't pay.
+ "mod": func(name string, args ...string) (any, error) {
+ return c.Mod(name, args...)
+ },
+ // modOr swallows the error, for optional modules (no README, etc).
+ "modOr": func(name string, args ...string) any {
+ v, err := c.Mod(name, args...)
+ if err != nil {
+ return nil
+ }
+ return v
+ },
+ "has": func(name string) bool {
+ v, err := c.Mod(name)
+ return err == nil && v != nil
+ },
+ "url": func(parts ...string) string {
+ return "/" + strings.Trim(strings.Join(parts, "/"), "/")
+ },
+ "dict": dict,
+ "date": func(t time.Time) string {
+ if t.IsZero() {
+ return ""
+ }
+ return t.Format("2006-01-02 15:04")
+ },
+ "rfc3339": func(t time.Time) string { return t.Format(time.RFC3339) },
+ "trunc": func(n int, s string) string {
+ if len(s) <= n {
+ return s
+ }
+ return s[:n] + "\u2026"
+ },
+ "bytes": humanBytes,
+ "patch": renderPatch,
+ "slug": slugify,
+ "bytes64": func(n int) string { return humanBytes(int64(n)) },
+ }
+}
+
+// slugify makes a section name usable as a fragment id.
+func slugify(s string) string {
+ var b strings.Builder
+ prevDash := false
+ for _, r := range strings.ToLower(s) {
+ switch {
+ case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
+ b.WriteRune(r)
+ prevDash = false
+ case !prevDash && b.Len() > 0:
+ b.WriteByte('-')
+ prevDash = true
+ }
+ }
+ return strings.TrimRight(b.String(), "-")
+}
+
+// renderPatch tags each diff line so the stylesheet can colour it. Doing
+// this here rather than in CSS lets the gutter carry the signal instead of
+// flooding whole rows with green and red.
+func renderPatch(p string) template.HTML {
+ var b strings.Builder
+ b.WriteString(`<div class="patch">`)
+ for _, ln := range strings.Split(strings.TrimRight(p, "\n"), "\n") {
+ cls := "ctx"
+ switch {
+ case strings.HasPrefix(ln, "+++"), strings.HasPrefix(ln, "---"),
+ strings.HasPrefix(ln, "diff "), strings.HasPrefix(ln, "index "),
+ strings.HasPrefix(ln, "new file"), strings.HasPrefix(ln, "deleted file"),
+ strings.HasPrefix(ln, "similarity"), strings.HasPrefix(ln, "rename "):
+ cls = "meta"
+ case strings.HasPrefix(ln, "@@"):
+ cls = "hunk"
+ case strings.HasPrefix(ln, "+"):
+ cls = "add"
+ case strings.HasPrefix(ln, "-"):
+ cls = "del"
+ }
+ b.WriteString(`<div class="pl ` + cls + `">`)
+ b.WriteString(template.HTMLEscapeString(ln))
+ b.WriteString("</div>")
+ }
+ b.WriteString(`</div>`)
+ return template.HTML(b.String())
+}
+
+// dict builds a map for passing named args to partials, mirroring Hugo's
+// dict so shared partials stay portable later.
+func dict(kv ...any) (map[string]any, error) {
+ if len(kv)%2 != 0 {
+ return nil, fmt.Errorf("dict: odd number of arguments")
+ }
+ m := make(map[string]any, len(kv)/2)
+ for i := 0; i < len(kv); i += 2 {
+ k, ok := kv[i].(string)
+ if !ok {
+ return nil, fmt.Errorf("dict: key %d is not a string", i)
+ }
+ m[k] = kv[i+1]
+ }
+ return m, nil
+}
+
+func humanBytes(n int64) string {
+ const unit = 1024
+ if n < unit {
+ return fmt.Sprintf("%d B", n)
+ }
+ div, exp := int64(unit), 0
+ for x := n / unit; x >= unit; x /= unit {
+ div *= unit
+ exp++
+ }
+ return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGT"[exp])
+}
@@ -0,0 +1,157 @@
+package core
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "net/http"
+)
+
+// TemplateSet holds one independently-parsed template tree per view.
+// Views are parsed separately because html/template has a single namespace
+// for defined blocks -- two views both defining "main" would collide.
+type TemplateSet map[string]*template.Template
+
+// Renderer turns a Ctx into bytes plus a content type. HTML pages and JSON
+// endpoints are the same shape of thing; this is where they unify.
+type Renderer interface {
+ Render(c *Ctx, ts TemplateSet) ([]byte, error)
+ ContentType() string
+}
+
+// View renders an HTML template. The template calls modules via `mod`.
+type View struct{ Template string }
+
+func (v View) ContentType() string { return "text/html; charset=utf-8" }
+
+func (v View) Render(c *Ctx, ts TemplateSet) ([]byte, error) {
+ t, ok := ts[v.Template]
+ if !ok {
+ return nil, fmt.Errorf("no such view %q", v.Template)
+ }
+ // Clone per render so the funcmap can close over this Ctx.
+ tt, err := t.Clone()
+ if err != nil {
+ return nil, err
+ }
+ tt = tt.Funcs(FuncMap(c))
+ var buf bytes.Buffer
+ if err := tt.ExecuteTemplate(&buf, v.Template, c); err != nil {
+ return nil, fmt.Errorf("view %s: %w", v.Template, err)
+ }
+ return buf.Bytes(), nil
+}
+
+// Endpoint builds the value that will be serialized. It must return a
+// purpose-built DTO, never a raw module value -- see internal/api.
+type Endpoint func(c *Ctx) (any, error)
+
+// JSON renders an API endpoint.
+type JSON struct{ Build Endpoint }
+
+func (j JSON) ContentType() string { return "application/json; charset=utf-8" }
+
+func (j JSON) Render(c *Ctx, _ TemplateSet) ([]byte, error) {
+ v, err := j.Build(c)
+ if err != nil {
+ return nil, err
+ }
+ b, err := json.MarshalIndent(v, "", " ")
+ if err != nil {
+ return nil, err
+ }
+ return append(b, '\n'), nil
+}
+
+// ------------------------------------------------------------------ routes
+
+// Route binds a renderer to a net/http pattern (Go 1.22 wildcards).
+//
+// /{repo}/tree/{rest...} -> r.PathValue("repo"), r.PathValue("rest")
+//
+// Scope decides how the Ctx is built before Render runs.
+type Route struct {
+ Name string
+ Pattern string // e.g. "GET /{repo}/commit/{rev}"
+ Render Renderer
+ Scope Scope
+ // Immutable marks responses whose content cannot change for a given
+ // URL -- anything addressed by a commit SHA. Those get a year-long
+ // Cache-Control; ref-addressed URLs get revalidation instead.
+ Immutable bool
+}
+
+type Scope int
+
+const (
+ ScopeRepo Scope = iota // resolves {repo}, and {rest...}/{rev} if present
+ ScopeSite // no repository in the URL
+)
+
+// httpError lets a module or endpoint choose the status code.
+type httpError struct {
+ Code int
+ Err error
+}
+
+func (e httpError) Error() string { return e.Err.Error() }
+func (e httpError) Unwrap() error { return e.Err }
+
+func Errorf(code int, format string, a ...any) error {
+ return httpError{Code: code, Err: fmt.Errorf(format, a...)}
+}
+
+func statusOf(err error) int {
+ var he httpError
+ if ok := asHTTPError(err, &he); ok {
+ return he.Code
+ }
+ return http.StatusInternalServerError
+}
+
+// HeaderSetter lets a renderer add response headers. Optional: the server
+// type-asserts for it after Render succeeds.
+type HeaderSetter interface {
+ Headers(c *Ctx, h http.Header)
+}
+
+// Raw serves file bytes straight out of the object database.
+//
+// SECURITY: a repository can contain an .html file with a <script> tag. If
+// this served it as text/html on your own origin, any push would be stored
+// XSS against the whole site -- which is exactly why GitHub puts raw content
+// on raw.githubusercontent.com, a separate origin. Serving it from the same
+// host is only safe if the response cannot be interpreted as a document:
+// text/plain or octet-stream, nosniff so the browser does not second-guess
+// that, and a sandbox CSP as a backstop.
+type Raw struct{ Module string }
+
+func (Raw) ContentType() string { return "text/plain; charset=utf-8" }
+
+func (r Raw) Render(c *Ctx, _ TemplateSet) ([]byte, error) {
+ v, err := c.Mod(r.Module)
+ if err != nil {
+ return nil, err
+ }
+ b, ok := v.(interface{ Bytes() []byte })
+ if !ok {
+ return nil, fmt.Errorf("raw: module %q is not raw-serving", r.Module)
+ }
+ return b.Bytes(), nil
+}
+
+func (r Raw) Headers(c *Ctx, h http.Header) {
+ h.Set("X-Content-Type-Options", "nosniff")
+ h.Set("Content-Security-Policy", "default-src 'none'; sandbox")
+ h.Set("Content-Disposition", "inline")
+
+ v, err := c.Mod(r.Module)
+ if err != nil {
+ return
+ }
+ if bin, ok := v.(interface{ IsBinary() bool }); ok && bin.IsBinary() {
+ h.Set("Content-Type", "application/octet-stream")
+ h.Set("Content-Disposition", "attachment")
+ }
+}
@@ -0,0 +1,236 @@
+package core
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io/fs"
+ "log/slog"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+
+ "gsg/internal/gitx"
+)
+
+type Server struct {
+ Site *Site
+ Routes []Route
+ Log *slog.Logger
+ Timeout time.Duration // per-request git budget
+ MaxGit int64 // per-command output cap, bytes
+
+ // TemplateDir, when set, re-parses templates on every request. Theme
+ // iteration without a restart; leave empty in production.
+ TemplateDir string
+ TemplateFS fs.FS
+
+ mu sync.RWMutex
+ templates TemplateSet
+ tmplTag string // changes when templates change; part of every ETag
+ cache Store
+ repos map[string]Handle
+}
+
+func NewServer(site *Site, routes []Route, tfs fs.FS, cacheSize int) (*Server, error) {
+ s := &Server{
+ Site: site, Routes: routes, TemplateFS: tfs,
+ Log: slog.Default(),
+ Timeout: 10 * time.Second,
+ MaxGit: 8 << 20,
+ cache: NewLRU(cacheSize),
+ repos: map[string]Handle{},
+ }
+ for _, h := range site.Repos {
+ if !h.Info.Hidden {
+ s.repos[h.Info.Slug] = h
+ }
+ }
+ if err := s.loadTemplates(); err != nil {
+ return nil, err
+ }
+ return s, nil
+}
+
+func (s *Server) loadTemplates() error {
+ fsys := s.TemplateFS
+ set, err := LoadTemplates(fsys)
+ if err != nil {
+ return err
+ }
+ tag, err := hashFS(fsys)
+ if err != nil {
+ return err
+ }
+ s.mu.Lock()
+ s.templates, s.tmplTag = set, tag
+ s.mu.Unlock()
+ return nil
+}
+
+// hashFS digests every template so that editing the theme invalidates every
+// ETag at once. Without this, a restyled page keeps serving 304s.
+func hashFS(fsys fs.FS) (string, error) {
+ h := sha256.New()
+ err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
+ if err != nil || d.IsDir() {
+ return err
+ }
+ b, err := fs.ReadFile(fsys, p)
+ if err != nil {
+ return err
+ }
+ fmt.Fprintf(h, "%s:%x\n", p, sha256.Sum256(b))
+ return nil
+ })
+ return hex.EncodeToString(h.Sum(nil))[:12], err
+}
+
+func (s *Server) Handler() http.Handler {
+ mux := http.NewServeMux()
+ for _, rt := range s.Routes {
+ rt := rt
+ mux.HandleFunc(rt.Pattern, func(w http.ResponseWriter, r *http.Request) {
+ s.serve(rt, w, r)
+ })
+ }
+ return mux
+}
+
+func (s *Server) serve(rt Route, w http.ResponseWriter, r *http.Request) {
+ if s.TemplateDir != "" {
+ if err := s.loadTemplates(); err != nil {
+ s.fail(w, r, rt, err)
+ return
+ }
+ }
+
+ ctx, cancel := contextWithTimeout(r, s.Timeout)
+ defer cancel()
+
+ c, err := s.buildCtx(ctx, rt, r)
+ if err != nil {
+ s.fail(w, r, rt, err)
+ return
+ }
+
+ // ETag is derived from the resolved commit SHA, not the response body,
+ // so a conditional request costs one ref lookup instead of a render.
+ s.mu.RLock()
+ tag := s.tmplTag
+ ts := s.templates
+ s.mu.RUnlock()
+
+ // Vars carry the second endpoint of a compare, so a moving base ref
+ // still changes the ETag.
+ etag := `"` + shortHash(rt.Name, r.URL.Path, r.URL.RawQuery,
+ c.Commit, c.Vars["baseSHA"], tag) + `"`
+ w.Header().Set("ETag", etag)
+ if rt.Immutable && c.Commit != "" {
+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
+ } else {
+ w.Header().Set("Cache-Control", "no-cache")
+ }
+ if match := r.Header.Get("If-None-Match"); match != "" && strings.Contains(match, etag) {
+ w.WriteHeader(http.StatusNotModified)
+ return
+ }
+
+ body, err := rt.Render.Render(c, ts)
+ if err != nil {
+ s.fail(w, r, rt, err)
+ return
+ }
+ w.Header().Set("Content-Type", rt.Render.ContentType())
+ if hs, ok := rt.Render.(HeaderSetter); ok {
+ hs.Headers(c, w.Header())
+ }
+ w.Write(body)
+}
+
+func (s *Server) buildCtx(ctx contexter, rt Route, r *http.Request) (*Ctx, error) {
+ if rt.Scope == ScopeSite {
+ c := NewCtx(s.Site, nil, RepoInfo{}, "")
+ c.Store = s.cache
+ return c, nil
+ }
+
+ slug := r.PathValue("repo")
+ h, ok := s.repos[slug]
+ if !ok {
+ return nil, Errorf(http.StatusNotFound, "no such repository %q", slug)
+ }
+ repo := h.Repo.WithContext(ctx.Ctx(), s.MaxGit)
+
+ // {rest...} carries ref and path fused together; {rev} is a bare ref;
+ // {range} is "base..head" or "base...head".
+ ref, path := "", ""
+ var base string
+ dots := "2"
+
+ switch {
+ case r.PathValue("range") != "":
+ rng := r.PathValue("range")
+ var head string
+ if b, hd, ok := strings.Cut(rng, "..."); ok {
+ base, head, dots = b, hd, "3"
+ } else if b, hd, ok := strings.Cut(rng, ".."); ok {
+ base, head = b, hd
+ } else {
+ return nil, Errorf(http.StatusBadRequest, "compare needs base..head")
+ }
+ ref = head
+ case r.PathValue("rest") != "":
+ ref, path = repo.SplitRefPath(r.PathValue("rest"))
+ case r.PathValue("rev") != "":
+ ref = r.PathValue("rev")
+ default:
+ ref = repo.HeadRef()
+ }
+
+ c := NewCtx(s.Site, repo, h.Info, ref)
+ c.Path = path
+ c.Store = s.cache
+
+ if !repo.Empty() {
+ sha, err := repo.Resolve(ref)
+ if err != nil {
+ return nil, Errorf(http.StatusNotFound, "%v", err)
+ }
+ c.Commit = sha
+ }
+ if base != "" {
+ baseSHA, err := repo.Resolve(base)
+ if err != nil {
+ return nil, Errorf(http.StatusNotFound, "%v", err)
+ }
+ c.Vars["base"], c.Vars["head"] = base, ref
+ c.Vars["baseSHA"], c.Vars["headSHA"] = baseSHA, c.Commit
+ c.Vars["dots"] = dots
+ }
+ return c, nil
+}
+
+func (s *Server) fail(w http.ResponseWriter, r *http.Request, rt Route, err error) {
+ code := statusOf(err)
+ if errors.Is(err, gitx.ErrTruncated) {
+ code = http.StatusRequestEntityTooLarge
+ }
+ if code >= 500 {
+ s.Log.Error("render failed", "route", rt.Name, "path", r.URL.Path, "err", err)
+ } else {
+ s.Log.Info("request rejected", "route", rt.Name, "path", r.URL.Path, "status", code, "err", err)
+ }
+ http.Error(w, http.StatusText(code), code)
+}
+
+func shortHash(parts ...string) string {
+ h := sha256.New()
+ for _, p := range parts {
+ h.Write([]byte(p))
+ h.Write([]byte{0})
+ }
+ return hex.EncodeToString(h.Sum(nil))[:16]
+}
@@ -0,0 +1,39 @@
+package core
+
+import (
+ "fmt"
+ "html/template"
+ "io/fs"
+ "path"
+)
+
+// LoadTemplates parses each views/*.html into its own tree alongside every
+// layouts/*.html. Returns a set keyed by view filename.
+//
+// Placeholder funcs are installed at parse time; the real ones are bound
+// per render, when a Ctx exists to close over.
+func LoadTemplates(fsys fs.FS) (TemplateSet, error) {
+ layouts, err := fs.Glob(fsys, "layouts/*.html")
+ if err != nil {
+ return nil, err
+ }
+ views, err := fs.Glob(fsys, "views/*.html")
+ if err != nil {
+ return nil, err
+ }
+ if len(views) == 0 {
+ return nil, fmt.Errorf("no templates under views/")
+ }
+
+ set := TemplateSet{}
+ for _, v := range views {
+ name := path.Base(v)
+ t := template.New(name).Funcs(FuncMap(&Ctx{}))
+ files := append(append([]string{}, layouts...), v)
+ if t, err = t.ParseFS(fsys, files...); err != nil {
+ return nil, fmt.Errorf("parse %s: %w", v, err)
+ }
+ set[name] = t
+ }
+ return set, nil
+}
@@ -0,0 +1,455 @@
+// Package gitx is the only place that knows how git works.
+// Everything above it sees plain Go values. Swap this for go-git or
+// git2go later without touching a single module or template.
+package gitx
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// Separators. exec() argv cannot contain NUL bytes, so the FORMAT strings
+// use git's own hex escapes and git emits the real bytes; we split on those.
+// (for-each-ref spells them %00, git-log spells them %x00.)
+const (
+ sepF = "\x00" // field separator, as it appears in git's OUTPUT
+ sepR = "\x1e" // record separator, as it appears in git's OUTPUT
+
+ escF = "%00" // field separator, as written in a for-each-ref format
+ escLogF = "%x00" // field separator, as written in a git-log format
+ escLogR = "%x1e" // record separator, as written in a git-log format
+)
+
+type Repo struct {
+ Dir string // path to the git dir (bare repo, or <worktree>/.git)
+ Slug string // url-safe short name, derived from the directory
+ empty bool // no commits yet (unborn HEAD)
+
+ ctx context.Context
+ maxBytes int64
+}
+
+// WithContext returns a view of the repo bound to a request context and an
+// output cap. Serving means every git invocation is attacker-influenced
+// work: without a deadline, one `git log` on a huge repo pins a core, and
+// without a cap one diff of a 200MB blob eats the heap.
+func (r *Repo) WithContext(ctx context.Context, maxBytes int64) *Repo {
+ c := *r
+ c.ctx = ctx
+ c.maxBytes = maxBytes
+ return &c
+}
+
+// Empty reports whether the repository has no commits. This is a normal
+// state for a freshly created bare repo, not an error -- Log and Tree
+// return nothing rather than failing, so a view still renders.
+func (r *Repo) Empty() bool { return r.empty }
+
+func Open(dir string) (*Repo, error) {
+ gitDir := dir
+ if st, err := os.Stat(filepath.Join(dir, ".git")); err == nil && st.IsDir() {
+ gitDir = filepath.Join(dir, ".git")
+ }
+ r := &Repo{
+ Dir: gitDir,
+ Slug: strings.TrimSuffix(filepath.Base(strings.TrimSuffix(dir, "/")), ".git"),
+ }
+ if _, err := r.run("rev-parse", "--git-dir"); err != nil {
+ return nil, fmt.Errorf("open %s: %w", dir, err)
+ }
+ _, err := r.run("rev-parse", "--verify", "--quiet", "HEAD")
+ r.empty = err != nil
+ return r, nil
+}
+
+// ErrTruncated is returned alongside partial output that hit maxBytes.
+var ErrTruncated = fmt.Errorf("output truncated")
+
+func (r *Repo) run(args ...string) ([]byte, error) {
+ ctx := r.ctx
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ full := append([]string{"--git-dir", r.Dir}, args...)
+ cmd := exec.CommandContext(ctx, "git", full...)
+ // Refuse to inherit anything that alters git's behaviour.
+ cmd.Env = []string{"GIT_CONFIG_NOSYSTEM=1", "HOME=/nonexistent", "GIT_TERMINAL_PROMPT=0", "PATH=" + os.Getenv("PATH")}
+
+ var out, errb bytes.Buffer
+ cmd.Stdout, cmd.Stderr = &out, &errb
+ err := cmd.Run()
+
+ if r.maxBytes > 0 && int64(out.Len()) > r.maxBytes {
+ return out.Bytes()[:r.maxBytes], ErrTruncated
+ }
+ if err != nil {
+ if ctx.Err() != nil {
+ return nil, fmt.Errorf("git %s: %w", args[0], ctx.Err())
+ }
+ return nil, fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(errb.String()))
+ }
+ return out.Bytes(), nil
+}
+
+// ------------------------------------------------------------------ config
+
+// Config reads a key from the repository's own config file, the way cgit
+// does with enable-git-config. A missing key is empty, not an error.
+func (r *Repo) Config(key string) string {
+ out, err := r.run("config", "--get", key)
+ if err != nil {
+ return "" // exit 1 == key absent
+ }
+ return strings.TrimSpace(string(out))
+}
+
+// ConfigFirst returns the first key that has a value, for fallback chains
+// such as gsg.description -> gitweb.description.
+func (r *Repo) ConfigFirst(keys ...string) string {
+ for _, k := range keys {
+ if v := r.Config(k); v != "" {
+ return v
+ }
+ }
+ return ""
+}
+
+const placeholderDesc = "Unnamed repository; edit this file 'description' to name the repository."
+
+// DescriptionFile reads the legacy `description` file, ignoring git's placeholder.
+func (r *Repo) DescriptionFile() string {
+ b, err := os.ReadFile(filepath.Join(r.Dir, "description"))
+ if err != nil {
+ return ""
+ }
+ s := strings.TrimSpace(string(b))
+ if s == placeholderDesc {
+ return ""
+ }
+ return s
+}
+
+// -------------------------------------------------------------------- refs
+
+type Ref struct {
+ Name string
+ Kind string // "branch" | "tag"
+ Target string // commit sha
+ When time.Time
+ Title string
+}
+
+func (r *Repo) Refs() ([]Ref, error) {
+ const f = "%(refname:short)" + escF + "%(objectname)" + escF +
+ "%(creatordate:iso-strict)" + escF + "%(subject)" + escF + "%(refname)"
+ out, err := r.run("for-each-ref", "--format="+f, "refs/heads", "refs/tags")
+ if err != nil {
+ return nil, err
+ }
+ var refs []Ref
+ for _, line := range strings.Split(strings.TrimRight(string(out), "\n"), "\n") {
+ if line == "" {
+ continue
+ }
+ p := strings.Split(line, sepF)
+ if len(p) < 5 {
+ continue
+ }
+ kind := "branch"
+ if strings.HasPrefix(p[4], "refs/tags/") {
+ kind = "tag"
+ }
+ t, _ := time.Parse(time.RFC3339, p[2])
+ refs = append(refs, Ref{Name: p[0], Kind: kind, Target: p[1], When: t, Title: p[3]})
+ }
+ return refs, nil
+}
+
+var shaRE = regexp.MustCompile(`^[0-9a-fA-F]{7,40}$`)
+
+// Resolve turns user-supplied ref text into a commit SHA.
+//
+// SECURITY: the input is never handed to `git rev-parse`. rev-parse accepts
+// an expression language (`:/regex` searches history, `@{...}` reads the
+// reflog) and git subcommands treat a leading `-` as an option. So input is
+// either a hex SHA that must already exist, or an exact match against the
+// repo's own ref list. Everything else is rejected.
+func (r *Repo) Resolve(ref string) (string, error) {
+ if ref == "" || ref == "HEAD" {
+ ref = r.HeadRef()
+ }
+ if strings.ContainsAny(ref, "\x00\n") || strings.HasPrefix(ref, "-") {
+ return "", fmt.Errorf("invalid ref %q", ref)
+ }
+ if shaRE.MatchString(ref) {
+ if _, err := r.run("cat-file", "-e", ref+"^{commit}"); err != nil {
+ return "", fmt.Errorf("unknown commit %q", ref)
+ }
+ out, err := r.run("rev-parse", "--verify", ref+"^{commit}")
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(string(out)), nil
+ }
+ refs, err := r.Refs()
+ if err != nil {
+ return "", err
+ }
+ for _, x := range refs {
+ if x.Name == ref {
+ out, err := r.run("rev-parse", "--verify", x.Target+"^{commit}")
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(string(out)), nil
+ }
+ }
+ return "", fmt.Errorf("unknown ref %q", ref)
+}
+
+// SplitRefPath separates "feature/x/src/main.go" into ref "feature/x" and
+// path "src/main.go" by matching the LONGEST prefix that is a real ref.
+// Branch names contain slashes, so a naive split at the first "/" is wrong;
+// cgit dodges this with ?h= query params, GitHub resolves against the ref
+// list. This does the latter.
+func (r *Repo) SplitRefPath(rest string) (ref, path string) {
+ rest = strings.Trim(rest, "/")
+ if rest == "" {
+ return r.HeadRef(), ""
+ }
+ names := map[string]bool{}
+ if refs, err := r.Refs(); err == nil {
+ for _, x := range refs {
+ names[x.Name] = true
+ }
+ }
+ segs := strings.Split(rest, "/")
+ for i := len(segs); i > 0; i-- {
+ cand := strings.Join(segs[:i], "/")
+ if names[cand] || shaRE.MatchString(cand) {
+ return cand, strings.Join(segs[i:], "/")
+ }
+ }
+ // No ref matched: treat the whole thing as a path at HEAD.
+ return r.HeadRef(), rest
+}
+
+// HeadRef resolves the repo's default branch, rather than guessing main/master.
+func (r *Repo) HeadRef() string {
+ out, err := r.run("symbolic-ref", "--short", "HEAD")
+ if err != nil {
+ return "HEAD"
+ }
+ return strings.TrimSpace(string(out))
+}
+
+// ----------------------------------------------------------------- commits
+
+type Commit struct {
+ SHA string
+ Short string
+ Author string
+ Email string
+ When time.Time
+ Subject string
+ Body string
+ Parents []string
+}
+
+// Log walks history from rev in ONE git process. n <= 0 means unbounded.
+// Never call this once per commit; fork overhead dominates at scale.
+func (r *Repo) Log(rev string, n int) ([]Commit, error) {
+ if r.empty {
+ return nil, nil
+ }
+ format := strings.Join([]string{"%H", "%an", "%ae", "%aI", "%P", "%s", "%b"}, escLogF)
+ args := []string{"log", "--format=" + escLogR + format}
+ if n > 0 {
+ args = append(args, "-n", strconv.Itoa(n))
+ }
+ args = append(args, rev)
+ return r.parseLog(r.run(args...))
+}
+
+func (r *Repo) parseLog(out []byte, err error) ([]Commit, error) {
+ if err != nil {
+ return nil, err
+ }
+ var commits []Commit
+ for _, rec := range strings.Split(string(out), sepR) {
+ rec = strings.TrimRight(rec, "\n")
+ if rec == "" {
+ continue
+ }
+ p := strings.SplitN(rec, sepF, 7)
+ if len(p) < 7 {
+ continue
+ }
+ t, _ := time.Parse(time.RFC3339, p[3])
+ c := Commit{
+ SHA: p[0], Author: p[1], Email: p[2], When: t,
+ Subject: p[5], Body: strings.TrimSpace(p[6]),
+ }
+ if len(c.SHA) >= 8 {
+ c.Short = c.SHA[:8]
+ }
+ if p[4] != "" {
+ c.Parents = strings.Fields(p[4])
+ }
+ commits = append(commits, c)
+ }
+ return commits, nil
+}
+
+// MergeBase returns the common ancestor of two resolved commits.
+func (r *Repo) MergeBase(a, b string) (string, error) {
+ out, err := r.run("merge-base", a, b)
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(string(out)), nil
+}
+
+// LogAll walks every ref in topological order. This is the input to the
+// branch graph: --topo-order guarantees a child always precedes its
+// parents, which the lane assignment depends on.
+func (r *Repo) LogAll(n int) ([]Commit, error) {
+ if r.empty {
+ return nil, nil
+ }
+ format := strings.Join([]string{"%H", "%an", "%ae", "%aI", "%P", "%s", "%b"}, escLogF)
+ return r.parseLog(r.run("log", "--all", "--topo-order", "--date-order",
+ "-n", strconv.Itoa(n), "--format="+escLogR+format))
+}
+
+// ------------------------------------------------------------------- trees
+
+type Entry struct {
+ Mode string
+ Type string // "blob" | "tree" | "commit" (submodule)
+ SHA string
+ Size int64
+ Name string // basename
+ Path string // full path from repo root
+}
+
+func (e Entry) IsDir() bool { return e.Type == "tree" }
+
+// Tree lists one directory level at rev.
+func (r *Repo) Tree(rev, path string) ([]Entry, error) {
+ if r.empty {
+ return nil, nil
+ }
+ spec := rev + ":"
+ if path != "" && path != "." {
+ spec = rev + ":" + path
+ }
+ out, err := r.run("ls-tree", "-z", "--long", spec)
+ if err != nil {
+ return nil, err
+ }
+ var entries []Entry
+ for _, rec := range strings.Split(string(out), "\x00") {
+ if rec == "" {
+ continue
+ }
+ head, name, ok := strings.Cut(rec, "\t")
+ if !ok {
+ continue
+ }
+ f := strings.Fields(head)
+ if len(f) < 3 {
+ continue
+ }
+ e := Entry{Mode: f[0], Type: f[1], SHA: f[2], Name: name}
+ if len(f) > 3 {
+ e.Size, _ = strconv.ParseInt(f[3], 10, 64)
+ }
+ e.Path = strings.TrimPrefix(path+"/"+name, "/")
+ entries = append(entries, e)
+ }
+ return entries, nil
+}
+
+// ------------------------------------------------------------------ diffs
+
+type FileStat struct {
+ Added, Deleted int
+ Path, OldPath string
+ Status string
+}
+
+type Diff struct {
+ Stats []FileStat
+ Patch string
+ Truncated bool
+}
+
+// DiffRange returns the patch between two resolved commits.
+// Callers MUST pass SHAs from Resolve, never raw user input.
+func (r *Repo) DiffRange(base, head string, threeDot bool) (Diff, error) {
+ sep := ".."
+ if threeDot {
+ sep = "..."
+ }
+ return r.diff("diff", "-p", "--no-color", "--no-ext-diff", "-M", "--numstat", base+sep+head)
+}
+
+// DiffCommit returns the patch for a single commit. --root makes the first
+// commit diff against the empty tree instead of erroring.
+func (r *Repo) DiffCommit(sha string) (Diff, error) {
+ return r.diff("diff-tree", "-p", "--root", "--no-color", "--no-ext-diff",
+ "-M", "--numstat", "--format=", sha)
+}
+
+func (r *Repo) diff(args ...string) (Diff, error) {
+ var d Diff
+ raw, err := r.run(args...)
+ if err == ErrTruncated {
+ d.Truncated = true
+ } else if err != nil {
+ return d, err
+ }
+ body := string(raw)
+ // numstat block comes first, then the patch; they are separated by the
+ // first line starting with "diff --git".
+ if i := strings.Index(body, "diff --git"); i >= 0 {
+ d.Stats = parseNumstat(body[:i])
+ d.Patch = body[i:]
+ } else {
+ d.Stats = parseNumstat(body)
+ }
+ return d, nil
+}
+
+func parseNumstat(s string) []FileStat {
+ var out []FileStat
+ for _, line := range strings.Split(s, "\n") {
+ f := strings.Split(strings.TrimSpace(line), "\t")
+ if len(f) < 3 {
+ continue
+ }
+ fs := FileStat{Path: f[2], Status: "M"}
+ if f[0] == "-" {
+ fs.Status = "B" // binary
+ } else {
+ fs.Added, _ = strconv.Atoi(f[0])
+ fs.Deleted, _ = strconv.Atoi(f[1])
+ }
+ out = append(out, fs)
+ }
+ return out
+}
+
+// Blob returns file contents at rev.
+func (r *Repo) Blob(rev, path string) ([]byte, error) {
+ return r.run("cat-file", "blob", rev+":"+path)
+}
@@ -0,0 +1,86 @@
+package modules
+
+import (
+ "bytes"
+ "net/http"
+ "strings"
+ "unicode/utf8"
+
+ "gsg/internal/core"
+)
+
+type Blob struct {
+ Path string
+ Size int
+ Binary bool
+ Lines []Line
+}
+
+type Line struct {
+ N int
+ Text string
+}
+
+func init() { core.Register("blob", blobMod) }
+
+// blob: file contents at Ctx.Path, split into numbered lines.
+// Binary detection is git's own heuristic: a NUL in the first 8000 bytes.
+func blobMod(c *core.Ctx, args ...string) (any, error) {
+ path := c.Path
+ if len(args) > 0 {
+ path = args[0]
+ }
+ if path == "" {
+ return nil, core.Errorf(http.StatusNotFound, "no path")
+ }
+ raw, err := c.Repo.Blob(c.Commit, path)
+ if err != nil {
+ return nil, core.Errorf(http.StatusNotFound, "no such file %q", path)
+ }
+ b := Blob{Path: path, Size: len(raw)}
+
+ head := raw
+ if len(head) > 8000 {
+ head = head[:8000]
+ }
+ if bytes.IndexByte(head, 0) >= 0 || !utf8.Valid(head) {
+ b.Binary = true
+ return b, nil
+ }
+ for i, t := range strings.Split(strings.TrimRight(string(raw), "\n"), "\n") {
+ b.Lines = append(b.Lines, Line{N: i + 1, Text: t})
+ }
+ return b, nil
+}
+
+// RawBlob is the unmodified object, for the /raw/ route. Kept separate from
+// Blob so the line-split view and the byte stream cache independently.
+type RawBlob struct {
+ Path string
+ Data []byte
+ Binary bool
+}
+
+func (r RawBlob) Bytes() []byte { return r.Data }
+func (r RawBlob) IsBinary() bool { return r.Binary }
+
+func init() { core.Register("blobraw", blobRawMod) }
+
+func blobRawMod(c *core.Ctx, args ...string) (any, error) {
+ path := c.Path
+ if len(args) > 0 {
+ path = args[0]
+ }
+ if path == "" {
+ return nil, core.Errorf(http.StatusNotFound, "no path")
+ }
+ raw, err := c.Repo.Blob(c.Commit, path)
+ if err != nil {
+ return nil, core.Errorf(http.StatusNotFound, "no such file %q", path)
+ }
+ head := raw
+ if len(head) > 8000 {
+ head = head[:8000]
+ }
+ return RawBlob{Path: path, Data: raw, Binary: bytes.IndexByte(head, 0) >= 0}, nil
+}
@@ -0,0 +1,38 @@
+package modules
+
+import (
+ "net/http"
+
+ "gsg/internal/core"
+ "gsg/internal/gitx"
+)
+
+func init() {
+ core.Register("commit", commitMod)
+ core.Register("diff", diffMod)
+}
+
+// commit: metadata for the single commit at Ctx.Commit.
+func commitMod(c *core.Ctx, args ...string) (any, error) {
+ if c.Commit == "" {
+ return nil, core.Errorf(http.StatusNotFound, "no commit")
+ }
+ log, err := c.Repo.Log(c.Commit, 1)
+ if err != nil {
+ return nil, err
+ }
+ if len(log) == 0 {
+ return nil, core.Errorf(http.StatusNotFound, "commit %s not found", c.Commit)
+ }
+ return log[0], nil
+}
+
+// diff: the patch for Ctx.Commit. This is the module that static
+// generation made impractical -- here it is one git process, cached
+// forever against an immutable SHA.
+func diffMod(c *core.Ctx, args ...string) (any, error) {
+ if c.Commit == "" {
+ return gitx.Diff{}, nil
+ }
+ return c.Repo.DiffCommit(c.Commit)
+}
@@ -0,0 +1,59 @@
+package modules
+
+import (
+ "net/http"
+
+ "gsg/internal/core"
+ "gsg/internal/gitx"
+)
+
+type Compare struct {
+ Base, Head string // as the user spelled them
+ BaseSHA, HeadSHA string
+ MergeBase string
+ ThreeDot bool
+ Diff gitx.Diff
+ Commits []gitx.Commit // what Head has that Base does not
+}
+
+func init() { core.Register("compare", compareMod) }
+
+// compare: diff between two arbitrary commits.
+//
+// Ctx.Vars carries the pair, already resolved to SHAs by the server -- so
+// the ref text never reaches git, same rule as everywhere else.
+//
+// "a...b" (three dots) 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. "a..b" is the literal endpoint difference.
+func compareMod(c *core.Ctx, args ...string) (any, error) {
+ base, head := c.Var("baseSHA"), c.Var("headSHA")
+ if base == "" || head == "" {
+ return nil, core.Errorf(http.StatusBadRequest, "compare needs two commits")
+ }
+ cmp := Compare{
+ Base: c.Var("base"), Head: c.Var("head"),
+ BaseSHA: base, HeadSHA: head,
+ ThreeDot: c.Var("dots") == "3",
+ }
+
+ from := base
+ if cmp.ThreeDot {
+ mb, err := c.Repo.MergeBase(base, head)
+ if err != nil {
+ return nil, core.Errorf(http.StatusNotFound, "no common ancestor")
+ }
+ cmp.MergeBase, from = mb, mb
+ }
+
+ d, err := c.Repo.DiffRange(from, head, false)
+ if err != nil && err != gitx.ErrTruncated {
+ return nil, err
+ }
+ cmp.Diff = d
+
+ if log, err := c.Repo.Log(from+".."+head, 250); err == nil {
+ cmp.Commits = log
+ }
+ return cmp, nil
+}
@@ -0,0 +1,52 @@
+package modules
+
+import (
+ "sort"
+ "strconv"
+
+ "gsg/internal/core"
+)
+
+// This file is the ENTIRE cost of adding a feature. No core change, no
+// route change, no registration list to update -- write the module, then
+// call `mod "authors"` from any template that wants it.
+
+type Author struct {
+ Name string
+ Commits int
+}
+
+func init() { core.Register("authors", authorsMod) }
+
+// authors: commit counts per author over the last N commits (default 500).
+func authorsMod(c *core.Ctx, args ...string) (any, error) {
+ n := 500
+ if len(args) > 0 {
+ if v, err := strconv.Atoi(args[0]); err == nil {
+ n = v
+ }
+ }
+ rev := c.Commit
+ if rev == "" {
+ rev = c.Ref
+ }
+ log, err := c.Repo.Log(rev, n)
+ if err != nil {
+ return nil, err
+ }
+ counts := map[string]int{}
+ for _, cm := range log {
+ counts[cm.Author]++
+ }
+ out := make([]Author, 0, len(counts))
+ for name, n := range counts {
+ out = append(out, Author{Name: name, Commits: n})
+ }
+ sort.Slice(out, func(i, j int) bool {
+ if out[i].Commits != out[j].Commits {
+ return out[i].Commits > out[j].Commits
+ }
+ return out[i].Name < out[j].Name
+ })
+ return out, nil
+}
@@ -0,0 +1,178 @@
+package modules
+
+import (
+ "fmt"
+ "html/template"
+ "strconv"
+ "strings"
+
+ "gsg/internal/core"
+ "gsg/internal/gitx"
+)
+
+// Graph is the branch/commit DAG laid out in lanes, one row per commit.
+//
+// Layout runs in a single pass over --topo-order output, which guarantees a
+// child is emitted before its parents. `active` holds, per lane, the SHA that
+// lane is currently waiting to draw. A commit claims the lane that was
+// expecting it (or a fresh one), hands that lane to its first parent, and
+// opens a new lane for each additional parent -- which is exactly what makes
+// a merge fork visually.
+type Graph struct {
+ Rows []Row
+ Lanes int
+}
+
+type Row struct {
+ Commit gitx.Commit
+ Lane int
+ Refs []gitx.Ref // branch/tag tips pointing here
+ // Through are lanes that pass this row untouched (draw a full-height
+ // vertical). In are lanes merging into this commit (diagonal from the
+ // top edge to the node). Out are lanes this commit forks into
+ // (diagonal from the node to the bottom edge).
+ Through []int
+ In []int
+ Out []int
+
+ // Rail is the row's slice of the graph, drawn as SVG. Geometry lives
+ // here because it IS the data; colour does not -- every stroke refers
+ // to a CSS custom property, so the theme still owns the palette.
+ Rail template.HTML
+}
+
+func init() { core.Register("graph", graphMod) }
+
+// graph: the DAG across every ref. {{ mod "graph" "120" }}
+func graphMod(c *core.Ctx, args ...string) (any, error) {
+ n := 150
+ if len(args) > 0 {
+ if v, err := strconv.Atoi(args[0]); err == nil && v > 0 {
+ n = v
+ }
+ }
+ commits, err := c.Repo.LogAll(n)
+ if err != nil {
+ return nil, err
+ }
+
+ tips := map[string][]gitx.Ref{}
+ if refs, err := c.Repo.Refs(); err == nil {
+ for _, r := range refs {
+ tips[r.Target] = append(tips[r.Target], r)
+ }
+ }
+
+ var g Graph
+ var active []string // lane -> SHA that lane is waiting for ("" = free)
+
+ for _, cm := range commits {
+ row := Row{Commit: cm, Refs: tips[cm.SHA]}
+
+ // Every lane already expecting this commit is an incoming edge.
+ mine := -1
+ for i, want := range active {
+ if want == cm.SHA {
+ if mine == -1 {
+ mine = i
+ } else {
+ row.In = append(row.In, i)
+ active[i] = "" // that lane terminates here
+ }
+ }
+ }
+ if mine == -1 { // a tip: no child has claimed it
+ mine = free(&active)
+ }
+ row.Lane = mine
+
+ // Lanes untouched by this row pass straight through.
+ for i, want := range active {
+ if i != mine && want != "" && !contains(row.In, i) {
+ row.Through = append(row.Through, i)
+ }
+ }
+
+ if len(cm.Parents) == 0 {
+ active[mine] = "" // root commit closes its lane
+ } else {
+ active[mine] = cm.Parents[0]
+ for _, p := range cm.Parents[1:] {
+ k := free(&active)
+ active[k] = p
+ row.Out = append(row.Out, k)
+ }
+ }
+
+ if len(active) > g.Lanes {
+ g.Lanes = len(active)
+ }
+ g.Rows = append(g.Rows, row)
+ }
+ for i := range g.Rows {
+ g.Rows[i].Rail = rail(g.Rows[i], g.Lanes)
+ }
+ if g.Lanes == 0 {
+ g.Lanes = 1
+ }
+ return g, nil
+}
+
+// Row geometry, in px. Height must match the table row height in CSS.
+const (
+ laneW = 15.0
+ rowH = 30.0
+ radius = 3.6
+)
+
+func lane(i int) string { return fmt.Sprintf("var(--lane-%d)", i%6) }
+
+func x(i int) float64 { return float64(i)*laneW + laneW/2 }
+
+func rail(r Row, lanes int) template.HTML {
+ var b strings.Builder
+ w := float64(lanes) * laneW
+ fmt.Fprintf(&b, `<svg class="rail-svg" width="%.0f" height="%.0f" viewBox="0 0 %.0f %.0f" aria-hidden="true">`, w, rowH, w, rowH)
+
+ line := func(x1, y1, x2, y2 float64, c string) {
+ fmt.Fprintf(&b, `<path d="M%.1f %.1f C%.1f %.1f %.1f %.1f %.1f %.1f" stroke="%s" fill="none" stroke-width="1.6"/>`,
+ x1, y1, x1, (y1+y2)/2, x2, (y1+y2)/2, x2, y2, c)
+ }
+
+ for _, l := range r.Through { // uninvolved lanes pass straight down
+ line(x(l), 0, x(l), rowH, lane(l))
+ }
+ for _, l := range r.In { // merged-in lanes end at this node
+ line(x(l), 0, x(r.Lane), rowH/2, lane(l))
+ }
+ for _, l := range r.Out { // extra parents fork away below
+ line(x(r.Lane), rowH/2, x(l), rowH, lane(l))
+ }
+ // the commit's own lane: top half only if a child pointed here
+ line(x(r.Lane), 0, x(r.Lane), rowH/2, lane(r.Lane))
+ line(x(r.Lane), rowH/2, x(r.Lane), rowH, lane(r.Lane))
+
+ fmt.Fprintf(&b, `<circle cx="%.1f" cy="%.1f" r="%.1f" fill="var(--bg)" stroke="%s" stroke-width="2"/>`,
+ x(r.Lane), rowH/2, radius, lane(r.Lane))
+ b.WriteString(`</svg>`)
+ return template.HTML(b.String())
+}
+
+func free(active *[]string) int {
+ for i, v := range *active {
+ if v == "" {
+ return i
+ }
+ }
+ *active = append(*active, "")
+ return len(*active) - 1
+}
+
+func contains(xs []int, n int) bool {
+ for _, x := range xs {
+ if x == n {
+ return true
+ }
+ }
+ return false
+}
@@ -0,0 +1,24 @@
+package modules
+
+import (
+ "strconv"
+
+ "gsg/internal/core"
+)
+
+func init() { core.Register("log", logMod) }
+
+// log: recent commits. {{ mod "log" "20" }} -- default 25, "0" = all.
+func logMod(c *core.Ctx, args ...string) (any, error) {
+ n := 25
+ if len(args) > 0 {
+ if v, err := strconv.Atoi(args[0]); err == nil {
+ n = v
+ }
+ }
+ rev := c.Commit
+ if rev == "" {
+ rev = c.Ref
+ }
+ return c.Repo.Log(rev, n)
+}
@@ -0,0 +1,83 @@
+package modules
+
+import (
+ "bytes"
+ "html/template"
+ "path"
+ "strings"
+
+ "github.com/yuin/goldmark"
+ "github.com/yuin/goldmark/ast"
+ "github.com/yuin/goldmark/extension"
+ "github.com/yuin/goldmark/parser"
+ "github.com/yuin/goldmark/text"
+)
+
+// The options here mirror Hugo's goldmark defaults on purpose: GFM,
+// typographer, and automatic heading IDs. Same renderer, same extensions,
+// so a README renders identically to the same file on your blog.
+//
+// WithUnsafe is deliberately NOT set -- also Hugo's default. Anyone who can
+// push can write a README, so raw HTML in markdown is untrusted input;
+// goldmark drops it rather than passing a <script> through into the page.
+var md = goldmark.New(
+ goldmark.WithExtensions(extension.GFM, extension.Typographer),
+ goldmark.WithParserOptions(parser.WithAutoHeadingID()),
+)
+
+// Markdown renders README source.
+//
+// base — "/slug", the repository root URL
+// ref — the ref being viewed, for building links
+// dir — directory the source file lives in, for resolving relative links
+//
+// Swap this variable to change engines; nothing else imports goldmark.
+var Markdown = func(src []byte, base, ref, dir string) (template.HTML, error) {
+ doc := md.Parser().Parse(text.NewReader(src))
+ rewriteLinks(doc, base, ref, dir)
+
+ var buf bytes.Buffer
+ if err := md.Renderer().Render(&buf, src, doc); err != nil {
+ return "", err
+ }
+ return template.HTML(buf.String()), nil
+}
+
+// rewriteLinks points relative markdown links at the browser instead of
+// leaving them dangling. A link to docs/design.md becomes the blob view;
+// an image becomes /raw/, because the blob view is an HTML page and an
+// <img> needs actual bytes.
+func rewriteLinks(doc ast.Node, base, ref, dir string) {
+ ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
+ if !entering {
+ return ast.WalkContinue, nil
+ }
+ switch v := n.(type) {
+ case *ast.Link:
+ if p, ok := resolve(string(v.Destination), dir); ok {
+ v.Destination = []byte(base + "/blob/" + ref + "/" + p)
+ }
+ case *ast.Image:
+ if p, ok := resolve(string(v.Destination), dir); ok {
+ v.Destination = []byte(base + "/raw/" + ref + "/" + p)
+ }
+ }
+ return ast.WalkContinue, nil
+ })
+}
+
+// resolve reports whether dest is repo-relative, and if so its path from the
+// repo root. Absolute URLs, protocol-relative URLs, site-absolute paths and
+// bare fragments are left alone.
+func resolve(dest, dir string) (string, bool) {
+ if dest == "" || strings.HasPrefix(dest, "#") || strings.HasPrefix(dest, "/") ||
+ strings.HasPrefix(dest, "//") || strings.Contains(dest, "://") ||
+ strings.HasPrefix(dest, "mailto:") {
+ return "", false
+ }
+ p := path.Clean(path.Join(dir, dest))
+ if p == "." || strings.HasPrefix(p, "..") {
+ return "", false // escapes the repo root
+ }
+ return p, true
+}
@@ -0,0 +1,27 @@
+package modules
+
+import (
+ "time"
+
+ "gsg/internal/core"
+)
+
+type Meta struct {
+ core.RepoInfo
+ DefaultRef string
+ LastCommit time.Time
+ Commits int
+}
+
+func init() { core.Register("meta", metaMod) }
+
+// meta: repository identity plus freshness. Used by the repo view, the
+// index view, and the JSON endpoint -- computed once per page thanks to
+// the Ctx cache.
+func metaMod(c *core.Ctx, args ...string) (any, error) {
+ m := Meta{RepoInfo: c.Info, DefaultRef: c.Repo.HeadRef()}
+ if log, err := c.Repo.Log(c.Ref, 1); err == nil && len(log) > 0 {
+ m.LastCommit = log[0].When
+ }
+ return m, nil
+}
@@ -0,0 +1,5 @@
+// Package modules holds the built-in modules. Each file registers itself
+// in init(); adding a new one never requires touching core.
+//
+// A module is: func(*core.Ctx, ...string) (any, error)
+package modules
@@ -0,0 +1,52 @@
+package modules
+
+import (
+ "html/template"
+ "strings"
+
+ "gsg/internal/core"
+)
+
+type Readme struct {
+ Name string
+ HTML template.HTML
+}
+
+var readmeNames = []string{"README.md", "readme.md", "README.markdown", "README", "readme"}
+
+func init() { core.Register("readme", readmeMod) }
+
+// readme: first README found at Ctx.Path, rendered.
+// Returns nil (not an error) when absent, so `with` in a template just skips it.
+func readmeMod(c *core.Ctx, args ...string) (any, error) {
+ rev := c.Commit
+ if rev == "" {
+ rev = c.Ref
+ }
+ entries, err := c.Repo.Tree(rev, c.Path)
+ if err != nil {
+ return nil, nil
+ }
+ present := map[string]bool{}
+ for _, e := range entries {
+ if e.Type == "blob" {
+ present[e.Name] = true
+ }
+ }
+ for _, name := range readmeNames {
+ if !present[name] {
+ continue
+ }
+ full := strings.TrimPrefix(c.Path+"/"+name, "/")
+ src, err := c.Repo.Blob(rev, full)
+ if err != nil {
+ continue
+ }
+ out, err := Markdown(src, "/"+c.Info.Slug, c.Ref, c.Path)
+ if err != nil {
+ return nil, err
+ }
+ return Readme{Name: name, HTML: out}, nil
+ }
+ return nil, nil
+}
@@ -0,0 +1,30 @@
+package modules
+
+import (
+ "gsg/internal/core"
+ "gsg/internal/gitx"
+)
+
+type Refs struct {
+ Branches []gitx.Ref
+ Tags []gitx.Ref
+}
+
+func init() { core.Register("refs", refsMod) }
+
+// refs: branches and tags, split.
+func refsMod(c *core.Ctx, args ...string) (any, error) {
+ all, err := c.Repo.Refs()
+ if err != nil {
+ return nil, err
+ }
+ var out Refs
+ for _, r := range all {
+ if r.Kind == "tag" {
+ out.Tags = append(out.Tags, r)
+ } else {
+ out.Branches = append(out.Branches, r)
+ }
+ }
+ return out, nil
+}
@@ -0,0 +1,93 @@
+package modules
+
+import (
+ "fmt"
+ "sort"
+ "time"
+
+ "gsg/internal/core"
+)
+
+type RepoSummary struct {
+ core.RepoInfo
+ LastCommit time.Time
+}
+
+func init() { core.Register("repos", reposMod) }
+
+// repos: every visible repository, newest activity first.
+// Site-scoped -- does not touch c.Repo, so it is safe on the site Ctx.
+func reposMod(c *core.Ctx, args ...string) (any, error) {
+ out := make([]RepoSummary, 0, len(c.Site.Repos))
+ for _, h := range c.Site.Repos {
+ if h.Info.Hidden {
+ continue
+ }
+ s := RepoSummary{RepoInfo: h.Info}
+ if log, err := h.Repo.Log(h.Repo.HeadRef(), 1); err == nil && len(log) > 0 {
+ s.LastCommit = log[0].When
+ }
+ out = append(out, s)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].LastCommit.After(out[j].LastCommit) })
+ return out, nil
+}
+
+// Section is a named group of repositories, from `gsg.section` in each
+// repo's config (or `gitweb.category`).
+type Section struct {
+ Name string // "" means the repo set has no sections at all
+ Repos []RepoSummary
+}
+
+func init() { core.Register("sections", sectionsMod) }
+
+// sections: repos grouped by gsg.section, sections alphabetical, unsectioned
+// last. Built on top of `repos` rather than re-walking the repo set, so the
+// index page pays for the log lookups exactly once.
+//
+// Returns a single unnamed Section when nothing sets gsg.section, which lets
+// the template render a flat list without branching on the config.
+func sectionsMod(c *core.Ctx, args ...string) (any, error) {
+ v, err := c.Mod("repos")
+ if err != nil {
+ return nil, err
+ }
+ list, ok := v.([]RepoSummary)
+ if !ok {
+ return nil, fmt.Errorf("sections: repos returned %T", v)
+ }
+
+ any := false
+ groups := map[string][]RepoSummary{}
+ for _, r := range list {
+ if r.Section != "" {
+ any = true
+ }
+ groups[r.Section] = append(groups[r.Section], r)
+ }
+ if !any {
+ return []Section{{Name: "", Repos: list}}, nil
+ }
+
+ names := make([]string, 0, len(groups))
+ for n := range groups {
+ if n != "" {
+ names = append(names, n)
+ }
+ }
+ sort.Strings(names)
+
+ out := make([]Section, 0, len(groups))
+ for _, n := range names {
+ out = append(out, Section{Name: n, Repos: groups[n]})
+ }
+ if rest, ok := groups[""]; ok {
+ out = append(out, Section{Name: unsectioned, Repos: rest})
+ }
+ return out, nil
+}
+
+// unsectioned labels the leftover group. Named rather than inlined so it is
+// obvious this string is user-facing.
+const unsectioned = "Other"
@@ -0,0 +1,48 @@
+package modules
+
+import (
+ "net/http"
+ "strings"
+
+ "gsg/internal/core"
+ "gsg/internal/gitx"
+)
+
+type Tree struct {
+ Path string
+ Crumbs []Crumb
+ Entries []gitx.Entry
+}
+
+type Crumb struct{ Name, Path string }
+
+func init() { core.Register("tree", treeMod) }
+
+// tree: one directory level at Ctx.Path, with breadcrumbs.
+// Optional arg overrides the path: {{ mod "tree" "docs" }}
+func treeMod(c *core.Ctx, args ...string) (any, error) {
+ path := c.Path
+ if len(args) > 0 {
+ path = args[0]
+ }
+ // Resolve against the SHA, not the ref name: the ref could move between
+ // the resolve in buildCtx and here, which would make the ETag a lie.
+ rev := c.Commit
+ if rev == "" {
+ rev = c.Ref
+ }
+ entries, err := c.Repo.Tree(rev, path)
+ if err != nil {
+ // A valid commit with a bad path is a 404, not a server error.
+ return nil, core.Errorf(http.StatusNotFound, "no such path %q", path)
+ }
+ t := Tree{Path: path, Entries: entries}
+ if path != "" {
+ var acc []string
+ for _, seg := range strings.Split(path, "/") {
+ acc = append(acc, seg)
+ t.Crumbs = append(t.Crumbs, Crumb{Name: seg, Path: strings.Join(acc, "/")})
+ }
+ }
+ return t, nil
+}
@@ -0,0 +1,72 @@
+// Package site is the route table: the one file you edit when adding a
+// page or an endpoint. Modules are the "what", routes are the "where".
+package site
+
+import (
+ "gsg/internal/api"
+ "gsg/internal/core"
+)
+
+func Routes() []core.Route {
+ return []core.Route{
+ // ---- site-scoped -------------------------------------------------
+ {Name: "index", Scope: core.ScopeSite,
+ Pattern: "GET /{$}",
+ Render: core.View{Template: "index.html"}},
+
+ {Name: "api-index", Scope: core.ScopeSite,
+ Pattern: "GET /api/repos.json",
+ Render: core.JSON{Build: api.IndexEndpoint}},
+
+ // ---- per repository ----------------------------------------------
+ {Name: "repo",
+ Pattern: "GET /{repo}/{$}",
+ Render: core.View{Template: "repo.html"}},
+
+ {Name: "refs",
+ Pattern: "GET /{repo}/refs/{$}",
+ Render: core.View{Template: "refs.html"}},
+
+ {Name: "api-repo",
+ Pattern: "GET /{repo}/api/repo.json",
+ Render: core.JSON{Build: api.RepoEndpoint}},
+
+ // {rest...} fuses ref and path; SplitRefPath separates them by
+ // longest-matching ref, so feature/x/src/main.go works.
+ {Name: "log",
+ Pattern: "GET /{repo}/log/{rest...}",
+ Render: core.View{Template: "log.html"}},
+
+ {Name: "tree",
+ Pattern: "GET /{repo}/tree/{rest...}",
+ Render: core.View{Template: "tree.html"}},
+
+ {Name: "blob",
+ Pattern: "GET /{repo}/blob/{rest...}",
+ Render: core.View{Template: "blob.html"}},
+
+ // Actual file bytes. See core.Raw for why this is not text/html.
+ {Name: "raw",
+ Pattern: "GET /{repo}/raw/{rest...}",
+ Render: core.Raw{Module: "blobraw"}},
+
+ // The DAG across every ref.
+ {Name: "graph",
+ Pattern: "GET /{repo}/graph/{$}",
+ Render: core.View{Template: "graph.html"}},
+
+ // Arbitrary two-commit diff: /demo/compare/main...feature
+ {Name: "compare",
+ Pattern: "GET /{repo}/compare/{range}",
+ Render: core.View{Template: "compare.html"}},
+
+ // SHA-addressed: content can never change, so cache forever.
+ {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}},
+ }
+}
@@ -0,0 +1,123 @@
+// Command gsg serves a modular git browser.
+//
+// gsg -root /srv/git -addr :8080 -name "rschanz.org"
+// gsg -root /srv/git -templates ./templates # hot-reload the theme
+//
+// Per-repository settings live in each repo's own git config, cgit-style:
+//
+// git --git-dir=/srv/git/foo.git config gsg.title "foo"
+// git --git-dir=/srv/git/foo.git config gsg.description "a thing"
+// git --git-dir=/srv/git/foo.git config gsg.hide true
+package main
+
+import (
+ "embed"
+ "flag"
+ "fmt"
+ "io/fs"
+ "log"
+ "log/slog"
+ "net/http"
+ "os"
+ "path/filepath"
+ "sort"
+ "time"
+
+ "gsg/internal/core"
+ "gsg/internal/gitx"
+ _ "gsg/internal/modules" // registers the built-in modules
+ "gsg/internal/site"
+)
+
+//go:embed templates
+var templateFS embed.FS
+
+//go:embed static
+var staticFS embed.FS
+
+func main() {
+ var (
+ root = flag.String("root", "/srv/git", "directory containing bare repositories")
+ addr = flag.String("addr", ":8080", "listen address")
+ name = flag.String("name", "git", "site name")
+ base = flag.String("base", "", "base URL")
+ tmplDir = flag.String("templates", "", "serve templates from this directory, re-parsed per request")
+ cache = flag.Int("cache", 4096, "module cache entries")
+ timeout = flag.Duration("timeout", 10*time.Second, "per-request git budget")
+ )
+ flag.Parse()
+
+ if err := run(*root, *addr, *name, *base, *tmplDir, *cache, *timeout); err != nil {
+ log.Fatalf("gsg: %v", err)
+ }
+}
+
+func run(root, addr, name, base, tmplDir string, cacheSize int, timeout time.Duration) error {
+ siteCfg := &core.Site{Name: name, BaseURL: base, Root: root}
+
+ handles, err := discover(root)
+ if err != nil {
+ return err
+ }
+ if len(handles) == 0 {
+ return fmt.Errorf("no repositories found under %s", root)
+ }
+ siteCfg.Repos = handles
+
+ var tfs fs.FS
+ if tmplDir != "" {
+ tfs = os.DirFS(tmplDir)
+ } else if tfs, err = fs.Sub(templateFS, "templates"); err != nil {
+ return err
+ }
+
+ srv, err := core.NewServer(siteCfg, site.Routes(), tfs, cacheSize)
+ if err != nil {
+ return err
+ }
+ srv.TemplateDir = tmplDir
+ srv.Timeout = timeout
+
+ static, err := fs.Sub(staticFS, "static")
+ if err != nil {
+ return err
+ }
+
+ mux := http.NewServeMux()
+ mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(static))))
+ mux.Handle("/", srv.Handler())
+
+ slog.Info("gsg listening",
+ "addr", addr, "repos", len(handles),
+ "routes", len(site.Routes()), "modules", len(core.Names()))
+
+ hs := &http.Server{
+ Addr: addr,
+ Handler: mux,
+ ReadHeaderTimeout: 5 * time.Second,
+ WriteTimeout: timeout + 5*time.Second,
+ }
+ return hs.ListenAndServe()
+}
+
+// discover finds bare repositories one level below root.
+// Extend to walk recursively if you nest by section.
+func discover(root string) ([]core.Handle, error) {
+ entries, err := os.ReadDir(root)
+ if err != nil {
+ return nil, err
+ }
+ var handles []core.Handle
+ for _, e := range entries {
+ if !e.IsDir() {
+ continue
+ }
+ r, err := gitx.Open(filepath.Join(root, e.Name()))
+ if err != nil {
+ continue // not a repository
+ }
+ handles = append(handles, core.Handle{Repo: r, Info: core.LoadRepoInfo(r)})
+ }
+ sort.Slice(handles, func(i, j int) bool { return handles[i].Info.Slug < handles[j].Info.Slug })
+ return handles, nil
+}
@@ -0,0 +1,348 @@
+/* gsg — monospace-first UI. Every value on these pages is an identifier,
+ hash, path or count, so the interface type is the same face as the code;
+ the sans face appears only inside rendered README prose. Palette is a cool
+ slate ramp with one indigo accent and a six-colour lane ramp that the
+ commit graph and its branch chips share. */
+
+:root {
+ --bg: #FAFAFC;
+ --surface: #FFFFFF;
+ --line: #E3E5EE;
+ --line-soft: #EDEEF5;
+ --fg: #14161D;
+ --fg-soft: #4A5063;
+ --dim: #858BA0;
+ --accent: #5B4BE0;
+ --accent-bg: #EEEBFE;
+ --add: #1E874B;
+ --add-bg: #E9F6EE;
+ --del: #C4314B;
+ --del-bg: #FCECEF;
+
+ --lane-0: #5B4BE0;
+ --lane-1: #109C8D;
+ --lane-2: #C97A0E;
+ --lane-3: #D2447A;
+ --lane-4: #2A7CD6;
+ --lane-5: #6C9B1E;
+
+ --mono: ui-monospace, "SFMono-Regular", "JetBrains Mono", "Cascadia Mono",
+ Menlo, Consolas, "Liberation Mono", monospace;
+ --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
+
+ --r: 6px;
+ --pad: clamp(1rem, 4vw, 2rem);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #0E1016;
+ --surface: #151823;
+ --line: #242838;
+ --line-soft: #1C2030;
+ --fg: #E4E7F0;
+ --fg-soft: #A8AEC2;
+ --dim: #6E7590;
+ --accent: #9C8BFF;
+ --accent-bg: #211D3D;
+ --add: #4BC585;
+ --add-bg: #12251A;
+ --del: #FF7A90;
+ --del-bg: #2A1419;
+
+ --lane-0: #9C8BFF;
+ --lane-1: #34C6B4;
+ --lane-2: #E5A544;
+ --lane-3: #F2678E;
+ --lane-4: #58A6F0;
+ --lane-5: #9BD45F;
+ }
+}
+
+*, *::before, *::after { box-sizing: border-box; }
+
+html { -webkit-text-size-adjust: 100%; }
+
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--fg);
+ font-family: var(--mono);
+ font-size: 13.5px;
+ line-height: 1.6;
+ font-variant-ligatures: none;
+ -webkit-font-smoothing: antialiased;
+}
+
+.wrap { max-width: 74rem; margin: 0 auto; padding-inline: var(--pad); }
+
+a { color: var(--accent); text-decoration: none; }
+a:hover { text-decoration: underline; text-underline-offset: 2px; }
+:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 2px; }
+.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
+
+/* ---------------------------------------------------------------- topbar */
+
+.topbar {
+ position: sticky; top: 0; z-index: 10;
+ background: color-mix(in srgb, var(--bg) 88%, transparent);
+ backdrop-filter: blur(10px);
+ border-bottom: 1px solid var(--line);
+}
+.bar {
+ display: flex; align-items: center; gap: .65rem;
+ height: 3.1rem; flex-wrap: wrap;
+}
+.brand { color: var(--fg); font-weight: 600; letter-spacing: -.01em; }
+.brand:hover { color: var(--accent); text-decoration: none; }
+.slash { color: var(--dim); }
+.repo { color: var(--fg); font-weight: 600; }
+
+.tabs { display: flex; gap: .15rem; margin-left: .6rem; }
+.tabs a {
+ color: var(--fg-soft); padding: .2rem .55rem; border-radius: var(--r);
+ font-size: .8125rem; letter-spacing: .02em;
+}
+.tabs a:hover { background: var(--line-soft); color: var(--fg); text-decoration: none; }
+
+.refpick { margin-left: auto; }
+.refpick select {
+ font: inherit; font-size: .8125rem;
+ color: var(--fg-soft); background: var(--surface);
+ border: 1px solid var(--line); border-radius: var(--r);
+ padding: .2rem 1.4rem .2rem .5rem; cursor: pointer;
+ appearance: none;
+ background-image: linear-gradient(45deg, transparent 50%, var(--dim) 50%),
+ linear-gradient(135deg, var(--dim) 50%, transparent 50%);
+ background-position: right .62rem center, right .42rem center;
+ background-size: 4px 4px, 4px 4px;
+ background-repeat: no-repeat;
+}
+.refpick select:hover { border-color: var(--accent); color: var(--fg); }
+
+/* ------------------------------------------------------------- page head */
+
+main.wrap { padding-block: 2.25rem 5rem; }
+
+.page-head { margin-bottom: 1.6rem; }
+.page-head h1 {
+ margin: 0; font-size: 1.375rem; font-weight: 600; letter-spacing: -.02em;
+ line-height: 1.3;
+}
+.page-head h1.subject { font-weight: 500; }
+.sub { margin: .3rem 0 0; color: var(--dim); font-size: .8125rem; }
+.sub code { color: var(--fg-soft); }
+.dots { color: var(--dim); padding: 0 .3rem; }
+
+h2 {
+ margin: 2.5rem 0 .7rem;
+ font-size: .75rem; font-weight: 600;
+ text-transform: uppercase; letter-spacing: .1em;
+ color: var(--dim);
+ display: flex; align-items: baseline; gap: .75rem;
+}
+h2 .more { font-size: .6875rem; letter-spacing: .06em; margin-left: auto; }
+section:first-of-type h2 { margin-top: 1.75rem; }
+
+.crumbs { display: flex; flex-wrap: wrap; gap: .3rem; align-items: baseline;
+ font-size: 1.0625rem; font-weight: 500; }
+.crumbs .here { color: var(--fg); }
+
+/* ------------------------------------------------------------------ facts */
+
+.facts {
+ display: grid; grid-template-columns: max-content 1fr;
+ gap: .1rem 1.5rem; margin: 0;
+ padding: .85rem 1rem;
+ background: var(--surface);
+ border: 1px solid var(--line); border-radius: var(--r);
+}
+.facts dt { color: var(--dim); font-size: .75rem; text-transform: uppercase; letter-spacing: .08em; }
+.facts dd { margin: 0; color: var(--fg-soft); }
+.facts dd code { color: var(--fg); }
+.full-sha { word-break: break-all; }
+.clone { user-select: all; }
+
+/* ----------------------------------------------------------- latest commit */
+
+.latest {
+ display: grid;
+ grid-template-columns: max-content 1fr max-content;
+ align-items: baseline; gap: .85rem;
+ margin-top: .9rem; padding: .6rem .85rem;
+ background: var(--surface);
+ border: 1px solid var(--line); border-radius: var(--r);
+ color: var(--fg);
+}
+.latest:hover { border-color: var(--accent); text-decoration: none; }
+.latest-sha { color: var(--accent); font-size: .8125rem; }
+.latest-subject { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.latest-meta { color: var(--dim); font-size: .75rem; white-space: nowrap; }
+
+.readme { margin-top: 1.75rem; }
+
+/* ---------------------------------------------------------------- tables */
+
+.listing {
+ width: 100%; border-collapse: collapse;
+ background: var(--surface);
+ border: 1px solid var(--line); border-radius: var(--r);
+ overflow: hidden;
+}
+.listing td {
+ padding: .42rem .75rem;
+ border-top: 1px solid var(--line-soft);
+ vertical-align: baseline;
+}
+.listing tr:first-child td { border-top: 0; }
+.listing tr:hover td { background: var(--line-soft); }
+.listing .name { width: 100%; }
+.listing .name a { color: var(--fg); }
+.listing .name a:hover { color: var(--accent); }
+.listing .desc, .listing .who { color: var(--dim); white-space: nowrap; }
+.listing .when, .listing .num, .listing .churn, .listing .act {
+ color: var(--dim); white-space: nowrap; text-align: right; font-size: .8125rem;
+}
+.listing .sha a { color: var(--accent); }
+.listing .icon { padding-right: 0; width: 1.4rem; }
+.glyph { color: var(--dim); font-size: .75rem; }
+.glyph.dir { color: var(--accent); }
+.muted { color: var(--dim); }
+.empty { color: var(--dim); text-align: center; padding: 1.6rem !important; }
+
+.ghost {
+ color: var(--dim); font-size: .75rem;
+ border: 1px solid var(--line); border-radius: 4px;
+ padding: .05rem .38rem;
+}
+.ghost:hover { color: var(--accent); border-color: var(--accent); text-decoration: none; }
+
+.churn .add { color: var(--add); }
+.churn .del { color: var(--del); margin-left: .5rem; }
+
+.chips { display: flex; flex-wrap: wrap; gap: .4rem; list-style: none; margin: 0; padding: 0; }
+.chip {
+ font-size: .75rem; padding: .1rem .45rem;
+ border: 1px solid var(--line); border-radius: 4px;
+ color: var(--fg-soft); background: var(--surface);
+}
+.chip b { color: var(--dim); font-weight: 400; }
+.chip-branch { color: var(--accent); background: var(--accent-bg); border-color: transparent; margin-right: .35rem; }
+.chip-tag { color: var(--lane-2); border-color: currentColor; margin-right: .35rem; }
+
+/* ----------------------------------------------------------------- graph */
+/* The signature: lane geometry comes from the module, colour from here. */
+
+table.graph { width: 100%; border-collapse: collapse; }
+table.graph td { padding: 0 .75rem; height: 30px; white-space: nowrap; vertical-align: middle; }
+table.graph tr:hover { background: var(--line-soft); }
+table.graph .rail { padding: 0; width: 1px; }
+.rail-svg { display: block; }
+table.graph .msg { width: 100%; white-space: normal; }
+table.graph .msg a { color: var(--fg); }
+table.graph .msg a:hover { color: var(--accent); }
+table.graph .who, table.graph .when { color: var(--dim); font-size: .8125rem; text-align: right; }
+
+/* ------------------------------------------------------------ source/diff */
+
+.source, .patch {
+ margin-top: 1rem;
+ background: var(--surface);
+ border: 1px solid var(--line); border-radius: var(--r);
+ overflow-x: auto;
+}
+.source table { border-collapse: collapse; width: 100%; }
+.source td { padding: 0; vertical-align: top; }
+.source .ln {
+ width: 1%; padding: 0 .9rem 0 .8rem; text-align: right;
+ color: var(--dim); user-select: none;
+ position: sticky; left: 0; background: var(--surface);
+ border-right: 1px solid var(--line-soft);
+}
+.source .ln a { color: inherit; }
+.source .lc { padding-left: .9rem; white-space: pre; font-size: .8125rem; line-height: 1.55; }
+.source tr:target .lc, .source tr:target .ln { background: var(--accent-bg); }
+
+/* Diffs mark the gutter rather than flooding the row: the signal stays
+ readable when a hunk is mostly additions. */
+.patch { font-size: .8125rem; line-height: 1.55; }
+.pl { white-space: pre; padding: 0 .9rem 0 .65rem; border-left: 3px solid transparent; }
+.pl.meta { color: var(--dim); }
+.pl.hunk { color: var(--accent); background: var(--accent-bg); border-left-color: var(--accent); }
+.pl.add { color: var(--add); background: var(--add-bg); border-left-color: var(--add); }
+.pl.del { color: var(--del); background: var(--del-bg); border-left-color: var(--del); }
+
+.message {
+ margin: 1rem 0 0; padding: .85rem 1rem;
+ background: var(--surface); border: 1px solid var(--line); border-radius: var(--r);
+ white-space: pre-wrap; color: var(--fg-soft); font-size: .8125rem;
+}
+.notice {
+ padding: .6rem .85rem; border-radius: var(--r);
+ background: var(--accent-bg); color: var(--fg-soft); font-size: .8125rem;
+}
+
+/* ----------------------------------------------------------------- prose */
+/* The only place the sans face appears. */
+
+.prose-wrap { margin-top: 2.5rem; }
+.prose {
+ font-family: var(--sans); font-size: .9375rem; line-height: 1.7;
+ color: var(--fg-soft);
+ padding: 1.25rem 1.5rem;
+ background: var(--surface);
+ border: 1px solid var(--line); border-radius: var(--r);
+}
+.prose h1, .prose h2, .prose h3 {
+ font-family: var(--sans); color: var(--fg);
+ text-transform: none; letter-spacing: -.01em; display: block;
+}
+.prose h1 { font-size: 1.25rem; margin: 1.5rem 0 .5rem; }
+.prose h2 { font-size: 1.0625rem; margin: 1.5rem 0 .5rem; }
+.prose :first-child { margin-top: 0; }
+.prose h3 { font-size: .9375rem; margin: 1.25rem 0 .4rem; }
+.prose p, .prose ul, .prose ol, .prose blockquote, .prose table { margin: 0 0 1rem; }
+.prose ul, .prose ol { padding-left: 1.35rem; }
+.prose li { margin-bottom: .2rem; }
+.prose img { max-width: 100%; height: auto; border-radius: 4px; }
+.prose hr { border: 0; border-top: 1px solid var(--line); margin: 1.75rem 0; }
+.prose blockquote {
+ padding: .1rem 0 .1rem 1rem; border-left: 2px solid var(--line);
+ color: var(--dim);
+}
+.prose table { border-collapse: collapse; font-size: .875rem; }
+.prose th, .prose td { padding: .35rem .8rem; border: 1px solid var(--line); text-align: left; }
+.prose th { background: var(--bg); font-weight: 600; color: var(--fg); }
+.prose code, .prose pre { font-family: var(--mono); font-size: .8125rem; }
+.prose :not(pre) > code {
+ padding: .1rem .3rem; background: var(--bg);
+ border: 1px solid var(--line-soft); border-radius: 3px;
+}
+.prose pre { padding: .8rem; background: var(--bg); border: 1px solid var(--line-soft);
+ border-radius: 4px; overflow-x: auto; line-height: 1.5; }
+.prose pre code { padding: 0; background: none; border: 0; }
+
+/* ---------------------------------------------------------------- footer */
+
+footer {
+ padding-block: 1.25rem; margin-top: 3rem;
+ border-top: 1px solid var(--line);
+ color: var(--dim); font-size: .75rem;
+ display: flex; gap: .5rem;
+}
+footer .sep { color: var(--line); }
+
+/* ----------------------------------------------------------- small screens */
+
+@media (max-width: 46rem) {
+ .listing .who, .listing .desc, table.graph .who { display: none; }
+ .latest { grid-template-columns: max-content 1fr; }
+ .latest-meta { grid-column: 1 / -1; }
+ .bar { height: auto; padding-block: .6rem; }
+ .refpick { margin-left: 0; width: 100%; }
+ .refpick select { width: 100%; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after { animation: none !important; transition: none !important; }
+}
@@ -0,0 +1,45 @@
+{{ define "base" }}<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{ block "title" . }}{{ with .Info.Title }}{{ . }} · {{ end }}{{ .Site.Name }}{{ end }}</title>
+<link rel="stylesheet" href="/static/style.css">
+</head>
+<body>
+{{ template "topbar" . }}
+<main class="wrap">{{ block "main" . }}{{ end }}</main>
+<footer class="wrap">
+ <span>{{ .Site.Name }}</span>
+ <span class="sep">/</span>
+ <span>served from bare repositories</span>
+</footer>
+</body>
+</html>{{ end }}
+
+{{ define "topbar" }}
+<header class="topbar">
+ <div class="wrap bar">
+ <a class="brand" href="/">{{ .Site.Name }}</a>
+ {{ with .Info.Slug }}
+ <span class="slash">/</span>
+ <a class="repo" href="/{{ . }}/">{{ $.Info.Title }}</a>
+ <nav class="tabs">
+ <a href="/{{ . }}/tree/{{ $.Ref }}">files</a>
+ <a href="/{{ . }}/log/{{ $.Ref }}">log</a>
+ <a href="/{{ . }}/graph/">graph</a>
+ <a href="/{{ . }}/refs/">refs</a>
+ </nav>
+ {{ with modOr "refs" }}
+ <label class="refpick">
+ <span class="sr">Ref</span>
+ <select onchange="location.href=this.value">
+ {{ range .Branches }}<option value="/{{ $.Info.Slug }}/tree/{{ .Name }}"{{ if eq .Name $.Ref }} selected{{ end }}>{{ .Name }}</option>{{ end }}
+ {{ range .Tags }}<option value="/{{ $.Info.Slug }}/tree/{{ .Name }}"{{ if eq .Name $.Ref }} selected{{ end }}>{{ .Name }}</option>{{ end }}
+ </select>
+ </label>
+ {{ end }}
+ {{ end }}
+ </div>
+</header>
+{{ end }}
@@ -0,0 +1,61 @@
+{{/* {{ template "tree-table" (dict "Base" "/slug" "Ref" ref "Entries" entries) }} */}}
+{{ define "tree-table" }}
+<table class="listing tree">
+ <tbody>
+ {{ $ := . }}{{ range .Entries }}
+ <tr>
+ <td class="icon">{{ if .IsDir }}<span class="glyph dir">▸</span>{{ else }}<span class="glyph file">·</span>{{ end }}</td>
+ <td class="name">
+ {{ if .IsDir }}<a class="dir" href="{{ $.Base }}/tree/{{ $.Ref }}/{{ .Path }}">{{ .Name }}</a>
+ {{ else }}<a href="{{ $.Base }}/blob/{{ $.Ref }}/{{ .Path }}">{{ .Name }}</a>{{ end }}
+ </td>
+ <td class="num">{{ if .IsDir }}<span class="muted">—</span>{{ else }}{{ bytes .Size }}{{ end }}</td>
+ <td class="act">{{ if not .IsDir }}<a class="ghost" href="{{ $.Base }}/raw/{{ $.Ref }}/{{ .Path }}">raw</a>{{ end }}</td>
+ </tr>
+ {{ else }}
+ <tr><td class="empty" colspan="4">This directory is empty.</td></tr>
+ {{ end }}
+ </tbody>
+</table>
+{{ end }}
+
+{{ define "commit-table" }}
+<table class="listing log">
+ <tbody>
+ {{ $ := . }}{{ range .Commits }}
+ <tr>
+ <td class="sha"><a href="{{ $.Base }}/commit/{{ .SHA }}">{{ .Short }}</a></td>
+ <td class="name"><a class="subject" href="{{ $.Base }}/commit/{{ .SHA }}">{{ trunc 78 .Subject }}</a></td>
+ <td class="who">{{ .Author }}</td>
+ <td class="when"><time datetime="{{ rfc3339 .When }}">{{ date .When }}</time></td>
+ </tr>
+ {{ else }}
+ <tr><td class="empty" colspan="4">No commits on this ref yet.</td></tr>
+ {{ end }}
+ </tbody>
+</table>
+{{ end }}
+
+{{/* {{ template "diffstat" (dict "Base" "/slug" "Ref" sha "Diff" d) }} */}}
+{{ define "diffstat" }}
+{{ $ := . }}
+{{ with .Diff.Stats }}
+<table class="listing stat">
+ <tbody>
+ {{ range . }}
+ <tr>
+ <td class="name"><a href="{{ $.Base }}/blob/{{ $.Ref }}/{{ .Path }}">{{ .Path }}</a></td>
+ <td class="churn">
+ {{ if eq .Status "B" }}<span class="muted">binary</span>
+ {{ else }}<span class="add">+{{ .Added }}</span><span class="del">−{{ .Deleted }}</span>{{ end }}
+ </td>
+ <td class="act"><a class="ghost" href="{{ $.Base }}/raw/{{ $.Ref }}/{{ .Path }}">raw</a></td>
+ </tr>
+ {{ end }}
+ </tbody>
+</table>
+{{ else }}
+<p class="empty">No files changed.</p>
+{{ end }}
+{{ if .Diff.Truncated }}<p class="notice">This diff exceeds the size limit and is shown in part. Fetch the file with <code>raw</code> to see all of it.</p>{{ end }}
+{{ end }}
@@ -0,0 +1,21 @@
+{{ define "blob.html" }}{{ template "base" . }}{{ end }}
+{{ define "main" }}
+{{ $b := mod "blob" }}{{ $slug := print "/" .Info.Slug }}
+<header class="page-head">
+ <nav class="crumbs">
+ <a href="{{ $slug }}/tree/{{ .Ref }}">{{ .Info.Title }}</a>
+ <span class="slash">/</span><span class="here">{{ $b.Path }}</span>
+ </nav>
+ <p class="sub">
+ <code>{{ .Ref }}</code> · {{ bytes64 $b.Size }}{{ if not $b.Binary }} · {{ len $b.Lines }} lines{{ end }}
+ · <a class="ghost" href="{{ $slug }}/raw/{{ .Ref }}/{{ $b.Path }}">raw</a>
+ </p>
+</header>
+{{ if $b.Binary }}
+ <p class="notice">Binary file. Fetch it with <code>raw</code>.</p>
+{{ else }}
+<div class="source"><table><tbody>
+{{ range $b.Lines }}<tr id="L{{ .N }}"><td class="ln"><a href="#L{{ .N }}">{{ .N }}</a></td><td class="lc">{{ .Text }}</td></tr>
+{{ end }}</tbody></table></div>
+{{ end }}
+{{ end }}
@@ -0,0 +1,20 @@
+{{ define "commit.html" }}{{ template "base" . }}{{ end }}
+{{ define "main" }}
+{{ $c := mod "commit" }}{{ $d := mod "diff" }}{{ $slug := print "/" .Info.Slug }}
+<header class="page-head">
+ <h1 class="subject">{{ $c.Subject }}</h1>
+ <p class="sub">{{ $c.Author }} committed <time datetime="{{ rfc3339 $c.When }}">{{ date $c.When }}</time></p>
+</header>
+<dl class="facts">
+ <dt>commit</dt><dd><code class="full-sha">{{ $c.SHA }}</code></dd>
+ {{ with $c.Parents }}<dt>parent{{ if gt (len .) 1 }}s{{ end }}</dt><dd>
+ {{ range . }}<a href="{{ $slug }}/commit/{{ . }}"><code>{{ trunc 12 . }}</code></a>
+ <a class="ghost" href="{{ $slug }}/compare/{{ . }}..{{ $c.SHA }}">diff</a>{{ end }}
+ </dd>{{ end }}
+</dl>
+{{ with $c.Body }}<pre class="message">{{ . }}</pre>{{ end }}
+
+<h2>Changed files</h2>
+{{ template "diffstat" (dict "Base" $slug "Ref" $c.SHA "Diff" $d) }}
+{{ with $d.Patch }}{{ patch . }}{{ end }}
+{{ end }}
@@ -0,0 +1,28 @@
+{{ define "compare.html" }}{{ template "base" . }}{{ end }}
+{{ define "main" }}
+{{ $c := mod "compare" }}
+<header class="page-head">
+ <h1>Compare</h1>
+ <p class="sub">
+ <code>{{ $c.Base }}</code>
+ <span class="dots">{{ if $c.ThreeDot }}…{{ else }}‥{{ end }}</span>
+ <code>{{ $c.Head }}</code>
+ </p>
+</header>
+
+<dl class="facts">
+ <dt>base</dt><dd><a href="{{ print "/" .Info.Slug }}/commit/{{ $c.BaseSHA }}"><code>{{ trunc 12 $c.BaseSHA }}</code></a></dd>
+ <dt>head</dt><dd><a href="{{ print "/" .Info.Slug }}/commit/{{ $c.HeadSHA }}"><code>{{ trunc 12 $c.HeadSHA }}</code></a></dd>
+ {{ with $c.MergeBase }}<dt>merge base</dt><dd><code>{{ trunc 12 . }}</code></dd>{{ end }}
+ <dt>commits</dt><dd>{{ len $c.Commits }}</dd>
+</dl>
+
+{{ template "diffstat" (dict "Base" (print "/" .Info.Slug) "Ref" $c.HeadSHA "Diff" $c.Diff) }}
+
+{{ with $c.Commits }}
+<h2>Commits</h2>
+{{ template "commit-table" (dict "Base" (print "/" $.Info.Slug) "Commits" .) }}
+{{ end }}
+
+{{ with $c.Diff.Patch }}<h2>Patch</h2>{{ patch . }}{{ end }}
+{{ end }}
@@ -0,0 +1,26 @@
+{{ define "graph.html" }}{{ template "base" . }}{{ end }}
+{{ define "main" }}
+{{ $g := mod "graph" "150" }}
+<header class="page-head">
+ <h1>Branch graph</h1>
+ <p class="sub">{{ len $g.Rows }} commits across all refs · {{ $g.Lanes }} lanes</p>
+</header>
+
+<table class="graph">
+ <tbody>
+ {{ range $g.Rows }}
+ <tr>
+ <td class="rail">{{ .Rail }}</td>
+ <td class="msg">
+ {{ range .Refs }}<span class="chip chip-{{ if eq .Kind "tag" }}tag{{ else }}branch{{ end }}">{{ .Name }}</span>{{ end }}
+ <a href="{{ print "/" $.Info.Slug }}/commit/{{ .Commit.SHA }}">{{ trunc 76 .Commit.Subject }}</a>
+ </td>
+ <td class="who">{{ .Commit.Author }}</td>
+ <td class="when"><time datetime="{{ rfc3339 .Commit.When }}">{{ date .Commit.When }}</time></td>
+ </tr>
+ {{ else }}
+ <tr><td class="empty">No commits yet.</td></tr>
+ {{ end }}
+ </tbody>
+</table>
+{{ end }}
@@ -0,0 +1,26 @@
+{{ define "index.html" }}{{ template "base" . }}{{ end }}
+{{ define "main" }}
+{{ $secs := mod "sections" }}
+<header class="page-head">
+ <h1>{{ .Site.Name }}</h1>
+ <p class="sub">{{ len (mod "repos") }} repositories{{ if gt (len $secs) 1 }} in {{ len $secs }} sections{{ end }}</p>
+</header>
+
+{{ range $secs }}
+ {{/* An unnamed section means gsg.section is unset everywhere: render flat. */}}
+ {{ with .Name }}<h2 id="{{ slug . }}">{{ . }}</h2>{{ end }}
+ <table class="listing repos">
+ <tbody>
+ {{ range .Repos }}
+ <tr>
+ <td class="name"><a href="/{{ .Slug }}/">{{ .Title }}</a></td>
+ <td class="desc">{{ with .Description }}{{ . }}{{ else }}<span class="muted">No description set</span>{{ end }}</td>
+ <td class="when">{{ with .LastCommit }}<time datetime="{{ rfc3339 . }}">{{ date . }}</time>{{ else }}<span class="muted">empty</span>{{ end }}</td>
+ </tr>
+ {{ else }}
+ <tr><td class="empty">No repositories yet. Create a bare repo under the configured root.</td></tr>
+ {{ end }}
+ </tbody>
+ </table>
+{{ end }}
+{{ end }}
@@ -0,0 +1,8 @@
+{{ define "log.html" }}{{ template "base" . }}{{ end }}
+{{ define "main" }}
+<header class="page-head">
+ <h1>Log</h1>
+ <p class="sub"><code>{{ .Ref }}</code></p>
+</header>
+{{ template "commit-table" (dict "Base" (print "/" .Info.Slug) "Commits" (mod "log" "200")) }}
+{{ end }}
@@ -0,0 +1,34 @@
+{{ define "refs.html" }}{{ template "base" . }}{{ end }}
+{{ define "main" }}
+{{ $r := mod "refs" }}{{ $slug := print "/" .Info.Slug }}
+<header class="page-head">
+ <h1>Refs</h1>
+ <p class="sub">{{ len $r.Branches }} branches · {{ len $r.Tags }} tags</p>
+</header>
+<section>
+ <h2>Branches</h2>
+ <table class="listing refs"><tbody>
+ {{ range $r.Branches }}
+ <tr>
+ <td class="name"><a href="{{ $slug }}/tree/{{ .Name }}">{{ .Name }}</a></td>
+ <td class="desc">{{ trunc 64 .Title }}</td>
+ <td class="when"><time datetime="{{ rfc3339 .When }}">{{ date .When }}</time></td>
+ <td class="act"><a class="ghost" href="{{ $slug }}/compare/{{ $.Ref }}...{{ .Name }}">compare</a></td>
+ </tr>
+ {{ else }}<tr><td class="empty" colspan="4">No branches.</td></tr>{{ end }}
+ </tbody></table>
+</section>
+<section>
+ <h2>Tags</h2>
+ <table class="listing refs"><tbody>
+ {{ range $r.Tags }}
+ <tr>
+ <td class="name"><a href="{{ $slug }}/tree/{{ .Name }}">{{ .Name }}</a></td>
+ <td class="desc">{{ trunc 64 .Title }}</td>
+ <td class="when"><time datetime="{{ rfc3339 .When }}">{{ date .When }}</time></td>
+ <td class="act"><a class="ghost" href="{{ $slug }}/compare/{{ $.Ref }}...{{ .Name }}">compare</a></td>
+ </tr>
+ {{ else }}<tr><td class="empty" colspan="4">No tags.</td></tr>{{ end }}
+ </tbody></table>
+</section>
+{{ end }}
@@ -0,0 +1,41 @@
+{{ define "repo.html" }}{{ template "base" . }}{{ end }}
+{{ define "main" }}
+{{ $slug := print "/" .Info.Slug }}
+{{ $m := mod "meta" }}
+
+<header class="page-head">
+ <h1>{{ $m.Title }}</h1>
+ {{ with $m.Description }}<p class="sub">{{ . }}</p>{{ end }}
+</header>
+
+<dl class="facts">
+ <dt>branch</dt><dd><a href="{{ $slug }}/tree/{{ $m.DefaultRef }}"><code>{{ $m.DefaultRef }}</code></a></dd>
+ <dt>updated</dt><dd>{{ with $m.LastCommit }}<time datetime="{{ rfc3339 . }}">{{ date . }}</time>{{ else }}<span class="muted">never</span>{{ end }}</dd>
+ {{ with $m.Section }}<dt>section</dt><dd><a href="/#{{ slug . }}">{{ . }}</a></dd>{{ end }}
+ {{ with $m.Clone }}<dt>clone</dt><dd><code class="clone">{{ . }}</code></dd>{{ end }}
+ {{ with $m.Owner }}<dt>owner</dt><dd>{{ . }}</dd>{{ end }}
+</dl>
+
+{{/* `with` on the slice, not index 0 -- an empty repo has no commits and
+ indexing would abort the render. */}}
+{{ with mod "log" "1" }}{{ with index . 0 }}
+<a class="latest" href="{{ $slug }}/commit/{{ .SHA }}">
+ <span class="latest-sha">{{ .Short }}</span>
+ <span class="latest-subject">{{ trunc 88 .Subject }}</span>
+ <span class="latest-meta">{{ .Author }} · <time datetime="{{ rfc3339 .When }}">{{ date .When }}</time></span>
+</a>
+{{ end }}{{ end }}
+
+{{ with modOr "readme" }}
+ <div class="prose readme">{{ .HTML }}</div>
+{{ else }}
+ <p class="notice">No README on <code>{{ $.Ref }}</code>. Browse the <a href="{{ $slug }}/tree/{{ $.Ref }}">files</a> instead.</p>
+{{ end }}
+
+{{/* Everything else moved to its own page. Modules are lazy, so the ones
+ this view no longer calls cost nothing -- to put authors back, this is
+ the entire change:
+ {{ with mod "authors" }}
+ <ul class="chips">{{ range . }}<li class="chip">{{ .Name }} <b>{{ .Commits }}</b></li>{{ end }}</ul>
+ {{ end }} */}}
+{{ end }}
@@ -0,0 +1,15 @@
+{{ define "tree.html" }}{{ template "base" . }}{{ end }}
+{{ define "main" }}
+{{ $t := mod "tree" }}{{ $slug := print "/" .Info.Slug }}
+<header class="page-head">
+ <nav class="crumbs">
+ <a href="{{ $slug }}/tree/{{ .Ref }}">{{ .Info.Title }}</a>
+ {{ range $t.Crumbs }}<span class="slash">/</span><a href="{{ $slug }}/tree/{{ $.Ref }}/{{ .Path }}">{{ .Name }}</a>{{ end }}
+ </nav>
+ <p class="sub"><code>{{ .Ref }}</code> · {{ len $t.Entries }} entries</p>
+</header>
+{{ template "tree-table" (dict "Base" $slug "Ref" .Ref "Entries" $t.Entries) }}
+{{ with modOr "readme" }}
+<section class="prose-wrap"><div class="prose">{{ .HTML }}</div></section>
+{{ end }}
+{{ end }}