Migration
This is my new public git source viewer. Source coming soon. Please contact with concerns/bugs.
Contact me
AGENTS.md
3a50036d96eb14462e96598fd169d1c79264ac4b
· 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. |