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

AGENTS.md

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