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

AGENTS.md

8d1bb5bfdd2cba82488775b46308efed740132a8 · 15.5 KB · 342 lines raw

1 # AGENTS.md
2
3 Instructions for AI agents and new contributors working in this repository.
4 Read this before changing code. The README describes what the program does
5 for its users; this file describes why it is built the way it is and which
6 properties must survive your change.
7
8 ## What this is
9
10 `gsg` is a self-hosted web frontend for bare git repositories. It serves
11 pages dynamically, reads its per-repository configuration out of each repo's
12 own git config, and is designed so that adding a page, a data source, or a
13 JSON endpoint is a local change rather than a change to the core.
14
15 The target deployment is one binary next to a gitolite-managed repository
16 root, behind a reverse proxy, on a personal server.
17
18 ### Goals, in priority order
19
20 1. **Extensibility without core churn.** Adding a feature should mean adding
21 one file and calling it from one template. If a change requires editing
22 `internal/core`, that is a signal the design is wrong, not that the core
23 needs another special case.
24 2. **Coherent theming.** Every page shares one layout, one stylesheet, one
25 set of partials. Views do not carry their own styling.
26 3. **Correctness over speed of delivery.** This serves attacker-influenced
27 input to a public network. A plausible-looking fix that is not understood
28 is worse than an open bug.
29 4. **Small dependency surface.** Currently: the Go standard library,
30 `github.com/yuin/goldmark` (which has no dependencies of its own), and the
31 `git` binary. Do not add a dependency without a clear reason that cannot be
32 met by the stdlib.
33
34 ### Non-goals
35
36 - **Not a forge.** No accounts, no issues, no pull requests, no write paths.
37 Every route is `GET` and nothing mutates a repository.
38 - **Not a static site generator.** An earlier version generated files ahead
39 of time. That was removed because it made diffs and arbitrary-branch
40 browsing impractical. Do not reintroduce build-time page enumeration.
41 - **Not a general git host.** Cloning is served by something else
42 (git-daemon, ssh, gitolite). This is read-only presentation.
43
44 ## Architecture
45
46 The layering is strict. Violating it is the most common way to make this
47 codebase worse.
48
49 ```
50 main.go flags, repo discovery, embedded assets
51 internal/gitx/ the ONLY package that knows git exists
52 internal/core/ Ctx, module registry, routes, server, cache, funcmap
53 internal/modules/ the extension point: one module per concern
54 internal/notices/ operator notices: YAML store + admin UI (imports no core)
55 internal/api/ JSON DTOs -- the serialization allowlist
56 internal/site/ the route table
57 templates/ layouts/ + views/
58 static/ one stylesheet
59 ```
60
61 **`internal/gitx`** wraps the `git` binary. Nothing above it may invoke git,
62 parse git output, or know what a packfile is. Everything it returns is a
63 plain Go value. This boundary exists so the implementation can be swapped for
64 go-git or git2go by rewriting one package.
65
66 **`internal/core`** knows about HTTP, templates, caching and the module
67 registry. It must not know git semantics. If you find yourself importing
68 `gitx` into `core` for anything beyond the `Handle`/`Repo` plumbing that is
69 already there, stop and reconsider.
70
71 **`internal/modules`** is where features go. A module is:
72
73 ```go
74 type Module func(c *Ctx, args ...string) (any, error)
75 ```
76
77 It registers itself in `init()` and is reached from templates through a
78 single funcmap entry, `mod`. Three properties follow and must be preserved:
79
80 - **Lazy.** A template calling `mod "authors"` is what *causes* authors to be
81 computed. A view that does not ask does not pay. This is why removing a
82 block from a template is a complete removal of its cost.
83 - **Memoized twice.** Per request in `Ctx.cache`, and process-wide in the
84 SHA-keyed `core.LRU`.
85 - **Declared by use.** There is no module manifest to keep in sync with the
86 templates. Do not add one.
87
88 **`internal/site/routes.go`** is the file you edit most. A route is a
89 `net/http` pattern plus a renderer.
90
91 ## Invariants
92
93 These are not style preferences. Each one is either load-bearing for security
94 or was a real bug that got fixed. Do not break them; if a change seems to
95 require breaking one, say so explicitly rather than working around it.
96
97 ### 1. User ref input never reaches `git rev-parse`
98
99 `rev-parse` accepts an expression language: `:/regex` searches history,
100 `@{...}` reads the reflog, and git subcommands treat a leading `-` as an
101 option. `gitx.Resolve` accepts only a hex SHA that already exists, or an
102 exact match against the repository's own ref list. Everything else is
103 rejected with a 404.
104
105 Any new route that takes a ref-like path segment must resolve it through
106 `Resolve` in `Server.buildCtx`, never pass it onward as a raw string.
107
108 Regression cases that must keep returning 404 with no side effect:
109 `--upload-pack=touch /tmp/pwned`, `:/secret`, `HEAD@{0}`, `-n`.
110
111 ### 2. Modules read `c.Commit`, not `c.Ref`
112
113 `c.Ref` is the ref as the user spelled it. `c.Commit` is that ref resolved to
114 a SHA once, at the top of the request. A ref can move between resolution and
115 render; a module reading `c.Ref` would then produce content that does not
116 match the ETag the server already computed. Use `c.Commit` and fall back to
117 `c.Ref` only when `c.Commit` is empty (an empty repository).
118
119 ### 3. Anything that changes a module's output belongs in the cache key
120
121 `Ctx.CacheKey` covers repo, commit, path, module name, args, **and
122 `Ctx.Vars`**. Vars were missing originally and caused a real bug:
123 `/compare/a..b` and `/compare/a...b` resolve to the same *head* commit, so the
124 second request was served the first one's diff. If you add a new input to a
125 module, add it to the key.
126
127 The cache needs no invalidation logic because it is keyed on immutable SHAs.
128 Do not add TTLs or manual eviction on the assumption that entries go stale —
129 if an entry can go stale, the key is wrong.
130
131 `Ctx.contentIdentity()` also folds in inputs that change *without* the commit
132 moving: `RepoInfo.GPGKeyring` and `Site.Revision()`. Site revision is the
133 site-level analogue of the keyring; operator notices use it so a banner edit
134 invalidates both the module cache and every ETag. Anything else with that
135 shape belongs there too.
136
137 `Ctx.Immutable` marks responses served with the year-long immutable cache
138 (see `Server.serve`). Commit-independent chrome must not be rendered into
139 them, because the browser is told never to revalidate; the notices partial
140 skips those pages for exactly this reason.
141
142 ### 4. JSON endpoints use DTOs, never module values
143
144 `internal/api` defines explicit structs with json tags. Go marshals every
145 exported field by default, so serializing a module's return type means the
146 day someone adds a field to `gitx.Commit`, it is public. The allowlist lives
147 in one readable file on purpose.
148
149 Specific product decision: **commit messages are not exposed over the JSON
150 API.** `api.Commit` has no `Subject` and no `Body`. Do not add them.
151
152 ### 5. `/raw/` never serves `text/html`
153
154 A repository can contain an `.html` file with a `<script>` tag. Serving it as
155 `text/html` on this origin turns any push into stored XSS against the whole
156 site — which is why GitHub puts raw content on a separate hostname. Raw
157 responses are `text/plain` (or `application/octet-stream` plus
158 `Content-Disposition: attachment` for binaries) with
159 `X-Content-Type-Options: nosniff` and
160 `Content-Security-Policy: default-src 'none'; sandbox`.
161
162 The one deliberate exception is a validated raster image (PNG, JPEG, GIF,
163 WebP, BMP, ICO, AVIF): `modules/image.go` requires the extension and the
164 magic bytes to agree before `/raw/` may declare `image/*`, so an `.png`
165 holding HTML is not relabelled. SVG is never in that list — it is an XML
166 document that can carry a script, so it stays `text/plain`. A raster image
167 is safe to serve inline because it cannot execute and `nosniff` stops the
168 declared type from being re-sniffed; the blob page needs those bytes on the
169 same origin for its `<img>` preview, and the same URL is the download link.
170 If raw content is ever moved to its own hostname, keep these headers anyway.
171
172 ### 6. goldmark runs without `WithUnsafe`
173
174 Anyone who can push can write a README, so raw HTML in markdown is untrusted
175 input. goldmark drops it. This also matches Hugo's default, which matters
176 because the extension set is deliberately aligned with Hugo's (GFM,
177 Typographer, auto heading IDs) so a README renders identically to the same
178 file on the author's blog. Keep them aligned.
179
180 ### 7. `exec` argv cannot contain NUL
181
182 Separator bytes in git format strings must be written as git's own hex
183 escapes — `%00` for `for-each-ref`, `%x00`/`%x1e` for `git log` — so that git
184 emits the byte. Passing a literal `\x00` in an argument fails with
185 `fork/exec: invalid argument`. See the constant block at the top of
186 `gitx/git.go`; use `sepF`/`sepR` when splitting output and `escF`/`escLogF`/
187 `escLogR` when building a format.
188
189 ### 8. Every git invocation is bounded
190
191 `Repo.WithContext` binds a request deadline and an output cap. Serving means
192 every git call is attacker-influenced work: without a deadline one `git log`
193 on a large repository pins a core, and without a cap one diff of a huge blob
194 eats the heap. The environment is scrubbed (`GIT_CONFIG_NOSYSTEM=1`, no
195 `HOME`, no terminal prompt) so repository or user config cannot alter
196 behaviour. New `gitx` methods must go through `Repo.run`.
197
198 ### 9. Graph geometry is coupled to CSS
199
200 `modules/graph.go` has `rowH = 30`, and `static/style.css` has
201 `table.graph td { height: 30px }`. They must match or the rails detach from
202 their rows. This is the one fragile seam in the design; if you change either,
203 change both.
204
205 Colour is *not* coupled: the module emits `var(--lane-N)` and the stylesheet
206 owns the palette. Keep it that way — do not hardcode a colour in Go.
207
208 ### 10. Empty repositories are a normal state
209
210 A freshly created bare repo has an unborn HEAD. `Repo.Empty()` is checked at
211 `Open`, and `Log`/`Tree` return nothing rather than failing. Templates must
212 not index into a possibly-empty slice — use `{{ with mod "log" "1" }}{{ with
213 index . 0 }}`, not `{{ index (mod "log" "1") 0 }}`, or the render aborts.
214
215 ## How to make common changes
216
217 ### Add a module
218
219 One new file in `internal/modules`. Nothing else moves.
220
221 ```go
222 func init() { core.Register("authors", authorsMod) }
223
224 func authorsMod(c *core.Ctx, args ...string) (any, error) { ... }
225 ```
226
227 Then call it from any template: `{{ with mod "authors" }}…{{ end }}`. Use
228 `modOr` when absence is normal rather than an error (a missing README, for
229 example) so the section simply disappears.
230
231 `internal/modules/example_authors.go` exists as a live demonstration of
232 exactly this. Do not delete it.
233
234 ### Add a page
235
236 A module for the data, a view in `templates/views/`, one entry in
237 `internal/site/routes.go`. Views define `main` and are parsed into separate
238 template trees, because `html/template` has a single namespace for defined
239 blocks and two views both defining `main` would collide. That is why
240 `LoadTemplates` parses each view with the layouts rather than globbing
241 everything together.
242
243 ### Add a JSON endpoint
244
245 A DTO in `internal/api`, an `Endpoint` function that calls modules and fills
246 it, one route with `core.JSON{Build: …}`. Pages and endpoints are the same
247 mechanism deliberately — both take a `*Ctx`, both call the same memoized
248 modules.
249
250 ### Add a per-repository config key
251
252 A field on `core.RepoInfo` and a line in `LoadRepoInfo`. Follow the existing
253 fallback pattern: the `gsg.*` key first, then the cgit/gitweb equivalent, so
254 an existing cgit tree keeps working unmodified.
255
256 A config key that nothing reads is a bug. `gsg.section` sat unused for a
257 while; if you add a key, wire it into a template or an endpoint in the same
258 change.
259
260 ### Add an operator notice source
261
262 Notices (global and per-repo banners) live in `internal/notices`: a
263 file-backed store watched for changes, plus the admin listener. The
264 `notices` module merges the global list with the current repo's, and
265 `templates/layouts/notices.html` renders them — but only when
266 `Ctx.Immutable` is false. `main.go` wires the store to `Site.RevFn` so a
267 change invalidates caches. `internal/notices` deliberately does not import
268 `internal/core`; keep it that way so the module and the admin handler can
269 both reach it without a cycle. The admin listener is the one deliberate
270 write path, and it writes a config file, never a repository.
271
272 ## Building and checking
273
274 ```sh
275 gofmt -l . # must print nothing
276 go vet ./... # must be clean
277 go build -o gsg .
278 ```
279
280 There is no test suite yet. Until there is, verify changes against a fixture
281 tree with at least: a bare repo with several commits, a diverged branch, a
282 merge commit, a tag, a repository with no commits, and a non-bare repo. The
283 divergence matters — without it, `a..b` and `a...b` produce the same output
284 and a compare bug is invisible.
285
286 ```sh
287 ./gsg -root /srv/git -addr 127.0.0.1:8080 -templates ./templates
288 ```
289
290 `-templates` re-parses on every request, which is how to iterate on the theme
291 without restarting.
292
293 Routes worth exercising after any change: `/`, `/{repo}/`, `/{repo}/graph/`,
294 `/{repo}/tree/{ref}/{path}`, `/{repo}/blob/…`, `/{repo}/raw/…`,
295 `/{repo}/compare/a...b`, `/{repo}/commit/{sha}`, plus the two JSON endpoints
296 and the empty repository.
297
298 If you add a test suite, start with the invariants above — they are the
299 things worth asserting, and several of them are single-request checks.
300
301 ## Conventions
302
303 - Comments explain **why**, not what. The code says what it does; the comment
304 should say what would go wrong otherwise. Several comments in this codebase
305 record a specific bug — preserve those rather than tidying them away.
306 - Errors that are the client's fault carry a status:
307 `core.Errorf(http.StatusNotFound, …)`. Anything else becomes a 500 and is
308 logged at error level. A valid commit with a bad path is a 404, not a 500.
309 - User-facing copy is plain and directive. Empty states say what to do ("No
310 repositories yet. Create a bare repo under the configured root."), not just
311 that something is absent.
312 - The UI type is monospace throughout because every value on these pages is
313 an identifier, hash, path or count. The sans face appears only inside
314 rendered README prose. All colour lives in `:root` custom properties; do
315 not introduce a literal hex value elsewhere in the stylesheet.
316 - `gsg` is a placeholder name with no expansion. If it is renamed, the module
317 path, the config section, and the footer string all move.
318
319 ## Known gaps
320
321 Accurate as of now; update this list when you close one.
322
323 - No test suite.
324 - No syntax highlighting in the blob view — it returns numbered plain lines.
325 Chroma would drop into the `blob` module.
326 - No pagination on `log` (caps at 200) or `graph` (caps at 150).
327 - The graph loads all refs into memory before layout. Fine to a few thousand
328 commits; beyond that it needs windowing.
329 - The LRU is bounded by entry count, not bytes, so a large diff and a ref
330 list occupy one slot each. The `Store` interface is the only thing a
331 size-aware replacement must satisfy.
332 - Repository discovery is one level deep and happens once at startup; a repo
333 created afterwards needs a restart.
334 - README relative-link rewriting assumes every link is a file. A link to a
335 directory lands on the blob view and 404s. Fixing it properly means a tree
336 lookup per link.
337 - Module errors surface at render time, not load time. A CI pass that renders
338 every view against a fixture repository would catch them earlier.
339 - Path queries in the structured JSON/YAML search store the deepest path
340 found for a line. Flow style (`{"a": 1, "b": 2}`) puts several values on
341 one row and keeps only the last path; a container query still reaches the
342 others through the prefix match, but a sibling's exact path does not.