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

AGENTS.md

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