Migration
This is my new public git source viewer. Source coming soon. Please contact with concerns/bugs.
Contact me
README.md
3a50036d96eb14462e96598fd169d1c79264ac4b
· 10.8 KB · 256 lines
raw
| 1 | # gsg — a modular git browser |
| 2 | |
| 3 | cgit's weight, Hugo's composability, served dynamically. A **module** |
| 4 | computes one piece of a view; a **route** binds a renderer to a URL pattern. |
| 5 | Adding a feature means adding a module and calling it from a template — |
| 6 | `internal/core` never changes. |
| 7 | |
| 8 | One dependency (`goldmark`, which itself has none) plus the `git` binary. Go 1.22+ (uses `net/http` |
| 9 | pattern wildcards, so there is no router dependency either). |
| 10 | |
| 11 | ``` |
| 12 | go build -o gsg . |
| 13 | ./gsg -root /srv/git -addr :8080 -name "rschanz.org" |
| 14 | ./gsg -root /srv/git -templates ./templates # re-parse the theme per request |
| 15 | ``` |
| 16 | |
| 17 | ## The idea |
| 18 | |
| 19 | ```go |
| 20 | type Module func(c *Ctx, args ...string) (any, error) |
| 21 | ``` |
| 22 | |
| 23 | Templates reach modules through one funcmap entry: |
| 24 | |
| 25 | ```gotmpl |
| 26 | {{ with mod "meta" }}<h1>{{ .Title }}</h1>{{ end }} |
| 27 | {{ $d := mod "diff" }} |
| 28 | {{ with modOr "readme" }}{{ .HTML }}{{ end }} |
| 29 | ``` |
| 30 | |
| 31 | - **Lazy.** A template that calls `mod "authors"` is what *causes* authors to |
| 32 | be computed. Views that don't ask don't pay. |
| 33 | - **Memoized twice.** Once per request (`Ctx.cache`), and once process-wide |
| 34 | against the resolved commit SHA (`core.LRU`). |
| 35 | - **No manifest.** Dependencies are declared by use. There is no module list |
| 36 | to keep in sync with the templates. |
| 37 | |
| 38 | `internal/modules/example_authors.go` is a live demonstration: 30 lines, an |
| 39 | `init()`, and one `{{ with mod "authors" }}` block. Nothing else moved. |
| 40 | |
| 41 | ## Pages and endpoints are the same mechanism |
| 42 | |
| 43 | A route is `{pattern, renderer}`. `core.View` renders a template, |
| 44 | `core.JSON` serializes an endpoint. Both take a `*Ctx`, both call modules. |
| 45 | |
| 46 | ```go |
| 47 | {Name: "commit", Immutable: true, |
| 48 | Pattern: "GET /{repo}/commit/{rev}", |
| 49 | Render: core.View{Template: "commit.html"}}, |
| 50 | |
| 51 | {Name: "api-commit", Immutable: true, |
| 52 | Pattern: "GET /{repo}/api/commit/{rev}", |
| 53 | Render: core.JSON{Build: api.CommitEndpoint}}, |
| 54 | ``` |
| 55 | |
| 56 | ### Endpoints use DTOs, not module values |
| 57 | |
| 58 | `internal/api` defines explicit structs and never marshals a module's return |
| 59 | value. Go marshals every exported field by default, so serializing a module |
| 60 | type means the day someone adds `Body` to `gitx.Commit`, commit messages are |
| 61 | public. The allowlist is one readable file. |
| 62 | |
| 63 | ``` |
| 64 | GET /api/repos.json site index |
| 65 | GET /{repo}/api/repo.json slug, title, description, owner, section, |
| 66 | clone_url, default_branch, last_commit |
| 67 | GET /{repo}/api/commit/{sha} sha, short, authored, parents, and |
| 68 | file/line counts — no subject, no body |
| 69 | ``` |
| 70 | |
| 71 | ## Routes |
| 72 | |
| 73 | | Route | URL | |
| 74 | |---|---| |
| 75 | | index | `/` | |
| 76 | | repo | `/{repo}/` — default branch, last update, latest commit, README | |
| 77 | | files | `/{repo}/tree/{ref}/{path}` | |
| 78 | | file | `/{repo}/blob/{ref}/{path}` | |
| 79 | | **raw bytes** | `/{repo}/raw/{ref}/{path}` | |
| 80 | | log | `/{repo}/log/{ref}` | |
| 81 | | refs | `/{repo}/refs/` | |
| 82 | | commit | `/{repo}/commit/{sha}` | |
| 83 | | **compare** | `/{repo}/compare/{base}..{head}` or `{base}...{head}` | |
| 84 | | **branch graph** | `/{repo}/graph/` | |
| 85 | |
| 86 | ### Compare |
| 87 | |
| 88 | `a..b` is the literal endpoint difference. `a...b` diffs from the merge |
| 89 | base, which is what you want when reviewing a branch — it excludes changes |
| 90 | that landed on the base since the branch forked. Verified against |
| 91 | `git merge-base` on a diverged fixture: `topic..main` lists `topic.txt` as |
| 92 | removed, `topic...main` does not. |
| 93 | |
| 94 | ### Raw files |
| 95 | |
| 96 | A repository can contain an `.html` file with a `<script>` tag. Serving that |
| 97 | as `text/html` on your own origin turns any push into stored XSS against the |
| 98 | whole site — which is why GitHub puts raw content on |
| 99 | `raw.githubusercontent.com`, a separate origin. Same-host serving is only |
| 100 | safe if the response cannot be interpreted as a document, so `/raw/` always |
| 101 | sends `text/plain` (or `application/octet-stream` for binaries, plus |
| 102 | `Content-Disposition: attachment`), with `X-Content-Type-Options: nosniff` |
| 103 | and `Content-Security-Policy: default-src \'none\'; sandbox` as a backstop. |
| 104 | |
| 105 | If you would rather have real isolation, serve `/raw/` from a second |
| 106 | hostname and the CSP becomes belt-and-braces rather than the only line. |
| 107 | |
| 108 | ### Branch graph |
| 109 | |
| 110 | `modules/graph.go` lays out the DAG in lanes in one pass over |
| 111 | `git log --all --topo-order`, which guarantees a child is emitted before its |
| 112 | parents. `active` holds, per lane, the SHA that lane is waiting to draw; a |
| 113 | commit claims the lane expecting it (or a fresh one), hands that lane to its |
| 114 | first parent, and opens a new lane per additional parent — which is what |
| 115 | makes a merge fork visually. |
| 116 | |
| 117 | The module emits the per-row SVG because the geometry *is* the data, but |
| 118 | every stroke refers to a CSS custom property (`--lane-0` … `--lane-5`), so |
| 119 | the theme still owns the palette. Row height in the SVG must stay in sync |
| 120 | with `table.graph td { height }` in the stylesheet. |
| 121 | |
| 122 | ## Caching, which is the whole reason this works dynamically |
| 123 | |
| 124 | Every module result is a pure function of `(repo, commit SHA, path, args)`. |
| 125 | Refs move; SHAs do not. So the process-wide cache is keyed on the resolved |
| 126 | SHA and needs **no invalidation logic at all** — a stale entry is impossible |
| 127 | by construction. |
| 128 | |
| 129 | `Ctx.Vars` are part of the cache key, not just the commit. They were not |
| 130 | originally, and the bug was exactly the kind this cache should be immune to: |
| 131 | `/compare/a..b` and `/compare/a...b` resolve to the same head commit, so the |
| 132 | second request was served the first one\'s diff. Anything that changes what a |
| 133 | module computes has to be in the key. |
| 134 | |
| 135 | ETags are derived from the resolved SHA plus a digest of every template |
| 136 | file, *not* from the response body. A conditional request costs one ref |
| 137 | lookup instead of a render, and restyling the theme invalidates every ETag |
| 138 | at once (without the template digest, a restyled page keeps serving 304s). |
| 139 | |
| 140 | Two URL classes, two policies: |
| 141 | |
| 142 | | URL shape | Example | Cache-Control | |
| 143 | |---|---|---| |
| 144 | | SHA-addressed | `/demo/commit/64b90fff…` | `max-age=31536000, immutable` | |
| 145 | | ref-addressed | `/demo/tree/main` | `no-cache` (revalidate via ETag) | |
| 146 | |
| 147 | ## Ref-vs-path ambiguity |
| 148 | |
| 149 | `/{repo}/tree/{rest...}` fuses them, and `gitx.SplitRefPath` separates them |
| 150 | by **longest matching prefix against the repo's actual ref list**, so |
| 151 | `feature/x/src/main.go` resolves correctly. Splitting at the first `/` is |
| 152 | wrong because branch names contain slashes; cgit dodges this with `?h=` |
| 153 | query params, GitHub does what this does. |
| 154 | |
| 155 | ## Serving is an attack surface; static generation was not |
| 156 | |
| 157 | - **Ref input never reaches `git rev-parse`.** rev-parse accepts an |
| 158 | expression language — `:/regex` searches history, `@{...}` reads the |
| 159 | reflog — and git subcommands treat a leading `-` as an option. |
| 160 | `gitx.Resolve` accepts either a hex SHA that already exists or an exact |
| 161 | match against the repo's own ref list. Verified: `--upload-pack=…`, |
| 162 | `:/secret`, `HEAD@{0}` and `-n` all return 404 with no side effects. |
| 163 | - **Every git call is `exec.CommandContext`** with a per-request deadline |
| 164 | (`-timeout`, default 10s) and a scrubbed environment |
| 165 | (`GIT_CONFIG_NOSYSTEM=1`, no `HOME`, no terminal prompt). |
| 166 | - **Output is capped** at `MaxGit` (8 MiB) per command; a diff that exceeds |
| 167 | it renders truncated rather than eating the heap. |
| 168 | |
| 169 | ## Layout |
| 170 | |
| 171 | ``` |
| 172 | main.go flags, repo discovery, embedded assets |
| 173 | internal/gitx/ the ONLY package that knows git exists |
| 174 | internal/core/core.go Ctx, RepoInfo, module registry, Store |
| 175 | internal/core/route.go Route, View, JSON |
| 176 | internal/core/server.go request -> Ctx, ETags, cache policy |
| 177 | internal/core/cache.go LRU keyed on commit SHAs |
| 178 | internal/core/funcs.go template funcmap (mod, modOr, dict, date, bytes) |
| 179 | internal/modules/ meta refs log tree readme repos commit diff blob |
| 180 | internal/api/ JSON DTOs — the serialization allowlist |
| 181 | internal/site/routes.go the route table (the file you edit most) |
| 182 | templates/ layouts/ + views/ |
| 183 | ``` |
| 184 | |
| 185 | `gitx` shells out to plumbing, one process per query, `git log` in a single |
| 186 | pass. Swapping in go-git means rewriting that one package. |
| 187 | |
| 188 | ## Sections |
| 189 | |
| 190 | `gsg.section` groups repositories on the index. The `sections` module builds |
| 191 | on `repos` rather than re-walking the repo set, so the index pays for its log |
| 192 | lookups once. When no repo sets a section, it returns a single unnamed group |
| 193 | and the template renders a flat list — no config, no headings. |
| 194 | |
| 195 | ```sh |
| 196 | git --git-dir=/srv/git/demo.git config gsg.section "Infrastructure" |
| 197 | ``` |
| 198 | |
| 199 | ## README rendering |
| 200 | |
| 201 | `modules/markdown.go` uses goldmark with GFM, Typographer, and automatic |
| 202 | heading IDs — Hugo\'s own goldmark defaults, so a README renders identically |
| 203 | to the same file on your blog. |
| 204 | |
| 205 | `WithUnsafe` is deliberately not set, which is also Hugo\'s default. Anyone |
| 206 | who can push can write a README, so raw HTML in markdown is untrusted input; |
| 207 | goldmark drops it. A `<script>` committed to a README comes out as |
| 208 | `<!-- raw HTML omitted -->`. |
| 209 | |
| 210 | Relative links are rewritten against the file\'s own directory: links become |
| 211 | the blob view, images become `/raw/` (an `<img>` needs bytes, not an HTML |
| 212 | page). Absolute URLs, site-absolute paths, fragments and `mailto:` are left |
| 213 | alone, and anything resolving above the repo root is not rewritten. |
| 214 | |
| 215 | ## Per-repository config |
| 216 | |
| 217 | Read from each repo's own git config, cgit-style, with `gitweb.*` and the |
| 218 | legacy `description` file as fallbacks, so an existing cgit tree works as-is: |
| 219 | |
| 220 | ```sh |
| 221 | git --git-dir=/srv/git/demo.git config gsg.title "Demo Project" |
| 222 | git --git-dir=/srv/git/demo.git config gsg.description "A demo repository" |
| 223 | git --git-dir=/srv/git/demo.git config gsg.clone "git://rschanz.org/demo" |
| 224 | git --git-dir=/srv/git/demo.git config gsg.hide true |
| 225 | ``` |
| 226 | |
| 227 | Add a key by adding a field to `core.RepoInfo` and a line in `LoadRepoInfo`. |
| 228 | |
| 229 | ## Theme |
| 230 | |
| 231 | Monospace-first: every value on these pages is an identifier, hash, path or |
| 232 | count, so the interface type is the same face as the code, and the sans face |
| 233 | appears only inside rendered README prose. A cool slate ramp, one indigo |
| 234 | accent, and a six-colour lane ramp shared by the commit graph and its branch |
| 235 | chips. Light and dark both defined via `prefers-color-scheme`. |
| 236 | |
| 237 | Diffs mark the gutter rather than flooding whole rows with green and red — |
| 238 | the signal stays readable when a hunk is mostly additions. |
| 239 | |
| 240 | No webfonts; system stacks only, so there is no third-party request. All |
| 241 | colour lives in `:root` custom properties in `static/style.css`. |
| 242 | |
| 243 | ## Known gaps |
| 244 | |
| 245 | - **No syntax highlighting.** `blob` returns numbered plain lines; Chroma |
| 246 | drops into the same module. |
| 247 | - **No pagination on `log` or `graph`.** They cap at 200 and 150 commits. |
| 248 | - **The graph loads all refs into memory** before laying out. Fine to a few |
| 249 | thousand commits; beyond that it needs windowing. |
| 250 | - **The LRU is bounded by entry count, not bytes.** A giant diff and a ref |
| 251 | list cost the same slot. The `Store` interface is the only thing a |
| 252 | size-aware replacement has to satisfy. |
| 253 | - **Repo discovery is one level deep** and happens once at startup; a repo |
| 254 | created later needs a restart. |
| 255 | - **Module errors surface at render time**, not load time. Add a CI pass |
| 256 | that renders every view against a fixture repo. |