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

README.md

c0427d5fc7c0fcf8606c546071085e8af231fb81 · 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.