steid

@jamesgill /

feat: browse a repository's files and history

Milestone 5. A repository is now readable on the web: the file tree at a
revision, a file's contents with line numbers, and the commit log. Built by
three agents in parallel on disjoint files, with the port, the fake and a
non-panicking stub adapter written first so neither branch could break the
other's build.

The lookup primitive is `cat-file --batch-check` with the spec on stdin. It
exits 0 and prints "<spec> missing" for anything unresolvable, which is what
makes not-found a value read off stdout rather than an exit code to interpret:
a non-zero exit is always an error, absence is a marker in the output. The
obvious alternatives are worse — rev-parse --verify --quiet returns 1 for an
unknown ref but 128 for a missing repository, and ls-tree aimed at a blob is a
fatal: for what is, to a visitor, a 404. Passing the spec on stdin also means no
revision or path can be read as a flag, whatever validation upstream does or
stops doing later.

The empty repository is a first-class state rather than an error. Steid creates
repositories empty and Milestone 4 made it easy to have one never pushed to, so
the page that previously hardcoded "This repository is empty" now either says so
truthfully with the three commands to push, or lists the files.

URLs use /tree/{rev}/-/{path}. The separator is there because refs and paths
both contain slashes and sit adjacent; it is unambiguous by construction where
GitHub-style candidate splits would cost a ref lookup — another fork per page,
which the read-path decision makes more expensive.

Verified against Steid's own repository hosted on Steid: 57 commits pushed, the
tree lists, directories descend and breadcrumb back, session.rs renders with
line numbers and correct escaping, the log shows author and relative time.
Unknown revision, unknown path, ../etc/passwd, unknown repo and unknown handle
all 404; clone still works; a private repository is 404 to anonymous on every
browse route while its owner sees it.

Also ships a container image. The find that matters: `cargo build --release`
alone produces a binary that will not boot, because AssetBundle::load() wants
assets/manifest.toml beside the executable and only `topcoat asset bundle`
writes it. Recorded in CLAUDE.md alongside three Topcoat gotchas the pages work
turned up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZwc7URWKVhkAuRTWiDmjA
JamesPatrickGill authored 8 days agoparentacde6b5Browse filesdce0bf3530aa1ef66dbe51b1bb120532069f757b

21 files changed+3509 −107

.dockerignore+25 −0View file
@@ -0,0 +1,25 @@
1+# Build output. Huge, host-specific, and rebuilt inside the image anyway.
2+target/
3+
4+# Not needed to build, and copying it invalidates the build layer on every commit.
5+.git/
6+.gitignore
7+
8+# Local secrets and local state. `.env` sets STEID_INSECURE_COOKIES for dev; baking
9+# it into an image would silently strip the Secure flag from a deployed instance.
10+.env
11+.env.prod
12+
13+# Local instance state — the database and the bare repositories both belong on the
14+# container's volume, never in the image.
15+*.db
16+*.db-shm
17+*.db-wal
18+data/
19+
20+# Documentation and editor noise: irrelevant to the build.
21+plans/
22+CLAUDE.md
23+Dockerfile
24+.dockerignore
25+.DS_Store
CLAUDE.md+22 −0View file
@@ -112,6 +112,18 @@ Working knowledge that is easy to get wrong and slow to rediscover:
112112 see above.
113113 - **Boolean attributes need a value** — `required=(true)`, not bare `required`. A `false`
114114 omits the attribute entirely, so `selected=(bool)` is correct.
115+- **`view!` needs `__cx` in scope, so a plain `async fn helper(cx: &Cx) -> Result`
116+ cannot build a view.** Make it `#[component] async fn helper(cx: &Cx, …)` — a
117+ component may declare `cx: &Cx` and it is *not* passed at the call site. The error is
118+ a bare "cannot find value `__cx`" pointing into the macro, which names nothing useful.
119+- **A `#[component]`'s name becomes a unit struct in module scope**, so it shadows any
120+ *parameter* of the same name elsewhere in the file. A component called `commits` broke
121+ a separate `fn commit_log(commits: &[CommitSummary])` with "interpreted as a unit
122+ struct, not a new binding".
123+- **Catch-all params are `{*path}`, read with `#[path_param] struct Path(str);`** — the
124+ `*` is not part of the name. The whole tail arrives as one percent-decoded string.
125+ Matching happens on the *raw* path, which is why `%2F` inside a `{rev}` segment
126+ survives as a single segment and decodes to a slash.
115127 - **UI components reference theme tokens, never raw colours** — see `styles.css`. A
116128 hardcoded colour follows neither a palette change nor the colour scheme. Registry
117129 components are copied in by `topcoat ui add`, not depended on.
@@ -157,7 +169,17 @@ It is a rundown of capability, not of files touched — that is the commit messa
157169 - **The setup token is in memory only**, so every restart — including each `topcoat
158170 dev` rebuild — mints a new one.
159171 - Don't leave background servers running; the user drives the app.
172+- **`cargo build --release` alone produces a binary that will not boot.** `main` calls
173+ `AssetBundle::load()`, which walks up from the executable looking for
174+ `assets/manifest.toml` — and `build.rs` does not write one. `topcoat asset bundle`
175+ does, and it runs `cargo build` itself, so it is the build command, not a step after
176+ it. A `cargo build`-only container image compiles cleanly and then fails at startup
177+ with `NotFound`. Found while writing the Dockerfile.
160178 - **`topcoat asset bundle` after a manual build**, or the CSS served is stale.
161179 `topcoat dev` does it for you.
180+- **The build needs network beyond crates.io**: `build.rs` downloads the standalone
181+ Tailwind CLI from GitHub releases, and those binaries are glibc-linked — which is why
182+ the container is Debian on both stages and a musl/Alpine builder fails at `cargo
183+ build`.
162184 - In `sqlite.rs` and similar, **every implementation precedes the `mod tests` block**.
163185 Appending to the end of the file otherwise lands inside the wrong block.
Dockerfile+90 −0View file
@@ -0,0 +1,90 @@
1+# syntax=docker/dockerfile:1
2+
3+# Steid as a container.
4+#
5+# Two stages: a Rust builder that compiles and bundles assets, and a slim Debian
6+# runtime that carries the binary, the asset bundle, and `git`.
7+#
8+# Debian, not Alpine, on both sides. `build.rs` runs the *standalone* Tailwind CLI,
9+# downloaded from GitHub at build time, and those Linux builds are glibc-linked —
10+# a musl builder fails at `cargo build`, not at runtime, which is at least loud.
11+# The runtime stays on the same libc so the binary needs no static-linking dance.
12+
13+
14+# --- builder ---------------------------------------------------------------
15+
16+# rustc >= 1.95 is a hard floor: Topcoat 0.5 requires it, and on an older toolchain
17+# `topcoat` resolves to an empty `v0.0.0` placeholder rather than failing. Pinned
18+# rather than `rust:bookworm` so an image rebuilt in six months is the same build.
19+FROM rust:1.97-bookworm AS builder
20+
21+# `topcoat asset bundle` needs the CLI. Installed before the source is copied so
22+# editing the app doesn't rebuild it. `--locked` keeps it reproducible.
23+RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
24+ cargo install topcoat-cli --version 0.5.0 --locked
25+
26+WORKDIR /src
27+COPY . .
28+
29+# One command does both jobs: `topcoat asset bundle` runs `cargo build` itself and
30+# then scans the linked binary for the assets it declares, writing them plus a
31+# `manifest.toml` to `target/assets`. A plain `cargo build --release` is *not*
32+# enough — it produces the binary, but `AssetBundle::load()` looks for
33+# `assets/manifest.toml` beside the executable at startup and the app fails to boot
34+# without it.
35+#
36+# `target/` and the cargo registry are cache mounts, so a rebuild after a code edit
37+# reuses compiled dependencies. Nothing is faked to get that: no dummy `main.rs`,
38+# no split manifest copy. Those tricks interact badly with `build.rs`, which scans
39+# the real sources for Tailwind classes, and a stale stylesheet is a silent wrong
40+# answer rather than a build failure. Because a cache mount is not part of the
41+# image, the artefacts are copied to `/out` inside the same `RUN`.
42+RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
43+ --mount=type=cache,target=/src/target,sharing=locked \
44+ topcoat asset bundle --release \
45+ && mkdir -p /out \
46+ && cp target/release/steid /out/steid \
47+ && cp -r target/assets /out/assets
48+
49+
50+# --- runtime ---------------------------------------------------------------
51+
52+FROM debian:bookworm-slim
53+
54+# `git` is not optional. Steid shells out to it for everything: `git init --bare`
55+# creates a repository and `git http-backend` (shipped inside the git package, at
56+# /usr/lib/git-core) serves clone and push. An image without it builds cleanly and
57+# then fails at the first repository the user creates.
58+# `ca-certificates` for outbound TLS.
59+RUN apt-get update \
60+ && apt-get install -y --no-install-recommends git ca-certificates \
61+ && rm -rf /var/lib/apt/lists/*
62+
63+# Non-root. A fixed uid so a bind-mounted host directory can be chowned to match.
64+RUN useradd --system --create-home --home-dir /home/steid --uid 10001 steid
65+
66+# The bundle must sit beside the binary: `AssetBundle::load()` walks up from the
67+# executable looking for `assets/manifest.toml`, so /app/steid finds /app/assets.
68+WORKDIR /app
69+COPY --from=builder --chown=root:root /out/steid /app/steid
70+COPY --from=builder --chown=root:root /out/assets /app/assets
71+
72+# All persistent state under one directory, so one volume covers it: the SQLite
73+# database as a file in /data, the bare repositories under /data/repos. SQLite
74+# writes `-wal` and `-shm` siblings, so /data itself must be writable, not just the
75+# database file.
76+RUN mkdir -p /data/repos && chown -R steid:steid /data
77+VOLUME ["/data"]
78+
79+ENV STEID_DATABASE_URL="sqlite:/data/steid.db?mode=rwc" \
80+ STEID_DATA_DIR="/data/repos" \
81+ HOST="0.0.0.0" \
82+ PORT="3000" \
83+ HOME="/home/steid"
84+
85+# Deliberately not set: STEID_INSECURE_COOKIES. It strips `Secure` from the session
86+# cookie and belongs to plain-HTTP local development only.
87+
88+EXPOSE 3000
89+USER steid
90+CMD ["/app/steid"]
plans/ROADMAP.md+2 −2View file
@@ -57,8 +57,8 @@ a baseline.
5757 | 3 | **Repo model** — records + bare repos on disk | done |
5858 | 4a | **Clone over HTTP** — `git http-backend`, public repos, no auth | done |
5959 | 4b | **Push and tokens** — PATs over HTTP Basic, push, private clone | done |
60| 5 | **Repo browsing** — tree, blob, commit log | active |
61| 6 | **Writing** — posts, markdown | not started |
60+| 5 | **Repo browsing** — tree, blob, commit log | done |
61+| 6 | **Writing** — posts, markdown | active |
6262 | 7 | **Identity, full** — multi-user, orgs, invites, registration policy | not started |
6363 | 8+ | Projects/showcases · issues & PRs · SSH transport · federation | not started |
6464
plans/current.md+24 −89View file
@@ -4,100 +4,24 @@
44 > [progress.md](progress.md). If this file starts reading like a changelog, it has
55 > drifted — that's exactly what went wrong last time.
66
7## Active: Milestone 5 — Repo browsing
7+## Active: Milestone 6 — Writing
88
9**Goal:** a repository's contents are readable on the web — the file tree at a ref, a
10single file's contents, and the commit log. A visitor can look at code without cloning
11it, which is the first time the profile behaves like a portfolio rather than a list of
12names.
9+**Goal:** posts, written in markdown, at `/{handle}/posts/{slug}`, appearing on the
10+profile. The second portfolio feature, and the one that makes Steid something other than
11+a git host.
1312
14**Out of scope:** editing files, diffs, blame, syntax highlighting, rendering a README as
15markdown (that wants the markdown pipeline Milestone 6 brings), search, and a
16last-commit-per-file column — see Open, where that one is a decision rather than an
17omission.
18
19### Steps
20
21- [x] Web: the clone URL on the repository page — no plumbing, and Milestone 4 made it
22 true
23- [ ] Domain: `ObjectId`, `RefName`, `TreeEntry` — value objects **before** any adapter,
24 per [0006](decisions/0006-git-binary-behind-narrow-ports.md)
25- [ ] Application: `GitQuery` port — `resolve_ref`, `list_tree`, `read_blob`, `log`
26- [ ] Infrastructure: adapter over the `git` binary, through the existing `git_command`
27- [ ] Application: `browse_repo` read model, reusing `view_repo`'s visibility rule
28- [ ] Web: the tree page, and the repository page showing its default branch
29- [ ] Web: the blob page, and the commit log
30- [ ] Verify in a browser against a real repository, including an empty one
31
32### Done when
33
34A visitor can open a public repository from a profile, see its files at the default
35branch, click into a directory and then a file and read its contents, and open the commit
36log. A private repository shows none of this to someone who may not see it. An empty
37repository says so rather than erroring.
38
39### Settled
40
41- **Value objects come first.** A query port returning `String`s is an anaemic
42 pass-through that pushes validation into the page, which
43 [0006](decisions/0006-git-binary-behind-narrow-ports.md) rejected in advance.
44
45- **The read path stays on the `git` binary, one process per query**, with
46 `cat-file --batch` as the named upgrade and `gix` closed off — reasoning, the
47 measurement, and the reopening conditions in
48 [0006](decisions/0006-git-binary-behind-narrow-ports.md#amendment--20260829-the-milestone-5-read-path).
49- **`GitQuery` is a shared handle in app context**, not constructed per request the way
50 the SQLite adapters are. Stage 1 does not need the sharing; stage 2 owns live
51 subprocesses and cannot work without it. The port gives us the seam, not the lifetime,
52 and getting the lifetime wrong now means touching every page later.
53- **The public origin is derived from the request, never configured.** An instance is
54 deployable anywhere without being told its own address: the `Host` header plus
55 `X-Forwarded-Proto` when a proxy terminates TLS in front of it. One less thing to get
56 wrong in a deployment, and it makes the clone URL correct on localhost and in
57 production without a branch.
58- **Tree URLs use a separator** — `/{handle}/repos/{name}/tree/{ref}/-/{path}`.
59 Unambiguous by construction where candidate splits would cost a ref lookup, which is
60 another fork on every page.
61- **The commit log shows 50 and does not page** in v1.
62- **No per-file last-commit column in v1.** The direct consequence of the above: at one
63 fork per entry a twenty-file directory is ~230ms. If it is missed, that is the trigger
64 to climb to stage 2 rather than to reopen `gix`.
65- **Fork/exec is ~11–12ms per call, and it is the process, not the query.** Measured on
66 a 201-commit repository, averaged over 50 runs each: `rev-parse` 11.2ms, `ls-tree`
67 11.7ms, `cat-file` 11.6ms, `log -20` 11.8ms, `for-each-ref` 14.4ms. The work is
68 free; starting git is not — which matches the ~13ms `git init --bare` measured in
69 Milestone 3. **A three-call page therefore costs ~35ms of pure overhead.** That is the
70 number the Open decision below turns on, and it is why a per-file last-commit column is
71 a decision and not a detail: at one call per entry, a twenty-file directory is ~230ms
72 before any real work.
13+**Not planned yet.** Steps get laid out at the start of the milestone.
7314
7415 ### Open
7516
76- **The ref-versus-path ambiguity in the URL.** `/{handle}/repos/{name}/tree/{ref}/{path}`
77 is unparseable in general, because a ref may contain slashes: `tree/feature/x/README`
78 splits two ways. GitHub resolves it by trying candidate splits against the real ref
79 list; GitLab inserts a `/-/` separator. A third option is a single-segment ref with the
80 path after it, refusing refs with slashes. This is a URL shape, so it is expensive to
81 change later. **Recommendation: the separator.** Candidate splits cost a ref lookup —
82 another fork on every page — which the decision above makes more expensive, and a
83 separator is unambiguous by construction rather than by lookup.
84- **How a blob page handles what is not source code.** Binary files, invalid UTF-8, and
85 very large files all arrive at the same page. Deciding beats discovering.
86- **How far back the commit log goes** before it needs paging.
87
88### Watch for
89
90- **An empty repository has no `HEAD`.** Steid creates repositories empty, and
91 Milestone 4 made it easy to have one that was never pushed to. `rev-parse HEAD` fails
92 rather than returning nothing, and the repository page must say "nothing here yet"
93 instead of erroring.
94- **A blob is not necessarily text.** Binary files, invalid UTF-8, and very large files
95 all reach the same page. Decide what each does rather than discovering it.
96- **Paths in URLs reach the filesystem indirectly**, via git rather than directly, but a
97 path is still user input arriving at a subprocess argument. `--` before path arguments,
98 as `init_bare` already does.
99- **The commit log is unbounded.** A repository with 50,000 commits needs a limit before
100 the page renders one.
17+- **Which markdown crate**, and whether rendering is trusted. `pulldown-cmark` is the
18+ obvious choice and is not currently a dependency. Raw HTML in markdown is the decision
19+ inside it: a single-author instance can trust its own input, but the moment Milestone 7
20+ adds a second user that assumption is a stored-XSS hole. Deciding now is cheaper than
21+ retrofitting a sanitiser.
22+- **Whether a repository's README renders on its page.** It is the feature that makes a
23+ repo page look like a portfolio piece rather than a file list, and it falls out of the
24+ markdown pipeline this milestone builds — so it belongs here rather than back in 5.
10125
10226 ### Carried over — small, unblocked
10327
@@ -125,6 +49,17 @@ repository says so rather than erroring.
12549 constraint, but surfaces as an opaque storage error rather than "name taken".
12650 - **Bare repos created on macOS carry `ignorecase = true`.** A migration gotcha if the
12751 data directory ever moves to Linux.
52+- **Light mode is still untested**, and now there is much more surface to get it wrong
53+ on — the file tree, the blob view and the log all shipped without anyone looking at
54+ them in light mode.
55+- **Submodule rendering was never seen**, only compiled: no fixture contained one.
56+- **The `/log` page shows no branch indicator** when no revision is given, because
57+ `repo_log` does not return the revision it resolved. A second query or a small
58+ application change, neither urgent.
59+- **A per-file last-commit column is still absent**, deliberately — see
60+ [0006](decisions/0006-git-binary-behind-narrow-ports.md#amendment--20260829-the-milestone-5-read-path).
61+ Wanting it is the trigger to move to a kept-alive `cat-file --batch`, not to reopen
62+ `gix`.
12863 - **Fonts are not loaded.** The theme names Geist and IBM Plex Mono; both fall back
12964 today. Topcoat's `font-fontsource` feature handles it.
13065 - **Light mode is untested.** The palette defines it; nobody has looked at it.
plans/progress.md+52 −1View file
@@ -2,7 +2,7 @@
22
33 ## This attempt (#3, Topcoat)
44
5249 tests. Active milestone in [current.md](current.md).
5+317 tests. Active milestone in [current.md](current.md).
66
77 ### Milestone 0 — Skeleton · done
88
@@ -351,6 +351,57 @@ at one call per entry would be ~230ms for twenty files. This is the input
351351 [0006](decisions/0006-git-binary-behind-narrow-ports.md) asked for before reconsidering
352352 `gix` on the read path.
353353
354+### Milestone 5 — Repo browsing · done
355+
356+A repository is readable on the web: the file tree at a revision, a file's contents with
357+line numbers, and the commit log. `ObjectId` / `RefName` / `RepoPath` / `EntryKind` /
358+`TreeEntry` / `CommitSummary` in the domain, a `GitQuery` port with `DiskGitQuery` and an
359+in-memory fake behind it, `browse_repo` and `repo_log` read models, and pages at
360+`/{handle}/repos/{name}`, `/tree/{rev}`, `/tree/{rev}/-/{path}`, `/log` and `/log/{rev}`.
361+The read-path decision and its upgrade ladder are in
362+[0006](decisions/0006-git-binary-behind-narrow-ports.md#amendment--20260829-the-milestone-5-read-path).
363+
364+**Verified against Steid's own repository, hosted on Steid.** A freshly created repo
365+shows push instructions; after pushing 57 commits the page lists the tree; directories
366+descend and breadcrumb back; `src/domain/session.rs` renders with line numbers and
367+correct HTML escaping; the log shows 50 commits with author and relative time. Unknown
368+revision, unknown path, `../etc/passwd`, unknown repo and unknown handle all 404, clone
369+still works, and a private repository answers 404 to an anonymous visitor on **every**
370+browse route while its owner sees it.
371+
372+#### Decisions worth remembering
373+
374+- **`cat-file --batch-check` with the spec on stdin is the lookup primitive.** It exits
375+ **0** and prints `<spec> missing` for anything unresolvable, which is what makes "not
376+ found" a *value read off stdout* rather than an exit code to interpret. The rule the
377+ adapter follows: **a non-zero exit is always an error; absence is a marker in the
378+ output** (`missing`, `ambiguous`, `dangling`, `notdir`). The obvious alternatives are
379+ worse — `rev-parse --verify --quiet` returns 1 for an unknown ref but 128 for a missing
380+ repository, and `ls-tree` pointed at a blob is a `fatal:` for what is, to a visitor, a
381+ 404. Passing the spec on **stdin** also means no revision or path can ever be read as a
382+ flag, whatever validation upstream does or stops doing.
383+- **`git log` in a repository with no commits is a fatal error, not empty output**, hence
384+ resolving the revision first. An extra fork, in exchange for not reading meaning out of
385+ a localised stderr string.
386+- **`%s` is git's *subject*, not the first line** — with no blank line in the message it
387+ joins the whole first paragraph with spaces. The adapter takes `.lines().next()` rather
388+ than trusting that.
389+- **Negative `%ct` exists** in imported histories and would panic `UNIX_EPOCH + Duration`.
390+- **A symlink is indistinguishable from a file in the object store** — both are blobs — so
391+ `read_blob` returns a symlink's target path as its content. Deliberate; making it
392+ `Ok(None)` would cost an extra `ls-tree` of the parent.
393+- **The empty repository is a first-class state, not an error.** Steid creates
394+ repositories empty and Milestone 4 made it easy to have one never pushed to, so
395+ `default_branch` returning `None` means "no commits" and the page offers the three
396+ commands to push rather than a broken listing.
397+- **`%2F` in a revision segment survives routing.** Topcoat matches on the raw path and
398+ percent-decodes after, so a slashed branch works as a single segment — which is what
399+ makes the `/-/` separator sufficient without a ref lookup.
400+- **Three agents worked in parallel on disjoint files**, with the port, the fake and a
401+ non-panicking stub adapter written first so neither branch could break the other's
402+ build. The stub answering "nothing there" rather than `todo!()` is what let the pages
403+ be developed and run before the adapter existed.
404+
354405 ---
355406
356407 ## Reference: what attempt #2 proved
plans/runbook.md+99 −0View file
@@ -119,6 +119,105 @@ git clone http://host/{handle}/repos/{name}.git
119119
120120 Fill in the token workflow and the `body_limit` setting once this is built.
121121
122+## Deployment (container)
123+
124+A `Dockerfile` at the repo root builds a self-contained image. Two stages: `rust:1.97-bookworm`
125+compiles and bundles, `debian:bookworm-slim` runs. ~206 MB.
126+
127+```bash
128+docker build -t steid .
129+docker volume create steid-data
130+docker run -d --name steid -p 3000:3000 -v steid-data:/data steid
131+docker logs steid # the setup token is here, and only here
132+```
133+
134+Verified end to end on 2026-08-29: the image builds, boots, applies migrations,
135+creates `/data/steid.db`, serves `/auth/setup` with its stylesheet, and `git init
136+--bare` succeeds inside `/data/repos` as the non-root user.
137+
138+### The build needs `topcoat asset bundle`, not just `cargo build`
139+
140+`cargo build --release` alone produces a binary that **fails to boot**. `main` calls
141+`AssetBundle::load()`, which walks up from the executable looking for
142+`assets/manifest.toml`; without one it returns `NotFound` and the process exits before
143+serving anything. `build.rs` does not write that bundle — it only runs Tailwind and
144+stages icons into `OUT_DIR`, where they are embedded in the binary.
145+
146+The bundle comes from the CLI, which the builder stage installs:
147+
148+```bash
149+cargo install topcoat-cli --version 0.5.0 --locked
150+topcoat asset bundle --release # runs `cargo build --release` itself, then bundles
151+```
152+
153+It writes `target/assets/`, which must be copied **next to the binary** in the runtime
154+image — `/app/steid` finds `/app/assets`. This is the same step `topcoat dev` performs
155+for you, and the reason a hand-built binary serves stale CSS.
156+
157+Two build-time consequences worth knowing:
158+
159+- The build **needs network access**: `build.rs` downloads the standalone Tailwind CLI
160+ from GitHub releases, and the bundler downloads any remote asset.
161+- The image is **Debian, not Alpine, on both sides**. Those Tailwind binaries are
162+ glibc-linked, so a musl builder fails during `cargo build`.
163+
164+The builder mounts the cargo registry and `target/` as BuildKit caches. There is
165+deliberately **no dummy-`main.rs` dependency-caching trick**: `build.rs` scans the real
166+sources for Tailwind classes, and a faked source tree yields a stale stylesheet — a
167+wrong answer that still builds, which is the worst kind.
168+
169+### Configuration in a container
170+
171+The image sets these defaults, so the `docker run` above needs no `-e` flags at all:
172+
173+```
174+STEID_DATABASE_URL=sqlite:/data/steid.db?mode=rwc
175+STEID_DATA_DIR=/data/repos
176+HOST=0.0.0.0 # Topcoat's, not STEID_-prefixed — see Configuration above
177+PORT=3000
178+```
179+
180+No public URL is configured anywhere: the origin is derived from the `Host` header and
181+`X-Forwarded-Proto`, so a proxy that forwards both needs nothing further.
182+
183+**`STEID_INSECURE_COOKIES` is deliberately unset in the image and must stay unset.**
184+The session cookie is `Secure`, which means the deployment needs TLS — assume a
185+terminating proxy in front (Caddy, nginx, a platform router). Setting the variable to
186+paper over a missing certificate hands every session cookie to anyone on the network
187+path. `.dockerignore` excludes `.env` for the same reason: the dev `.env` sets it, and
188+copying it in would silently unharden a deployed image.
189+
190+### State is one volume
191+
192+Everything that must survive a restart lives under `/data`: the SQLite database as a
193+file directly in it, the bare repositories under `/data/repos`. `VOLUME ["/data"]` is
194+declared, so a container started without `-v` still keeps its state — in an anonymous
195+volume that is easy to lose track of. Name it.
196+
197+`/data` itself must be writable, not just the database file: SQLite creates `-wal` and
198+`-shm` siblings next to it.
199+
200+The container runs as uid **10001** (`steid`). A named volume inherits that ownership
201+from the image on first use. A **bind mount does not** — `-v /srv/steid:/data` starts
202+root-owned and the app fails to write, so `chown 10001:10001 /srv/steid` on the host
203+first.
204+
205+### Claiming a deployed instance
206+
207+The setup token is printed to **stdout only, and only while the instance is
208+unclaimed**. It is held in memory, so every restart — including every redeploy —
209+mints a new one, and a claimed instance mints none at all.
210+
211+There is no way to recover it other than the platform's logs:
212+
213+```bash
214+docker logs steid | tail -20
215+```
216+
217+Read the token from the **most recent** boot, then claim at `https://your-host/auth/setup`.
218+Until it is claimed every route redirects there, so an instance left unclaimed on a
219+public address is an open door — claim it immediately after the first deploy.
220+
122221 ## Manual verification checklist (#2)
123222
124223 Attempt #2 verified these by hand each milestone but never wrote down the steps. They
src/application/browse.rs+183 −0View file
@@ -0,0 +1,183 @@
1+//! Reading a repository's contents for display.
2+//!
3+//! Authorization is not re-implemented here: every entry point goes through
4+//! [`view_repo`](super::repo::view_repo), so a repository invisible on its page is
5+//! invisible in its file tree, by construction rather than by remembering to check.
6+
7+use crate::domain::{
8+ Actor, CommitSummary, ObjectId, OrgName, RefName, RepoName, RepoPath,
9+ repository::{MembershipRepository, OrgRepository, RepoRepository},
10+};
11+
12+use super::{
13+ error::Result,
14+ port::{Blob, GitQuery},
15+ repo::view_repo,
16+};
17+
18+/// The largest file Steid will render.
19+///
20+/// A page has a person waiting on it, and past a megabyte nobody is reading the file —
21+/// they are waiting for a browser to lay out a megabyte of text. Bigger files are
22+/// reported by size rather than shown.
23+pub const MAX_BLOB_BYTES: u64 = 1024 * 1024;
24+
25+/// How many commits a log shows. No paging in v1; this is the whole of it.
26+pub const LOG_LIMIT: usize = 50;
27+
28+/// A file, as far as it can be displayed.
29+#[derive(Debug, Clone, PartialEq, Eq)]
30+pub struct FileView {
31+ pub id: ObjectId,
32+ pub size: u64,
33+ /// The contents, when they are text small enough to show.
34+ ///
35+ /// `None` covers both "not valid UTF-8" and "too large"; [`too_large`](Self::too_large)
36+ /// tells them apart, because the page says something different for each.
37+ pub text: Option<String>,
38+ pub too_large: bool,
39+}
40+
41+impl FileView {
42+ /// Whether the file exists and is simply not displayable as text.
43+ pub fn is_binary(&self) -> bool {
44+ self.text.is_none() && !self.too_large
45+ }
46+}
47+
48+/// What is at a path in a repository.
49+#[derive(Debug, Clone, PartialEq, Eq)]
50+pub enum Browsed {
51+ /// The repository has no commits. Distinct from an empty directory: there is
52+ /// nothing to point a revision at, so the page offers push instructions rather than
53+ /// an empty listing.
54+ Empty,
55+ Directory {
56+ rev: RefName,
57+ path: RepoPath,
58+ /// Ordered directories-first, then case-insensitively by name.
59+ entries: Vec<crate::domain::TreeEntry>,
60+ },
61+ File {
62+ rev: RefName,
63+ path: RepoPath,
64+ file: FileView,
65+ },
66+}
67+
68+/// Resolves a path in a repository into whatever is there.
69+///
70+/// `rev` of `None` means the default branch, which is what a bare repository URL asks
71+/// for.
72+///
73+/// `Ok(None)` means the repository is invisible, absent, or has nothing at that path —
74+/// all rendered identically as 404, for the reason
75+/// [`view_repo`](super::repo::view_repo) gives.
76+#[allow(clippy::too_many_arguments)]
77+pub async fn browse_repo(
78+ handle: &OrgName,
79+ name: &RepoName,
80+ rev: Option<&RefName>,
81+ path: &RepoPath,
82+ actor: &Actor,
83+ orgs: &impl OrgRepository,
84+ memberships: &impl MembershipRepository,
85+ repos: &impl RepoRepository,
86+ queries: &impl GitQuery,
87+) -> Result<Option<Browsed>> {
88+ if view_repo(handle, name, actor, orgs, memberships, repos)
89+ .await?
90+ .is_none()
91+ {
92+ return Ok(None);
93+ }
94+
95+ let Some(rev) = resolve_revision(handle, name, rev, queries).await? else {
96+ return Ok(Some(Browsed::Empty));
97+ };
98+
99+ // A directory first, because that is the common case and the cheaper question.
100+ if let Some(mut entries) = queries.list_tree(handle, name, &rev, path).await? {
101+ entries.sort_by_key(crate::domain::TreeEntry::ordering_key);
102+
103+ return Ok(Some(Browsed::Directory {
104+ rev,
105+ path: path.clone(),
106+ entries,
107+ }));
108+ }
109+
110+ let Some(blob) = queries
111+ .read_blob(handle, name, &rev, path, MAX_BLOB_BYTES)
112+ .await?
113+ else {
114+ return Ok(None);
115+ };
116+
117+ Ok(Some(Browsed::File {
118+ rev,
119+ path: path.clone(),
120+ file: view_of(blob),
121+ }))
122+}
123+
124+/// The commit log for a revision, newest first.
125+///
126+/// `Ok(None)` on the same terms as [`browse_repo`]. An empty repository logs nothing
127+/// rather than failing.
128+#[allow(clippy::too_many_arguments)]
129+pub async fn repo_log(
130+ handle: &OrgName,
131+ name: &RepoName,
132+ rev: Option<&RefName>,
133+ actor: &Actor,
134+ orgs: &impl OrgRepository,
135+ memberships: &impl MembershipRepository,
136+ repos: &impl RepoRepository,
137+ queries: &impl GitQuery,
138+) -> Result<Option<Vec<CommitSummary>>> {
139+ if view_repo(handle, name, actor, orgs, memberships, repos)
140+ .await?
141+ .is_none()
142+ {
143+ return Ok(None);
144+ }
145+
146+ let Some(rev) = resolve_revision(handle, name, rev, queries).await? else {
147+ return Ok(Some(Vec::new()));
148+ };
149+
150+ Ok(Some(queries.log(handle, name, &rev, LOG_LIMIT).await?))
151+}
152+
153+/// Settles which revision is being asked about.
154+///
155+/// `None` out means the repository has no commits at all — not that the revision was
156+/// wrong, which surfaces later as nothing being found at the path.
157+async fn resolve_revision(
158+ handle: &OrgName,
159+ name: &RepoName,
160+ rev: Option<&RefName>,
161+ queries: &impl GitQuery,
162+) -> Result<Option<RefName>> {
163+ match rev {
164+ Some(rev) => Ok(Some(rev.clone())),
165+ None => Ok(queries.default_branch(handle, name).await?),
166+ }
167+}
168+
169+/// Decides what can be done with a blob's bytes.
170+///
171+/// The port carries bytes and a size; turning those into "text", "binary" or "too big"
172+/// is a display decision, so it happens here rather than in the adapter.
173+fn view_of(blob: Blob) -> FileView {
174+ let too_large = blob.content.is_none();
175+ let text = blob.content.and_then(|bytes| String::from_utf8(bytes).ok());
176+
177+ FileView {
178+ id: blob.id,
179+ size: blob.size,
180+ text,
181+ too_large,
182+ }
183+}
src/application/error.rs+11 −1View file
@@ -1,6 +1,6 @@
11 use crate::domain::{DomainError, repository::RepositoryError};
22
3use super::port::{GitProtocolError, GitStorageError, PasswordError};
3+use super::port::{GitProtocolError, GitQueryError, GitStorageError, PasswordError};
44
55 /// What a use case can fail with.
66 ///
@@ -20,6 +20,8 @@ pub enum Error {
2020 GitStorage(GitStorageError),
2121 /// The git protocol could not be served.
2222 GitProtocol(GitProtocolError),
23+ /// A repository's contents could not be read.
24+ GitQuery(GitQueryError),
2325 }
2426
2527 impl From<DomainError> for Error {
@@ -52,6 +54,12 @@ impl From<GitProtocolError> for Error {
5254 }
5355 }
5456
57+impl From<GitQueryError> for Error {
58+ fn from(error: GitQueryError) -> Self {
59+ Self::GitQuery(error)
60+ }
61+}
62+
5563 impl std::fmt::Display for Error {
5664 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5765 match self {
@@ -60,6 +68,7 @@ impl std::fmt::Display for Error {
6068 Self::Password(error) => write!(f, "{error}"),
6169 Self::GitStorage(error) => write!(f, "{error}"),
6270 Self::GitProtocol(error) => write!(f, "{error}"),
71+ Self::GitQuery(error) => write!(f, "{error}"),
6372 }
6473 }
6574 }
@@ -72,6 +81,7 @@ impl std::error::Error for Error {
7281 Self::Password(error) => Some(error),
7382 Self::GitStorage(error) => Some(error),
7483 Self::GitProtocol(error) => Some(error),
84+ Self::GitQuery(error) => Some(error),
7585 }
7686 }
7787 }
src/application/mod.rs+2 −0View file
@@ -4,6 +4,7 @@
44 //! the rules before any side effect. Nothing here knows about HTTP or Topcoat.
55
66 pub(crate) mod authz;
7+pub mod browse;
78 pub mod claim;
89 pub mod config;
910 pub mod error;
@@ -16,6 +17,7 @@ pub mod repo;
1617 pub mod session;
1718 pub mod token;
1819
20+pub use browse::{Browsed, FileView, LOG_LIMIT, MAX_BLOB_BYTES, browse_repo, repo_log};
1921 pub use claim::{OwnerSpec, claim_instance, is_claimed};
2022 pub use config::AppConfig;
2123 pub use error::{Error, Result};
src/application/port.rs+105 −1View file
@@ -7,7 +7,9 @@ use std::{path::PathBuf, pin::Pin};
77
88 use tokio::io::AsyncRead;
99
10use crate::domain::{OrgName, PasswordHash, RepoName};
10+use crate::domain::{
11+ CommitSummary, ObjectId, OrgName, PasswordHash, RefName, RepoName, RepoPath, TreeEntry,
12+};
1113
1214 /// Hashes and verifies passwords.
1315 ///
@@ -258,3 +260,105 @@ impl std::error::Error for GitProtocolError {
258260 Some(&*self.0)
259261 }
260262 }
263+
264+/// A blob, as far as the port will carry it.
265+///
266+/// `content` is `None` when the blob is larger than the caller's limit: the size is
267+/// still reported, so a page can say how big the thing it will not show is. Reading it
268+/// anyway would let one URL pull an arbitrarily large file into memory.
269+#[derive(Debug, Clone, PartialEq, Eq)]
270+pub struct Blob {
271+ pub id: ObjectId,
272+ pub size: u64,
273+ pub content: Option<Vec<u8>>,
274+}
275+
276+/// Reading what is inside a repository.
277+///
278+/// The third of the three git families named in
279+/// [0006](../../plans/decisions/0006-git-binary-behind-narrow-ports.md), arriving with
280+/// its use case as that ADR requires. Every method takes a handle and a repository name
281+/// rather than a path, matching [`GitStorage`] — where a repository lives is the
282+/// adapter's business.
283+///
284+/// **Bytes are capped, never streamed.** Unlike the protocol, a browse request has a
285+/// person waiting on a rendered page, so a bounded read is the right shape.
286+///
287+/// `Ok(None)` throughout means "no such thing in this repository" — an unknown
288+/// revision, a path that is not there. It is not an authorization answer; that is
289+/// settled before this port is reached.
290+pub trait GitQuery: Send + Sync {
291+ /// The repository's default branch, or `None` if it has no commits yet.
292+ ///
293+ /// Separate from resolving a revision because an empty repository has a `HEAD` that
294+ /// names a branch which does not exist, and telling those apart is the difference
295+ /// between "nothing pushed yet" and a 404.
296+ fn default_branch(
297+ &self,
298+ handle: &OrgName,
299+ name: &RepoName,
300+ ) -> impl Future<Output = Result<Option<RefName>, GitQueryError>> + Send;
301+
302+ /// Resolves a revision to the commit it names.
303+ fn resolve(
304+ &self,
305+ handle: &OrgName,
306+ name: &RepoName,
307+ rev: &RefName,
308+ ) -> impl Future<Output = Result<Option<ObjectId>, GitQueryError>> + Send;
309+
310+ /// Lists a directory, unsorted — ordering is [`TreeEntry::ordering_key`]'s job.
311+ ///
312+ /// `Ok(None)` for a path that is not a directory in this revision, which includes a
313+ /// path that is a file.
314+ fn list_tree(
315+ &self,
316+ handle: &OrgName,
317+ name: &RepoName,
318+ rev: &RefName,
319+ path: &RepoPath,
320+ ) -> impl Future<Output = Result<Option<Vec<TreeEntry>>, GitQueryError>> + Send;
321+
322+ /// Reads a file, up to `max_bytes`.
323+ ///
324+ /// `Ok(None)` for a path that is not a file in this revision.
325+ fn read_blob(
326+ &self,
327+ handle: &OrgName,
328+ name: &RepoName,
329+ rev: &RefName,
330+ path: &RepoPath,
331+ max_bytes: u64,
332+ ) -> impl Future<Output = Result<Option<Blob>, GitQueryError>> + Send;
333+
334+ /// The most recent commits reachable from a revision, newest first.
335+ fn log(
336+ &self,
337+ handle: &OrgName,
338+ name: &RepoName,
339+ rev: &RefName,
340+ limit: usize,
341+ ) -> impl Future<Output = Result<Vec<CommitSummary>, GitQueryError>> + Send;
342+}
343+
344+/// A repository could not be read.
345+#[derive(Debug)]
346+pub struct GitQueryError(Box<dyn std::error::Error + Send + Sync>);
347+
348+impl GitQueryError {
349+ pub fn new(error: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
350+ Self(error.into())
351+ }
352+}
353+
354+impl std::fmt::Display for GitQueryError {
355+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
356+ write!(f, "could not read the repository: {}", self.0)
357+ }
358+}
359+
360+impl std::error::Error for GitQueryError {
361+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
362+ Some(&*self.0)
363+ }
364+}
src/domain/mod.rs+4 −0View file
@@ -8,8 +8,10 @@ pub mod email;
88 pub mod error;
99 pub mod id;
1010 pub mod membership;
11+pub mod object;
1112 pub mod org;
1213 pub mod password;
14+pub mod reference;
1315 pub mod repo;
1416 pub mod repository;
1517 pub mod session;
@@ -22,8 +24,10 @@ pub use email::Email;
2224 pub use error::DomainError;
2325 pub use id::{MembershipId, OrgId, RepoId, TokenId, UserId};
2426 pub use membership::{Membership, Role};
27+pub use object::{CommitSummary, EntryKind, ObjectId, TreeEntry};
2528 pub use org::{OrgName, Organization};
2629 pub use password::PasswordHash;
30+pub use reference::{RefName, RepoPath};
2731 pub use repo::{RepoName, Repository, Visibility};
2832 pub use session::{Session, SessionTokenHash};
2933 pub use setup_token::SetupToken;
src/domain/object.rs+239 −0View file
@@ -0,0 +1,239 @@
1+//! Git objects, as the domain sees them.
2+//!
3+//! Deliberately not a model of git: an id, what a tree entry is, and enough of a commit
4+//! to list one. [0006](../../plans/decisions/0006-git-binary-behind-narrow-ports.md)
5+//! rejected modelling git objects properly as premature, and it still is — this exists
6+//! so a query port returns meaning rather than `String`s.
7+
8+use std::{fmt, time::SystemTime};
9+
10+use super::DomainError;
11+
12+/// The id of a git object, hex-encoded.
13+///
14+/// Accepts both widths git uses: 40 characters for SHA-1 and 64 for SHA-256. Steid
15+/// creates SHA-1 repositories today, and refusing the wider form would turn a
16+/// repository created elsewhere unreadable rather than merely unsupported.
17+#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
18+pub struct ObjectId(String);
19+
20+impl ObjectId {
21+ const SHA1_LEN: usize = 40;
22+ const SHA256_LEN: usize = 64;
23+
24+ /// How much of an id to show. Seven is what git itself abbreviates to by default.
25+ const SHORT_LEN: usize = 7;
26+
27+ pub fn new(value: impl AsRef<str>) -> Result<Self, DomainError> {
28+ let value = value.as_ref().trim();
29+
30+ if value.len() != Self::SHA1_LEN && value.len() != Self::SHA256_LEN {
31+ return Err(DomainError::validation(
32+ "object id",
33+ format!(
34+ "an object id is {} or {} characters, got {}",
35+ Self::SHA1_LEN,
36+ Self::SHA256_LEN,
37+ value.len()
38+ ),
39+ ));
40+ }
41+
42+ if !value.chars().all(|c| c.is_ascii_hexdigit()) {
43+ return Err(DomainError::validation(
44+ "object id",
45+ "an object id is hexadecimal",
46+ ));
47+ }
48+
49+ // Lowercased on the way in so two spellings of one id compare equal.
50+ Ok(Self(value.to_ascii_lowercase()))
51+ }
52+
53+ pub fn from_trusted(value: impl Into<String>) -> Self {
54+ Self(value.into())
55+ }
56+
57+ pub fn as_str(&self) -> &str {
58+ &self.0
59+ }
60+
61+ /// The abbreviated form, for display.
62+ ///
63+ /// Never for lookup: an abbreviation can become ambiguous as a repository grows,
64+ /// which is exactly the bug that only appears once a repository is large.
65+ pub fn short(&self) -> &str {
66+ &self.0[..Self::SHORT_LEN.min(self.0.len())]
67+ }
68+}
69+
70+impl fmt::Display for ObjectId {
71+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72+ f.write_str(&self.0)
73+ }
74+}
75+
76+/// What a tree entry is.
77+///
78+/// Git encodes this in a file mode, which is a POSIX mode only by resemblance — the
79+/// meaningful values are a fixed set, so this is an enum rather than a bitfield.
80+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
81+pub enum EntryKind {
82+ /// A directory.
83+ ///
84+ /// Ordered first so a listing sorts directories above files without a custom
85+ /// comparator at every call site.
86+ Tree,
87+ /// A file.
88+ Blob,
89+ /// A symbolic link. Its "content" is the target path.
90+ Symlink,
91+ /// Another repository, mounted as a submodule. Steid cannot look inside one.
92+ Submodule,
93+}
94+
95+impl EntryKind {
96+ /// Reads git's mode field.
97+ ///
98+ /// Returns `Result`, never a default: an unknown mode silently becoming a file
99+ /// would render a submodule as an empty blob.
100+ pub fn from_mode(mode: &str) -> Result<Self, DomainError> {
101+ // Trimmed because `ls-tree` pads the mode of a tree to six characters with a
102+ // leading zero, while `cat-file` does not.
103+ match mode.trim().trim_start_matches('0') {
104+ "40000" => Ok(Self::Tree),
105+ "100644" | "100755" => Ok(Self::Blob),
106+ "120000" => Ok(Self::Symlink),
107+ "160000" => Ok(Self::Submodule),
108+ other => Err(DomainError::validation(
109+ "mode",
110+ format!("unknown git file mode {other:?}"),
111+ )),
112+ }
113+ }
114+
115+ pub fn is_tree(self) -> bool {
116+ self == Self::Tree
117+ }
118+
119+ pub fn as_str(self) -> &'static str {
120+ match self {
121+ Self::Tree => "tree",
122+ Self::Blob => "blob",
123+ Self::Symlink => "symlink",
124+ Self::Submodule => "submodule",
125+ }
126+ }
127+}
128+
129+/// One entry in a directory listing.
130+#[derive(Debug, Clone, PartialEq, Eq)]
131+pub struct TreeEntry {
132+ /// The entry's own name, never a path — the containing path is the caller's.
133+ pub name: String,
134+ pub kind: EntryKind,
135+ pub id: ObjectId,
136+ /// A blob's size in bytes. `None` for anything without one.
137+ pub size: Option<u64>,
138+}
139+
140+impl TreeEntry {
141+ /// Orders a listing the way a file browser does: directories first, then by name.
142+ ///
143+ /// Case-insensitive, because a listing sorted by byte value puts every capitalised
144+ /// name above every lowercase one, which reads as unsorted.
145+ pub fn ordering_key(&self) -> (EntryKind, String) {
146+ (self.kind, self.name.to_lowercase())
147+ }
148+}
149+
150+/// A commit, reduced to what a log entry shows.
151+#[derive(Debug, Clone, PartialEq, Eq)]
152+pub struct CommitSummary {
153+ pub id: ObjectId,
154+ /// The first line of the message.
155+ pub summary: String,
156+ pub author_name: String,
157+ pub committed_at: SystemTime,
158+}
159+
160+#[cfg(test)]
161+mod tests {
162+ use super::*;
163+
164+ const SHA1: &str = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0";
165+
166+ #[test]
167+ fn both_hash_widths_are_accepted() {
168+ assert!(ObjectId::new(SHA1).is_ok());
169+ assert!(ObjectId::new("a".repeat(64)).is_ok());
170+ }
171+
172+ #[test]
173+ fn an_id_is_lowercased_so_two_spellings_compare_equal() {
174+ assert_eq!(
175+ ObjectId::new(SHA1.to_uppercase()).expect("valid"),
176+ ObjectId::new(SHA1).expect("valid")
177+ );
178+ }
179+
180+ #[test]
181+ fn a_wrong_width_or_non_hex_id_is_refused() {
182+ for value in ["", "abc", &"a".repeat(39), &"a".repeat(41), &"g".repeat(40)] {
183+ assert!(ObjectId::new(value).is_err(), "{value:?} should be refused");
184+ }
185+ }
186+
187+ #[test]
188+ fn the_short_form_is_for_display_only() {
189+ assert_eq!(ObjectId::new(SHA1).expect("valid").short(), "a1b2c3d");
190+ }
191+
192+ #[test]
193+ fn git_modes_map_to_kinds() {
194+ for (mode, expected) in [
195+ ("040000", EntryKind::Tree),
196+ ("40000", EntryKind::Tree),
197+ ("100644", EntryKind::Blob),
198+ ("100755", EntryKind::Blob),
199+ ("120000", EntryKind::Symlink),
200+ ("160000", EntryKind::Submodule),
201+ ] {
202+ assert_eq!(
203+ EntryKind::from_mode(mode).expect("known mode"),
204+ expected,
205+ "mode {mode}"
206+ );
207+ }
208+ }
209+
210+ #[test]
211+ fn an_unknown_mode_is_an_error_not_a_default() {
212+ // Defaulting would render a submodule as an empty file.
213+ assert!(EntryKind::from_mode("100600").is_err());
214+ assert!(EntryKind::from_mode("").is_err());
215+ }
216+
217+ #[test]
218+ fn a_listing_sorts_directories_first_then_case_insensitively_by_name() {
219+ let entry = |name: &str, kind| TreeEntry {
220+ name: name.to_owned(),
221+ kind,
222+ id: ObjectId::new(SHA1).expect("valid"),
223+ size: None,
224+ };
225+
226+ let mut entries = [
227+ entry("README.md", EntryKind::Blob),
228+ entry("src", EntryKind::Tree),
229+ entry("Cargo.toml", EntryKind::Blob),
230+ entry("migrations", EntryKind::Tree),
231+ ];
232+ entries.sort_by_key(TreeEntry::ordering_key);
233+
234+ assert_eq!(
235+ entries.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(),
236+ vec!["migrations", "src", "Cargo.toml", "README.md"]
237+ );
238+ }
239+}
src/domain/reference.rs+378 −0View file
@@ -0,0 +1,378 @@
1+//! How a place inside a repository is addressed: a revision, and a path within it.
2+//!
3+//! Both are user input that ends up as an argument to the `git` binary, so both are
4+//! validated here rather than at the call site — the same reasoning that put traversal
5+//! defence in [`RepoName`](super::RepoName) rather than in `DiskGitStorage`.
6+
7+use std::fmt;
8+
9+use super::DomainError;
10+
11+/// A revision: a branch, a tag, or an object id.
12+///
13+/// Named `RefName` because that is what
14+/// [0006](../../plans/decisions/0006-git-binary-behind-narrow-ports.md) called it, but it
15+/// deliberately accepts an object id too — a URL carries whatever the visitor clicked,
16+/// and deciding whether `a1b2c3` is a branch or a commit is git's job, not ours. What is
17+/// enforced here is only that the value is *safe and well-formed*, never what it points
18+/// at.
19+///
20+/// The rules are git's own `check-ref-format`, minus the parts that only apply to
21+/// writing refs.
22+#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
23+pub struct RefName(String);
24+
25+impl RefName {
26+ /// Long enough for any real branch name; short enough to bound a URL segment.
27+ pub const MAX_LEN: usize = 255;
28+
29+ /// Characters git forbids in a ref, all of which mean something to its revision
30+ /// parser: `~` and `^` walk ancestry, `:` separates a rev from a path, `?`, `*` and
31+ /// `[` are globs, and `\` is an escape.
32+ const FORBIDDEN: [char; 7] = ['~', '^', ':', '?', '*', '[', '\\'];
33+
34+ pub fn new(value: impl AsRef<str>) -> Result<Self, DomainError> {
35+ let value = value.as_ref().trim();
36+
37+ if value.is_empty() {
38+ return Err(invalid("a revision cannot be empty"));
39+ }
40+
41+ if value.chars().count() > Self::MAX_LEN {
42+ return Err(invalid(format!(
43+ "a revision is at most {} characters",
44+ Self::MAX_LEN
45+ )));
46+ }
47+
48+ // A leading hyphen would be read as a flag by the binary this is handed to.
49+ // Refused here, at the boundary, rather than escaped at every call site.
50+ if value.starts_with('-') {
51+ return Err(invalid("a revision cannot start with '-'"));
52+ }
53+
54+ if value.chars().any(|c| c.is_ascii_control() || c == ' ') {
55+ return Err(invalid(
56+ "a revision cannot contain spaces or control characters",
57+ ));
58+ }
59+
60+ if value.chars().any(|c| Self::FORBIDDEN.contains(&c)) {
61+ return Err(invalid("a revision cannot contain ~ ^ : ? * [ or \\"));
62+ }
63+
64+ // `..` is a range, and `@{` is a reflog lookup. Neither addresses a single
65+ // revision, and both would silently mean something other than what was typed.
66+ if value.contains("..") || value.contains("@{") {
67+ return Err(invalid("a revision cannot contain '..' or '@{'"));
68+ }
69+
70+ if value == "@" {
71+ return Err(invalid("'@' is not a revision"));
72+ }
73+
74+ if value.starts_with('/') || value.ends_with('/') || value.contains("//") {
75+ return Err(invalid("a revision cannot have empty path components"));
76+ }
77+
78+ if value.ends_with('.') {
79+ return Err(invalid("a revision cannot end with '.'"));
80+ }
81+
82+ // Per component, because `refs/heads/.hidden` and `refs/heads/x.lock` are both
83+ // refused by git even though the whole string looks fine.
84+ for component in value.split('/') {
85+ if component.starts_with('.') || component.ends_with(".lock") {
86+ return Err(invalid(
87+ "no part of a revision may start with '.' or end with '.lock'",
88+ ));
89+ }
90+ }
91+
92+ Ok(Self(value.to_owned()))
93+ }
94+
95+ /// Wraps a revision already known to be well-formed.
96+ pub fn from_trusted(value: impl Into<String>) -> Self {
97+ Self(value.into())
98+ }
99+
100+ pub fn as_str(&self) -> &str {
101+ &self.0
102+ }
103+}
104+
105+impl fmt::Display for RefName {
106+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107+ f.write_str(&self.0)
108+ }
109+}
110+
111+/// A path to something inside a repository, relative to its root.
112+///
113+/// The empty path is the root itself, which is what a bare `/tree/{ref}/-/` addresses.
114+/// Never touches the filesystem directly — it is handed to git — but it is still user
115+/// input arriving at a subprocess, so `..` is refused rather than normalised away.
116+#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
117+pub struct RepoPath(String);
118+
119+impl RepoPath {
120+ /// Well past any real path, and a bound on what a URL can make git consider.
121+ pub const MAX_LEN: usize = 4096;
122+
123+ pub fn new(value: impl AsRef<str>) -> Result<Self, DomainError> {
124+ let value = value.as_ref().trim_matches('/');
125+
126+ if value.is_empty() {
127+ return Ok(Self::root());
128+ }
129+
130+ if value.len() > Self::MAX_LEN {
131+ return Err(invalid_path(format!(
132+ "a path is at most {} characters",
133+ Self::MAX_LEN
134+ )));
135+ }
136+
137+ if value.starts_with('-') {
138+ return Err(invalid_path("a path cannot start with '-'"));
139+ }
140+
141+ if value.chars().any(|c| c.is_ascii_control()) {
142+ return Err(invalid_path("a path cannot contain control characters"));
143+ }
144+
145+ // `:` separates a revision from a path in git's own syntax, so a colon here
146+ // would let a path smuggle in a second revision.
147+ if value.contains(':') {
148+ return Err(invalid_path("a path cannot contain ':'"));
149+ }
150+
151+ for component in value.split('/') {
152+ match component {
153+ "" => return Err(invalid_path("a path cannot contain empty components")),
154+ "." | ".." => {
155+ return Err(invalid_path("a path cannot contain '.' or '..' components"));
156+ }
157+ _ => {}
158+ }
159+ }
160+
161+ Ok(Self(value.to_owned()))
162+ }
163+
164+ /// The repository root.
165+ pub fn root() -> Self {
166+ Self(String::new())
167+ }
168+
169+ pub fn from_trusted(value: impl Into<String>) -> Self {
170+ Self(value.into())
171+ }
172+
173+ pub fn is_root(&self) -> bool {
174+ self.0.is_empty()
175+ }
176+
177+ pub fn as_str(&self) -> &str {
178+ &self.0
179+ }
180+
181+ /// The path's components, for rendering breadcrumbs.
182+ pub fn components(&self) -> impl Iterator<Item = &str> {
183+ self.0.split('/').filter(|part| !part.is_empty())
184+ }
185+
186+ /// The containing directory, or `None` at the root.
187+ pub fn parent(&self) -> Option<Self> {
188+ if self.is_root() {
189+ return None;
190+ }
191+
192+ Some(match self.0.rsplit_once('/') {
193+ Some((parent, _)) => Self(parent.to_owned()),
194+ None => Self::root(),
195+ })
196+ }
197+
198+ /// This path with one more component on the end.
199+ pub fn join(&self, name: &str) -> Self {
200+ if self.is_root() {
201+ Self(name.to_owned())
202+ } else {
203+ Self(format!("{}/{name}", self.0))
204+ }
205+ }
206+
207+ /// The last component — a file or directory's own name.
208+ pub fn file_name(&self) -> Option<&str> {
209+ if self.is_root() {
210+ return None;
211+ }
212+
213+ Some(self.0.rsplit('/').next().unwrap_or(&self.0))
214+ }
215+}
216+
217+impl fmt::Display for RepoPath {
218+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
219+ f.write_str(&self.0)
220+ }
221+}
222+
223+fn invalid(reason: impl Into<String>) -> DomainError {
224+ DomainError::validation("revision", reason)
225+}
226+
227+fn invalid_path(reason: impl Into<String>) -> DomainError {
228+ DomainError::validation("path", reason)
229+}
230+
231+#[cfg(test)]
232+mod tests {
233+ use super::*;
234+
235+ // --- RefName ------------------------------------------------------------------
236+
237+ #[test]
238+ fn ordinary_revisions_are_accepted() {
239+ for value in [
240+ "main",
241+ "feature/login",
242+ "release/2026-08-29",
243+ "v1.0.0",
244+ "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
245+ "HEAD",
246+ ] {
247+ assert!(RefName::new(value).is_ok(), "{value:?} should be valid");
248+ }
249+ }
250+
251+ #[test]
252+ fn a_revision_that_would_be_read_as_a_flag_is_refused() {
253+ // The value is handed to a subprocess. Refusing here beats escaping everywhere.
254+ assert!(RefName::new("--upload-pack=evil").is_err());
255+ assert!(RefName::new("-main").is_err());
256+ }
257+
258+ #[test]
259+ fn gits_own_forbidden_characters_are_refused() {
260+ for value in [
261+ "ma~in", "ma^in", "ma:in", "ma?in", "ma*in", "ma[in", "ma\\in",
262+ ] {
263+ assert!(RefName::new(value).is_err(), "{value:?} should be refused");
264+ }
265+ }
266+
267+ #[test]
268+ fn range_and_reflog_syntax_are_refused() {
269+ // Both address something other than a single revision.
270+ assert!(RefName::new("main..other").is_err());
271+ assert!(RefName::new("main@{yesterday}").is_err());
272+ }
273+
274+ #[test]
275+ fn empty_components_and_trailing_punctuation_are_refused() {
276+ for value in ["/main", "main/", "feature//login", "main."] {
277+ assert!(RefName::new(value).is_err(), "{value:?} should be refused");
278+ }
279+ }
280+
281+ #[test]
282+ fn dot_prefixed_and_lock_suffixed_components_are_refused() {
283+ assert!(RefName::new(".hidden").is_err());
284+ assert!(RefName::new("refs/.hidden/x").is_err());
285+ assert!(RefName::new("main.lock").is_err());
286+ assert!(RefName::new("refs/heads/main.lock").is_err());
287+ }
288+
289+ #[test]
290+ fn spaces_control_characters_and_bare_at_are_refused() {
291+ assert!(RefName::new("my branch").is_err());
292+ assert!(RefName::new("main\nother").is_err());
293+ assert!(RefName::new("@").is_err());
294+ assert!(RefName::new("").is_err());
295+ assert!(RefName::new(" ").is_err());
296+ }
297+
298+ #[test]
299+ fn a_revision_has_a_length_limit() {
300+ assert!(RefName::new("a".repeat(RefName::MAX_LEN)).is_ok());
301+ assert!(RefName::new("a".repeat(RefName::MAX_LEN + 1)).is_err());
302+ }
303+
304+ // --- RepoPath -----------------------------------------------------------------
305+
306+ #[test]
307+ fn the_empty_path_is_the_root() {
308+ assert!(RepoPath::new("").expect("valid").is_root());
309+ assert!(RepoPath::new("/").expect("valid").is_root());
310+ assert!(RepoPath::root().is_root());
311+ }
312+
313+ #[test]
314+ fn ordinary_paths_are_accepted_and_normalised() {
315+ let path = RepoPath::new("/src/domain/repo.rs/").expect("valid");
316+
317+ assert_eq!(path.as_str(), "src/domain/repo.rs");
318+ assert!(!path.is_root());
319+ }
320+
321+ #[test]
322+ fn traversal_is_refused_rather_than_normalised() {
323+ // Refusing beats cleaning: a normaliser that misses a case fails open.
324+ for value in ["../etc/passwd", "src/../../etc", "src/./x", ".."] {
325+ assert!(RepoPath::new(value).is_err(), "{value:?} should be refused");
326+ }
327+ }
328+
329+ #[test]
330+ fn a_colon_is_refused_because_git_reads_it_as_a_revision_separator() {
331+ assert!(RepoPath::new("src:main").is_err());
332+ }
333+
334+ #[test]
335+ fn a_path_that_would_be_read_as_a_flag_is_refused() {
336+ assert!(RepoPath::new("-rf").is_err());
337+ }
338+
339+ #[test]
340+ fn empty_components_and_control_characters_are_refused() {
341+ assert!(RepoPath::new("src//main.rs").is_err());
342+ assert!(RepoPath::new("src/\0/x").is_err());
343+ }
344+
345+ #[test]
346+ fn a_path_walks_up_to_its_parent() {
347+ let path = RepoPath::new("src/domain/repo.rs").expect("valid");
348+
349+ let parent = path.parent().expect("has a parent");
350+ assert_eq!(parent.as_str(), "src/domain");
351+
352+ let grandparent = parent.parent().expect("has a parent");
353+ assert_eq!(grandparent.as_str(), "src");
354+
355+ let root = grandparent.parent().expect("has a parent");
356+ assert!(root.is_root());
357+ assert_eq!(root.parent(), None, "the root has no parent");
358+ }
359+
360+ #[test]
361+ fn joining_builds_a_child_path() {
362+ let root = RepoPath::root();
363+ assert_eq!(root.join("src").as_str(), "src");
364+ assert_eq!(root.join("src").join("main.rs").as_str(), "src/main.rs");
365+ }
366+
367+ #[test]
368+ fn components_drive_breadcrumbs() {
369+ let path = RepoPath::new("src/domain/repo.rs").expect("valid");
370+
371+ assert_eq!(
372+ path.components().collect::<Vec<_>>(),
373+ vec!["src", "domain", "repo.rs"]
374+ );
375+ assert_eq!(path.file_name(), Some("repo.rs"));
376+ assert_eq!(RepoPath::root().file_name(), None);
377+ }
378+}
src/infrastructure/git.rs+122 −5View file
@@ -5,7 +5,7 @@
55 //! belong here too rather than growing a second recipe.
66
77 use std::{
8 collections::HashSet,
8+ collections::{HashMap, HashSet},
99 ffi::OsStr,
1010 io,
1111 path::PathBuf,
@@ -20,10 +20,10 @@ use tokio::{
2020
2121 use crate::{
2222 application::port::{
23 GitMethod, GitProtocolError, GitProtocolServer, GitRequest, GitResponse, GitStorage,
24 GitStorageError,
23+ Blob, GitMethod, GitProtocolError, GitProtocolServer, GitQuery, GitQueryError, GitRequest,
24+ GitResponse, GitStorage, GitStorageError,
2525 },
26 domain::{OrgName, RepoName},
26+ domain::{CommitSummary, ObjectId, OrgName, RefName, RepoName, RepoPath, TreeEntry},
2727 };
2828
2929 /// The most CGI headers `git http-backend` will ever emit, with room to spare.
@@ -122,7 +122,7 @@ impl GitStorage for DiskGitStorage {
122122 /// redirected object storage. Both the lifecycle commands and the protocol backend
123123 /// build on this, which is the point — 0006 exists because these flags are exactly what
124124 /// drifts silently between call sites.
125fn git_command() -> Command {
125+pub(crate) fn git_command() -> Command {
126126 let mut command = Command::new("git");
127127
128128 // Host configuration must not leak into repositories Steid creates, for the same
@@ -468,6 +468,123 @@ impl GitProtocolServer for InMemoryGitProtocol {
468468 }
469469 }
470470
471+/// A repository's contents, held in memory, for testing use cases and pages.
472+///
473+/// The counterpart to `DiskGitQuery`. Seeded with exactly what a test needs rather than
474+/// pretending to be a git implementation: it answers the questions the port asks and
475+/// knows nothing about how a real repository stores them.
476+#[derive(Debug, Default, Clone)]
477+pub struct InMemoryGitQuery {
478+ default_branch: Option<RefName>,
479+ /// Keyed `rev\0path`, because a tree only means anything at a revision.
480+ trees: HashMap<String, Vec<TreeEntry>>,
481+ blobs: HashMap<String, Vec<u8>>,
482+ commits: Vec<CommitSummary>,
483+}
484+
485+impl InMemoryGitQuery {
486+ /// An empty repository: no default branch, so nothing has been pushed.
487+ pub fn empty() -> Self {
488+ Self::default()
489+ }
490+
491+ /// A repository whose default branch is `main`.
492+ pub fn new() -> Self {
493+ Self {
494+ default_branch: Some(RefName::from_trusted("main")),
495+ ..Self::default()
496+ }
497+ }
498+
499+ fn key(rev: &RefName, path: &RepoPath) -> String {
500+ format!("{}\0{}", rev.as_str(), path.as_str())
501+ }
502+
503+ pub fn with_tree(mut self, rev: &str, path: &str, entries: Vec<TreeEntry>) -> Self {
504+ self.trees.insert(
505+ Self::key(&RefName::from_trusted(rev), &RepoPath::from_trusted(path)),
506+ entries,
507+ );
508+ self
509+ }
510+
511+ pub fn with_blob(mut self, rev: &str, path: &str, content: impl Into<Vec<u8>>) -> Self {
512+ self.blobs.insert(
513+ Self::key(&RefName::from_trusted(rev), &RepoPath::from_trusted(path)),
514+ content.into(),
515+ );
516+ self
517+ }
518+
519+ pub fn with_log(mut self, commits: Vec<CommitSummary>) -> Self {
520+ self.commits = commits;
521+ self
522+ }
523+}
524+
525+impl GitQuery for InMemoryGitQuery {
526+ async fn default_branch(
527+ &self,
528+ _handle: &OrgName,
529+ _name: &RepoName,
530+ ) -> Result<Option<RefName>, GitQueryError> {
531+ Ok(self.default_branch.clone())
532+ }
533+
534+ async fn resolve(
535+ &self,
536+ _handle: &OrgName,
537+ _name: &RepoName,
538+ _rev: &RefName,
539+ ) -> Result<Option<ObjectId>, GitQueryError> {
540+ Ok(self
541+ .default_branch
542+ .as_ref()
543+ .map(|_| ObjectId::from_trusted("0".repeat(40))))
544+ }
545+
546+ async fn list_tree(
547+ &self,
548+ _handle: &OrgName,
549+ _name: &RepoName,
550+ rev: &RefName,
551+ path: &RepoPath,
552+ ) -> Result<Option<Vec<TreeEntry>>, GitQueryError> {
553+ Ok(self.trees.get(&Self::key(rev, path)).cloned())
554+ }
555+
556+ async fn read_blob(
557+ &self,
558+ _handle: &OrgName,
559+ _name: &RepoName,
560+ rev: &RefName,
561+ path: &RepoPath,
562+ max_bytes: u64,
563+ ) -> Result<Option<Blob>, GitQueryError> {
564+ Ok(self.blobs.get(&Self::key(rev, path)).map(|content| {
565+ let size = content.len() as u64;
566+
567+ Blob {
568+ id: ObjectId::from_trusted("1".repeat(40)),
569+ size,
570+ // The same cap the real adapter applies, so a test can exercise the
571+ // too-large path without a megabyte of fixture.
572+ content: (size <= max_bytes).then(|| content.clone()),
573+ }
574+ }))
575+ }
576+
577+ async fn log(
578+ &self,
579+ _handle: &OrgName,
580+ _name: &RepoName,
581+ _rev: &RefName,
582+ limit: usize,
583+ ) -> Result<Vec<CommitSummary>, GitQueryError> {
584+ Ok(self.commits.iter().take(limit).cloned().collect())
585+ }
586+}
587+
471588 #[cfg(test)]
472589 mod tests {
473590 use std::path::Path;
src/infrastructure/git_query.rs+1323 −0View file
@@ -0,0 +1,1323 @@
1+//! Reading repository contents through the `git` binary.
2+//!
3+//! One process per question, per the Milestone 5 amendment to
4+//! [0006](../../plans/decisions/0006-git-binary-behind-narrow-ports.md): ~11–12ms of
5+//! that is `execve`, and the upgrade to a kept-alive `cat-file --batch` is an adapter
6+//! change behind this unchanged port.
7+//!
8+//! # Telling "not there" from "broken"
9+//!
10+//! git reports both with a non-zero exit, and at different call sites with *different*
11+//! non-zero exits: `rev-parse --verify --quiet` says 1 for an unknown ref but 128 for a
12+//! missing repository, while `ls-tree` pointed at a blob says 128 for what is, to a
13+//! visitor, a 404. Keying off exit codes therefore either turns a typo'd URL into a 500
14+//! or buries a corrupt repository behind a "not found".
15+//!
16+//! So every existence question here goes through one command that does not use its exit
17+//! status to answer: `git cat-file --batch-check` writes `<spec> missing` on stdout and
18+//! **exits 0** for anything it cannot resolve — an unknown ref, an absent path, a path
19+//! traversing through a blob, a submodule's commit that lives in another repository.
20+//! That gives a single rule for this whole module:
21+//!
22+//! **A non-zero exit from git is always an error.** "Not found" is a value read off
23+//! stdout, never an exit code. Everything else — git missing from `PATH`, a repository
24+//! directory that is gone, an unreadable object store — surfaces as [`GitQueryError`]
25+//! carrying git's own words.
26+//!
27+//! The listing and content commands are only ever reached *after* `--batch-check` has
28+//! confirmed the object and its type, and are handed the resolved object id rather than
29+//! the user's revision, so their failure modes are genuinely faults.
30+
31+use std::{
32+ ffi::OsStr,
33+ path::{Path, PathBuf},
34+ process::{Output, Stdio},
35+ time::{Duration, SystemTime, UNIX_EPOCH},
36+};
37+
38+use tokio::io::AsyncWriteExt;
39+
40+use crate::{
41+ application::port::{Blob, GitQuery, GitQueryError},
42+ domain::{CommitSummary, EntryKind, ObjectId, OrgName, RefName, RepoName, RepoPath, TreeEntry},
43+ infrastructure::git::git_command,
44+};
45+
46+/// The words `cat-file --batch-check` ends a line with when it did not resolve a spec.
47+///
48+/// `missing` covers the common cases; `ambiguous` is an abbreviated id matching more
49+/// than one object, and `dangling` and `notdir` appear when following a `^{}` or a path
50+/// through something that cannot hold one. None of them is a failure — they are the
51+/// answer "no such thing here".
52+const NOT_FOUND_MARKERS: [&str; 4] = ["missing", "ambiguous", "dangling", "notdir"];
53+
54+/// Repository contents, read from bare repositories under a data directory.
55+#[derive(Debug, Clone)]
56+pub struct DiskGitQuery {
57+ data_dir: PathBuf,
58+}
59+
60+impl DiskGitQuery {
61+ pub fn new(data_dir: impl Into<PathBuf>) -> Self {
62+ Self {
63+ data_dir: data_dir.into(),
64+ }
65+ }
66+
67+ /// Where a repository lives, matching `DiskGitStorage`'s layout.
68+ pub(crate) fn repo_path(&self, handle: &OrgName, name: &RepoName) -> PathBuf {
69+ self.data_dir
70+ .join(handle.as_str())
71+ .join(format!("{name}.git"))
72+ }
73+}
74+
75+impl GitQuery for DiskGitQuery {
76+ async fn default_branch(
77+ &self,
78+ handle: &OrgName,
79+ name: &RepoName,
80+ ) -> Result<Option<RefName>, GitQueryError> {
81+ let repo = self.repo_path(handle, name);
82+
83+ // An empty repository's HEAD names a branch that does not exist yet, so
84+ // `symbolic-ref` happily answers `main` for a repository with nothing in it.
85+ // Whether HEAD *resolves* is the actual question, and it is asked first.
86+ let Some(head) = object_info(&repo, "HEAD").await? else {
87+ return Ok(None);
88+ };
89+
90+ let branch = run(&repo, [OsStr::new("symbolic-ref"), OsStr::new("HEAD")]).await;
91+
92+ match branch {
93+ Ok(output) => {
94+ let full = String::from_utf8_lossy(&output.stdout).trim().to_owned();
95+ // `refs/heads/main` rather than `--short`, because `--short` shortens
96+ // only as far as is unambiguous and would hand back `heads/main` for a
97+ // repository that also has a tag called `main`.
98+ let short = full.strip_prefix("refs/heads/").unwrap_or(&full);
99+
100+ Ok(Some(RefName::from_trusted(short)))
101+ }
102+ // A detached HEAD is not a state Steid creates, but a repository pushed into
103+ // from elsewhere can be in it. The commit is still browsable, so name it
104+ // rather than claiming the repository is empty — which is what `Ok(None)`
105+ // would mean to a page.
106+ Err(_) => Ok(Some(RefName::from_trusted(head.id.as_str()))),
107+ }
108+ }
109+
110+ async fn resolve(
111+ &self,
112+ handle: &OrgName,
113+ name: &RepoName,
114+ rev: &RefName,
115+ ) -> Result<Option<ObjectId>, GitQueryError> {
116+ let repo = self.repo_path(handle, name);
117+
118+ // `^{commit}` peels an annotated tag to what it points at, and refuses a
119+ // revision that names a tree or a blob — a browse page wants a commit, and
120+ // returning a tree id here would fail confusingly two calls later.
121+ let spec = format!("{}^{{commit}}", rev.as_str());
122+
123+ Ok(object_info(&repo, &spec).await?.map(|info| info.id))
124+ }
125+
126+ async fn list_tree(
127+ &self,
128+ handle: &OrgName,
129+ name: &RepoName,
130+ rev: &RefName,
131+ path: &RepoPath,
132+ ) -> Result<Option<Vec<TreeEntry>>, GitQueryError> {
133+ let repo = self.repo_path(handle, name);
134+
135+ let Some(info) = object_info(&repo, &tree_spec(rev, path)).await? else {
136+ return Ok(None);
137+ };
138+
139+ // A file is not a directory. Asking `ls-tree` anyway is a fatal error, which is
140+ // exactly the confusion this check exists to avoid.
141+ if info.kind != ObjectKind::Tree {
142+ return Ok(None);
143+ }
144+
145+ // `-z` because a filename may contain a newline, and `--long` for blob sizes.
146+ // The already-resolved tree id is passed rather than the user's revision, so
147+ // nothing here has to think about what git's revision parser might make of it.
148+ let output = run(
149+ &repo,
150+ [
151+ OsStr::new("ls-tree"),
152+ OsStr::new("-z"),
153+ OsStr::new("--long"),
154+ OsStr::new(info.id.as_str()),
155+ ],
156+ )
157+ .await?;
158+
159+ parse_tree(&output.stdout).map(Some)
160+ }
161+
162+ async fn read_blob(
163+ &self,
164+ handle: &OrgName,
165+ name: &RepoName,
166+ rev: &RefName,
167+ path: &RepoPath,
168+ max_bytes: u64,
169+ ) -> Result<Option<Blob>, GitQueryError> {
170+ let repo = self.repo_path(handle, name);
171+
172+ // The root is a tree, and `{rev}:` is how git spells it — but a caller asking to
173+ // read the root is asking for a file that is not there.
174+ if path.is_root() {
175+ return Ok(None);
176+ }
177+
178+ let Some(info) = object_info(&repo, &tree_spec(rev, path)).await? else {
179+ return Ok(None);
180+ };
181+
182+ // Symlinks are blobs whose content is the target path, and are read as such:
183+ // showing where a link points is more use than a blank page. Trees and
184+ // submodules are not files.
185+ if info.kind != ObjectKind::Blob {
186+ return Ok(None);
187+ }
188+
189+ // The size comes from the object header, so an oversized file is never read.
190+ // Doing this the other way round — read, then measure — is how one URL becomes
191+ // an out-of-memory kill.
192+ let content = if info.size > max_bytes {
193+ None
194+ } else {
195+ let output = run(
196+ &repo,
197+ [
198+ OsStr::new("cat-file"),
199+ OsStr::new("blob"),
200+ OsStr::new(info.id.as_str()),
201+ ],
202+ )
203+ .await?;
204+
205+ Some(output.stdout)
206+ };
207+
208+ Ok(Some(Blob {
209+ id: info.id,
210+ size: info.size,
211+ content,
212+ }))
213+ }
214+
215+ async fn log(
216+ &self,
217+ handle: &OrgName,
218+ name: &RepoName,
219+ rev: &RefName,
220+ limit: usize,
221+ ) -> Result<Vec<CommitSummary>, GitQueryError> {
222+ let repo = self.repo_path(handle, name);
223+
224+ // `git log` on a repository with no commits is a fatal error, and so is a log of
225+ // a branch that does not exist. Resolving first turns both into the empty list
226+ // the port's signature promises, without having to read meaning into a stderr
227+ // string that is localised and free to change between git versions.
228+ let Some(commit) = self.resolve(handle, name, rev).await? else {
229+ return Ok(Vec::new());
230+ };
231+
232+ if limit == 0 {
233+ return Ok(Vec::new());
234+ }
235+
236+ // Every separator is a NUL: `-z` between commits, `%x00` between fields. A
237+ // commit message contains newlines as a matter of course, and a name can contain
238+ // almost anything, so splitting on lines or whitespace would misread real
239+ // history rather than exotic history.
240+ let format = "--format=%H%x00%ct%x00%an%x00%s";
241+ let count = format!("--max-count={limit}");
242+
243+ let output = run(
244+ &repo,
245+ [
246+ OsStr::new("log"),
247+ OsStr::new("-z"),
248+ OsStr::new(&count),
249+ OsStr::new(format),
250+ OsStr::new(commit.as_str()),
251+ ],
252+ )
253+ .await?;
254+
255+ parse_log(&output.stdout)
256+ }
257+}
258+
259+/// What `cat-file --batch-check` said about one object.
260+#[derive(Debug, Clone, PartialEq, Eq)]
261+struct ObjectInfo {
262+ id: ObjectId,
263+ kind: ObjectKind,
264+ size: u64,
265+}
266+
267+/// A git object's type, as its header spells it.
268+///
269+/// Distinct from [`EntryKind`], which is about what a tree entry *means* — the object
270+/// store cannot tell a symlink from a file, because both are blobs.
271+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
272+enum ObjectKind {
273+ Blob,
274+ Tree,
275+ Commit,
276+ Tag,
277+}
278+
279+impl ObjectKind {
280+ fn from_str(value: &str) -> Option<Self> {
281+ match value {
282+ "blob" => Some(Self::Blob),
283+ "tree" => Some(Self::Tree),
284+ "commit" => Some(Self::Commit),
285+ "tag" => Some(Self::Tag),
286+ _ => None,
287+ }
288+ }
289+}
290+
291+/// How git addresses a path inside a revision: `{rev}:{path}`, and `{rev}:` for the root.
292+fn tree_spec(rev: &RefName, path: &RepoPath) -> String {
293+ format!("{}:{}", rev.as_str(), path.as_str())
294+}
295+
296+/// Asks git what one revision-and-path resolves to, or `None` if it resolves to nothing.
297+///
298+/// The spec goes over stdin rather than in an argument, so no revision or path can ever
299+/// be read as a flag regardless of what validation upstream does or stops doing.
300+async fn object_info(repo: &Path, spec: &str) -> Result<Option<ObjectInfo>, GitQueryError> {
301+ let mut command = git_command();
302+ command
303+ .arg("-C")
304+ .arg(repo)
305+ .arg("cat-file")
306+ .arg("--batch-check")
307+ .stdin(Stdio::piped())
308+ .stdout(Stdio::piped())
309+ .stderr(Stdio::piped());
310+
311+ let mut child = command
312+ .spawn()
313+ .map_err(|error| GitQueryError::new(format!("could not run git: {error}")))?;
314+
315+ let mut stdin = child.stdin.take().expect("stdin was piped");
316+
317+ // One short line, far below a pipe's buffer, so writing before waiting cannot
318+ // deadlock. Dropping stdin is what ends the batch — git would otherwise wait for
319+ // another spec forever.
320+ stdin
321+ .write_all(format!("{spec}\n").as_bytes())
322+ .await
323+ .map_err(|error| {
324+ GitQueryError::new(format!("could not ask git about {spec:?}: {error}"))
325+ })?;
326+ drop(stdin);
327+
328+ let output = child
329+ .wait_with_output()
330+ .await
331+ .map_err(|error| GitQueryError::new(format!("could not wait for git: {error}")))?;
332+
333+ // Per the module note: a non-zero exit here is never "not found".
334+ if !output.status.success() {
335+ return Err(GitQueryError::new(format!(
336+ "git exited with {} looking up {spec:?}: {}",
337+ output.status,
338+ String::from_utf8_lossy(&output.stderr).trim()
339+ )));
340+ }
341+
342+ let line = String::from_utf8_lossy(&output.stdout);
343+ let line = line.trim_end_matches('\n');
344+
345+ // The marker is checked before the field count, because a not-found line echoes the
346+ // spec back — and a spec naming a file with spaces in it has no fixed field count.
347+ if line
348+ .rsplit(' ')
349+ .next()
350+ .is_some_and(|last| NOT_FOUND_MARKERS.contains(&last))
351+ {
352+ return Ok(None);
353+ }
354+
355+ let fields: Vec<&str> = line.split_whitespace().collect();
356+ let [id, kind, size] = fields[..] else {
357+ return Err(GitQueryError::new(format!(
358+ "git described {spec:?} in a shape we do not understand: {line:?}"
359+ )));
360+ };
361+
362+ Ok(Some(ObjectInfo {
363+ // Validated rather than trusted. git's ids are trustworthy, but this is a parse
364+ // of text whose layout we have assumed, and an id is about to appear in a URL —
365+ // a misread field should stop here rather than surface as a broken link. The
366+ // cost is a length and hex check next to a process spawn.
367+ id: ObjectId::new(id)
368+ .map_err(|error| GitQueryError::new(format!("git named a bad object id: {error}")))?,
369+ kind: ObjectKind::from_str(kind).ok_or_else(|| {
370+ GitQueryError::new(format!("git reported an unknown object type {kind:?}"))
371+ })?,
372+ size: size.parse().map_err(|_| {
373+ GitQueryError::new(format!("git reported an unreadable object size {size:?}"))
374+ })?,
375+ }))
376+}
377+
378+/// Parses `ls-tree -z --long` output.
379+///
380+/// Each record is `<mode> SP <type> SP <id> SP <size> TAB <name>`, NUL-terminated, where
381+/// the size is space-padded and `-` for anything that is not a blob. The name is
382+/// everything after the first tab and is *raw bytes* — which is why the split happens
383+/// before any attempt to read it as text.
384+fn parse_tree(stdout: &[u8]) -> Result<Vec<TreeEntry>, GitQueryError> {
385+ let mut entries = Vec::new();
386+
387+ for record in stdout.split(|byte| *byte == 0) {
388+ if record.is_empty() {
389+ continue;
390+ }
391+
392+ let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
393+ return Err(GitQueryError::new(
394+ "git listed a tree entry with no name separator",
395+ ));
396+ };
397+
398+ let (meta, name) = record.split_at(tab);
399+ let name = &name[1..];
400+
401+ let meta = std::str::from_utf8(meta).map_err(|_| {
402+ GitQueryError::new("git listed a tree entry whose metadata is not text")
403+ })?;
404+
405+ let fields: Vec<&str> = meta.split_whitespace().collect();
406+ let [mode, _type, id, size] = fields[..] else {
407+ return Err(GitQueryError::new(format!(
408+ "git listed a tree entry in a shape we do not understand: {meta:?}"
409+ )));
410+ };
411+
412+ entries.push(TreeEntry {
413+ // Lossy, because `TreeEntry::name` is a `String` and a filename is not
414+ // required to be UTF-8. A replacement character renders; refusing to list
415+ // the whole directory because one file has an odd name does not.
416+ name: String::from_utf8_lossy(name).into_owned(),
417+ kind: EntryKind::from_mode(mode)
418+ .map_err(|error| GitQueryError::new(format!("git listed {name:?}: {error}")))?,
419+ id: ObjectId::new(id).map_err(|error| {
420+ GitQueryError::new(format!("git named a bad object id: {error}"))
421+ })?,
422+ // `-` for a tree or a submodule, which have no size a listing can show.
423+ size: size.parse().ok(),
424+ });
425+ }
426+
427+ // Unsorted on purpose: ordering is `TreeEntry::ordering_key`'s decision, made once
428+ // in the application rather than differently in each adapter.
429+ Ok(entries)
430+}
431+
432+/// Parses the NUL-separated `log` stream into four-field records.
433+fn parse_log(stdout: &[u8]) -> Result<Vec<CommitSummary>, GitQueryError> {
434+ // `-z` terminates the last record too, so the split leaves a trailing empty field
435+ // that is not a commit.
436+ let fields: Vec<&[u8]> = stdout
437+ .split(|byte| *byte == 0)
438+ .filter(|field| !field.is_empty())
439+ .collect();
440+
441+ let mut commits = Vec::with_capacity(fields.len() / 4);
442+
443+ for record in fields.chunks(4) {
444+ let [id, committed_at, author_name, summary] = record[..] else {
445+ return Err(GitQueryError::new(
446+ "git logged a commit with missing fields",
447+ ));
448+ };
449+
450+ let id = String::from_utf8_lossy(id);
451+ let committed_at = String::from_utf8_lossy(committed_at);
452+ let committed_at: i64 = committed_at.trim().parse().map_err(|_| {
453+ GitQueryError::new(format!(
454+ "git logged an unreadable commit time {committed_at:?}"
455+ ))
456+ })?;
457+
458+ commits.push(CommitSummary {
459+ id: ObjectId::new(id.trim()).map_err(|error| {
460+ GitQueryError::new(format!("git named a bad object id: {error}"))
461+ })?,
462+ // `%s` is git's subject: the first paragraph, joined into one line. Trimmed
463+ // to the first line anyway, because that invariant is git's rather than
464+ // something this parser should assume.
465+ summary: String::from_utf8_lossy(summary)
466+ .lines()
467+ .next()
468+ .unwrap_or_default()
469+ .to_owned(),
470+ author_name: String::from_utf8_lossy(author_name).into_owned(),
471+ committed_at: unix_time(committed_at),
472+ });
473+ }
474+
475+ Ok(commits)
476+}
477+
478+/// A unix timestamp as a `SystemTime`, including the negative ones.
479+///
480+/// A commit dated before 1970 is either a lie or an import from something older than
481+/// git, and both exist in real repositories. `UNIX_EPOCH + Duration` would panic on the
482+/// subtraction it cannot do.
483+fn unix_time(seconds: i64) -> SystemTime {
484+ match u64::try_from(seconds) {
485+ Ok(seconds) => UNIX_EPOCH + Duration::from_secs(seconds),
486+ Err(_) => UNIX_EPOCH - Duration::from_secs(seconds.unsigned_abs()),
487+ }
488+}
489+
490+/// Runs a git command inside a repository and fails on a non-zero exit.
491+///
492+/// Only ever used for commands whose subject has already been confirmed to exist, so a
493+/// failure really is a failure. Built from [`git_command`] so the host isolation 0006
494+/// insists on cannot drift out of this module.
495+async fn run<I, S>(repo: &Path, args: I) -> Result<Output, GitQueryError>
496+where
497+ I: IntoIterator<Item = S>,
498+ S: AsRef<OsStr>,
499+{
500+ let mut command = git_command();
501+ command.arg("-C").arg(repo).args(args).stdin(Stdio::null());
502+
503+ let output = command
504+ .output()
505+ .await
506+ .map_err(|error| GitQueryError::new(format!("could not run git: {error}")))?;
507+
508+ if !output.status.success() {
509+ return Err(GitQueryError::new(format!(
510+ "git exited with {}: {}",
511+ output.status,
512+ String::from_utf8_lossy(&output.stderr).trim()
513+ )));
514+ }
515+
516+ Ok(output)
517+}
518+
519+#[cfg(test)]
520+mod tests {
521+ use std::collections::HashMap;
522+
523+ use tempfile::TempDir;
524+
525+ use super::*;
526+ use crate::domain::EntryKind;
527+
528+ /// Fixed so a timestamp assertion is exact rather than approximate.
529+ const FIRST_COMMIT: i64 = 1_700_000_000;
530+ const SECOND_COMMIT: i64 = 1_700_000_100;
531+ const THIRD_COMMIT: i64 = 1_700_000_200;
532+
533+ /// A subject with the punctuation a naive parser splits on, followed by a body — so
534+ /// a test can prove the body does not leak into the summary.
535+ const ODD_MESSAGE: &str =
536+ "third: 'quotes', \"doubles\" | pipes\n\nbody line one\nbody line two";
537+
538+ const BINARY: &[u8] = &[0x00, 0x01, 0xff, 0xfe, 0x80];
539+
540+ fn handle() -> OrgName {
541+ OrgName::new("jamesgill").expect("valid handle")
542+ }
543+
544+ fn repo_name() -> RepoName {
545+ RepoName::new("steid").expect("valid repository name")
546+ }
547+
548+ fn rev(value: &str) -> RefName {
549+ RefName::new(value).expect("valid revision")
550+ }
551+
552+ fn path(value: &str) -> RepoPath {
553+ RepoPath::new(value).expect("valid path")
554+ }
555+
556+ /// Runs git in a fixture, isolated from the host's configuration the same way the
557+ /// adapter is — otherwise a developer's `commit.gpgsign` or `init.defaultBranch`
558+ /// decides whether the suite passes.
559+ fn git(dir: &Path, when: i64, args: &[&str]) {
560+ let date = format!("@{when} +0000");
561+
562+ let output = std::process::Command::new("git")
563+ .arg("-C")
564+ .arg(dir)
565+ .args(args)
566+ .env("GIT_CONFIG_GLOBAL", "/dev/null")
567+ .env("GIT_CONFIG_SYSTEM", "/dev/null")
568+ .env("GIT_AUTHOR_NAME", "Ada Lovelace")
569+ .env("GIT_AUTHOR_EMAIL", "ada@example.com")
570+ .env("GIT_COMMITTER_NAME", "Ada Lovelace")
571+ .env("GIT_COMMITTER_EMAIL", "ada@example.com")
572+ .env("GIT_AUTHOR_DATE", &date)
573+ .env("GIT_COMMITTER_DATE", &date)
574+ .output()
575+ .expect("git should be on PATH");
576+
577+ assert!(
578+ output.status.success(),
579+ "git {args:?} failed: {}",
580+ String::from_utf8_lossy(&output.stderr)
581+ );
582+ }
583+
584+ /// A data directory holding one empty bare repository, exactly as Steid creates it.
585+ ///
586+ /// The `TempDir` is returned because dropping it deletes the fixture.
587+ fn empty() -> (TempDir, DiskGitQuery) {
588+ let dir = TempDir::new().expect("temp dir");
589+ let query = DiskGitQuery::new(dir.path());
590+ let repo = query.repo_path(&handle(), &repo_name());
591+
592+ std::fs::create_dir_all(repo.parent().expect("has a parent")).expect("create handle dir");
593+ git(
594+ dir.path(),
595+ FIRST_COMMIT,
596+ &[
597+ "init",
598+ "--bare",
599+ "--quiet",
600+ "--template=",
601+ "--initial-branch=main",
602+ "--",
603+ repo.to_str().expect("utf-8 fixture path"),
604+ ],
605+ );
606+
607+ (dir, query)
608+ }
609+
610+ /// The empty repository with three commits pushed into it, the way a real one fills
611+ /// up — a working copy and a push, rather than plumbing straight into the object
612+ /// store.
613+ fn populated() -> (TempDir, DiskGitQuery) {
614+ let (dir, query) = empty();
615+ let repo = query.repo_path(&handle(), &repo_name());
616+ let work = dir.path().join("work");
617+
618+ std::fs::create_dir_all(work.join("src/deep")).expect("create work tree");
619+ git(&work, FIRST_COMMIT, &["init", "--quiet", "-b", "main"]);
620+
621+ std::fs::write(work.join("README.md"), b"hello\n").expect("write");
622+ std::fs::write(work.join("with space.txt"), b"spaced\n").expect("write");
623+ std::fs::write(work.join("bin.dat"), BINARY).expect("write");
624+ std::fs::write(work.join("big.txt"), vec![b'x'; 100]).expect("write");
625+ std::fs::write(work.join("src/deep/file.rs"), b"fn main() {}\n").expect("write");
626+ std::os::unix::fs::symlink("README.md", work.join("link")).expect("symlink");
627+
628+ git(&work, FIRST_COMMIT, &["add", "-A"]);
629+ git(&work, FIRST_COMMIT, &["commit", "--quiet", "-m", "first"]);
630+
631+ std::fs::write(work.join("README.md"), b"hello again\n").expect("write");
632+ git(&work, SECOND_COMMIT, &["add", "-A"]);
633+ git(&work, SECOND_COMMIT, &["commit", "--quiet", "-m", "second"]);
634+
635+ git(
636+ &work,
637+ THIRD_COMMIT,
638+ &["commit", "--quiet", "--allow-empty", "-m", ODD_MESSAGE],
639+ );
640+
641+ git(
642+ &work,
643+ THIRD_COMMIT,
644+ &[
645+ "push",
646+ "--quiet",
647+ repo.to_str().expect("utf-8 fixture path"),
648+ "main",
649+ ],
650+ );
651+
652+ (dir, query)
653+ }
654+
655+ /// A listing keyed by name, so an assertion does not depend on an order the port
656+ /// explicitly does not promise.
657+ fn by_name(entries: Vec<TreeEntry>) -> HashMap<String, TreeEntry> {
658+ entries
659+ .into_iter()
660+ .map(|entry| (entry.name.clone(), entry))
661+ .collect()
662+ }
663+
664+ // --- an empty repository ---------------------------------------------------
665+
666+ #[tokio::test]
667+ async fn an_empty_repository_has_no_default_branch() {
668+ // The distinction the port exists for: HEAD names `main`, but `main` has no
669+ // commits, so "nothing pushed yet" rather than a branch a page can browse.
670+ let (_dir, query) = empty();
671+
672+ assert_eq!(
673+ query
674+ .default_branch(&handle(), &repo_name())
675+ .await
676+ .expect("should read"),
677+ None
678+ );
679+ }
680+
681+ #[tokio::test]
682+ async fn nothing_resolves_in_an_empty_repository() {
683+ let (_dir, query) = empty();
684+
685+ for revision in ["main", "HEAD", "v1.0"] {
686+ assert_eq!(
687+ query
688+ .resolve(&handle(), &repo_name(), &rev(revision))
689+ .await
690+ .expect("should read"),
691+ None,
692+ "{revision} should not resolve"
693+ );
694+ }
695+ }
696+
697+ #[tokio::test]
698+ async fn an_empty_repository_lists_nothing_and_reads_nothing() {
699+ let (_dir, query) = empty();
700+
701+ assert_eq!(
702+ query
703+ .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root())
704+ .await
705+ .expect("should read"),
706+ None
707+ );
708+ assert_eq!(
709+ query
710+ .read_blob(
711+ &handle(),
712+ &repo_name(),
713+ &rev("main"),
714+ &path("README.md"),
715+ 1024
716+ )
717+ .await
718+ .expect("should read"),
719+ None
720+ );
721+ }
722+
723+ #[tokio::test]
724+ async fn an_empty_repository_has_an_empty_log() {
725+ // `git log` is a fatal error here, and an empty list is what the port promises.
726+ let (_dir, query) = empty();
727+
728+ assert_eq!(
729+ query
730+ .log(&handle(), &repo_name(), &rev("main"), 10)
731+ .await
732+ .expect("should read"),
733+ Vec::new()
734+ );
735+ }
736+
737+ // --- a missing repository is a failure, not a 404 ---------------------------
738+
739+ #[tokio::test]
740+ async fn a_repository_that_is_not_on_disk_is_an_error() {
741+ // A record with no directory is a fault to investigate, not a "no such branch".
742+ // Answering `Ok(None)` here would hide it behind a plausible-looking 404.
743+ let (_dir, query) = empty();
744+ let missing = RepoName::new("never-created").expect("valid repository name");
745+
746+ assert!(query.default_branch(&handle(), &missing).await.is_err());
747+ assert!(
748+ query
749+ .resolve(&handle(), &missing, &rev("main"))
750+ .await
751+ .is_err()
752+ );
753+ assert!(
754+ query
755+ .list_tree(&handle(), &missing, &rev("main"), &RepoPath::root())
756+ .await
757+ .is_err()
758+ );
759+ assert!(
760+ query
761+ .log(&handle(), &missing, &rev("main"), 10)
762+ .await
763+ .is_err()
764+ );
765+ }
766+
767+ // --- default_branch and resolve --------------------------------------------
768+
769+ #[tokio::test]
770+ async fn a_repository_with_commits_reports_its_default_branch() {
771+ let (_dir, query) = populated();
772+
773+ assert_eq!(
774+ query
775+ .default_branch(&handle(), &repo_name())
776+ .await
777+ .expect("should read"),
778+ Some(RefName::from_trusted("main"))
779+ );
780+ }
781+
782+ #[tokio::test]
783+ async fn a_branch_and_head_resolve_to_the_same_commit() {
784+ let (_dir, query) = populated();
785+
786+ let main = query
787+ .resolve(&handle(), &repo_name(), &rev("main"))
788+ .await
789+ .expect("should read")
790+ .expect("main should resolve");
791+ let head = query
792+ .resolve(&handle(), &repo_name(), &rev("HEAD"))
793+ .await
794+ .expect("should read");
795+
796+ assert_eq!(head, Some(main));
797+ }
798+
799+ #[tokio::test]
800+ async fn a_commit_id_resolves_to_itself() {
801+ let (_dir, query) = populated();
802+
803+ let main = query
804+ .resolve(&handle(), &repo_name(), &rev("main"))
805+ .await
806+ .expect("should read")
807+ .expect("main should resolve");
808+
809+ assert_eq!(
810+ query
811+ .resolve(&handle(), &repo_name(), &rev(main.as_str()))
812+ .await
813+ .expect("should read"),
814+ Some(main)
815+ );
816+ }
817+
818+ #[tokio::test]
819+ async fn an_unknown_revision_resolves_to_nothing() {
820+ let (_dir, query) = populated();
821+
822+ assert_eq!(
823+ query
824+ .resolve(&handle(), &repo_name(), &rev("no-such-branch"))
825+ .await
826+ .expect("looking up a missing branch is not a failure"),
827+ None
828+ );
829+ }
830+
831+ // --- list_tree --------------------------------------------------------------
832+
833+ #[tokio::test]
834+ async fn the_root_lists_every_top_level_entry() {
835+ let (_dir, query) = populated();
836+
837+ let entries = by_name(
838+ query
839+ .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root())
840+ .await
841+ .expect("should read")
842+ .expect("the root is a directory"),
843+ );
844+
845+ let mut names: Vec<&str> = entries.keys().map(String::as_str).collect();
846+ names.sort_unstable();
847+ assert_eq!(
848+ names,
849+ vec![
850+ "README.md",
851+ "big.txt",
852+ "bin.dat",
853+ "link",
854+ "src",
855+ "with space.txt"
856+ ]
857+ );
858+ assert_eq!(entries["src"].kind, EntryKind::Tree);
859+ assert_eq!(entries["README.md"].kind, EntryKind::Blob);
860+ assert_eq!(
861+ entries["link"].kind,
862+ EntryKind::Symlink,
863+ "a symlink is its own kind, not a file"
864+ );
865+ }
866+
867+ #[tokio::test]
868+ async fn a_listing_carries_blob_sizes_but_not_tree_sizes() {
869+ let (_dir, query) = populated();
870+
871+ let entries = by_name(
872+ query
873+ .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root())
874+ .await
875+ .expect("should read")
876+ .expect("the root is a directory"),
877+ );
878+
879+ assert_eq!(entries["big.txt"].size, Some(100));
880+ assert_eq!(
881+ entries["src"].size, None,
882+ "a directory has no size a listing can show"
883+ );
884+ }
885+
886+ #[tokio::test]
887+ async fn a_filename_containing_a_space_survives_the_listing() {
888+ // The reason `-z` is not optional: split on whitespace and this name becomes two.
889+ let (_dir, query) = populated();
890+
891+ let entries = by_name(
892+ query
893+ .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root())
894+ .await
895+ .expect("should read")
896+ .expect("the root is a directory"),
897+ );
898+
899+ assert_eq!(entries["with space.txt"].kind, EntryKind::Blob);
900+ assert_eq!(entries["with space.txt"].size, Some(7));
901+ }
902+
903+ #[tokio::test]
904+ async fn a_nested_directory_lists_only_its_own_entries() {
905+ let (_dir, query) = populated();
906+
907+ let entries = query
908+ .list_tree(&handle(), &repo_name(), &rev("main"), &path("src"))
909+ .await
910+ .expect("should read")
911+ .expect("src is a directory");
912+
913+ assert_eq!(entries.len(), 1);
914+ assert_eq!(entries[0].name, "deep", "names are entry names, not paths");
915+ assert_eq!(entries[0].kind, EntryKind::Tree);
916+
917+ let deeper = query
918+ .list_tree(&handle(), &repo_name(), &rev("main"), &path("src/deep"))
919+ .await
920+ .expect("should read")
921+ .expect("src/deep is a directory");
922+
923+ assert_eq!(deeper.len(), 1);
924+ assert_eq!(deeper[0].name, "file.rs");
925+ }
926+
927+ #[tokio::test]
928+ async fn listing_a_file_as_a_directory_finds_nothing() {
929+ // git calls this a fatal error; to a visitor it is a wrong URL.
930+ let (_dir, query) = populated();
931+
932+ assert_eq!(
933+ query
934+ .list_tree(&handle(), &repo_name(), &rev("main"), &path("README.md"))
935+ .await
936+ .expect("a file is not a failure"),
937+ None
938+ );
939+ }
940+
941+ #[tokio::test]
942+ async fn listing_a_path_that_is_not_there_finds_nothing() {
943+ let (_dir, query) = populated();
944+
945+ for missing in ["nope", "src/nope", "README.md/nope"] {
946+ assert_eq!(
947+ query
948+ .list_tree(&handle(), &repo_name(), &rev("main"), &path(missing))
949+ .await
950+ .expect("should read"),
951+ None,
952+ "{missing} should not be found"
953+ );
954+ }
955+ }
956+
957+ #[tokio::test]
958+ async fn listing_at_an_unknown_revision_finds_nothing() {
959+ let (_dir, query) = populated();
960+
961+ assert_eq!(
962+ query
963+ .list_tree(
964+ &handle(),
965+ &repo_name(),
966+ &rev("no-such-branch"),
967+ &RepoPath::root()
968+ )
969+ .await
970+ .expect("should read"),
971+ None
972+ );
973+ }
974+
975+ #[tokio::test]
976+ async fn a_listing_reflects_the_revision_it_was_asked_for() {
977+ // Proves the revision is actually used rather than HEAD being read every time.
978+ let (_dir, query) = populated();
979+
980+ let first = query
981+ .log(&handle(), &repo_name(), &rev("main"), 10)
982+ .await
983+ .expect("should read")
984+ .last()
985+ .expect("three commits")
986+ .id
987+ .clone();
988+
989+ let old = query
990+ .read_blob(
991+ &handle(),
992+ &repo_name(),
993+ &rev(first.as_str()),
994+ &path("README.md"),
995+ 1024,
996+ )
997+ .await
998+ .expect("should read")
999+ .expect("README existed in the first commit");

Showing the first 1000 lines of 1324. View the whole file at this revision.

src/infrastructure/mod.rs+1 −0View file
@@ -1,5 +1,6 @@
11 pub mod database;
22 pub mod git;
3+pub mod git_query;
34 pub mod password;
45 pub mod repository;
56 pub mod web;
src/infrastructure/web/browse.rs+776 −0View file
@@ -0,0 +1,776 @@
1+//! Browsing a repository — the file tree, a single file, and the commit log.
2+//!
3+//! Four routes, all reading through [`browse_repo`] and [`repo_log`], so visibility is
4+//! decided in one place: a repository invisible on its page is invisible here, and
5+//! `Ok(None)` from either use case renders as the same 404 as a repository that was
6+//! never created.
7+//!
8+//! The URL carries a `/-/` separator between the revision and the path
9+//! (`/tree/{rev}/-/{path}`) because a ref may contain slashes, so a candidate split
10+//! would cost a ref lookup — another `git` process — on every page. The separator is
11+//! unambiguous by construction instead.
12+//!
13+//! One route serves both directories and files. Which one a path is, is git's answer,
14+//! not the URL's, and a link that had to know would be wrong the moment a file became
15+//! a directory.
16+
17+use std::time::{SystemTime, UNIX_EPOCH};
18+
19+use topcoat::{
20+ Result,
21+ context::Cx,
22+ icon::{icon, iconify::iconify_icon},
23+ router::{
24+ error::{RouterErrorExt, not_found},
25+ page, path_param,
26+ },
27+ view::{attributes, component, view},
28+};
29+
30+use crate::{
31+ application::{Browsed, FileView, RepoView, browse_repo, repo_log},
32+ components::badge::{BadgeVariant, badge},
33+ domain::{CommitSummary, EntryKind, RefName, RepoPath, TreeEntry},
34+};
35+
36+use super::{
37+ context::{current_actor, memberships, orgs, queries, repos, server_error},
38+ repo::{clone_url, clone_url_for, repo_for},
39+};
40+
41+/// `{rev}` from the path, raw — validation is [`RefName`]'s job.
42+#[path_param]
43+struct Rev(str);
44+
45+/// `{*path}` from the path: every remaining segment, as one string.
46+#[path_param]
47+struct Path(str);
48+
49+/// Which page of a repository is being looked at, for the nav.
50+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51+pub(super) enum Tab {
52+ Files,
53+ Log,
54+}
55+
56+/// The revision from the URL, or 404.
57+///
58+/// A malformed revision is a page that does not exist rather than a bad request — the
59+/// same reasoning that 404s a malformed handle.
60+fn rev_param(cx: &Cx) -> Result<RefName> {
61+ Ok(RefName::new(path_param::<Rev>(cx)).map_err(|_| not_found())?)
62+}
63+
64+/// The path from the URL, or 404. Same reasoning as [`rev_param`].
65+fn path_arg(cx: &Cx) -> Result<RepoPath> {
66+ Ok(RepoPath::new(path_param::<Path>(cx)).map_err(|_| not_found())?)
67+}
68+
69+#[page("/{handle}/repos/{name}/tree/{rev}")]
70+async fn tree_root_page(cx: &Cx) -> Result {
71+ let rev = rev_param(cx)?;
72+
73+ view! { browsing(rev: Some(rev), path: RepoPath::root()) }
74+}
75+
76+#[page("/{handle}/repos/{name}/tree/{rev}/-/{*path}")]
77+async fn tree_path_page(cx: &Cx) -> Result {
78+ let rev = rev_param(cx)?;
79+ let path = path_arg(cx)?;
80+
81+ view! { browsing(rev: Some(rev), path: path) }
82+}
83+
84+#[page("/{handle}/repos/{name}/log")]
85+async fn log_page(_cx: &Cx) -> Result {
86+ view! { history(rev: None) }
87+}
88+
89+#[page("/{handle}/repos/{name}/log/{rev}")]
90+async fn log_rev_page(cx: &Cx) -> Result {
91+ let rev = rev_param(cx)?;
92+
93+ view! { history(rev: Some(rev)) }
94+}
95+
96+/// Reads a path in a repository, or 404.
97+///
98+/// Shared with the repository page, which browses the default branch at the root.
99+pub(super) async fn browsed_at(
100+ cx: &Cx,
101+ repo: &RepoView,
102+ rev: Option<&RefName>,
103+ path: &RepoPath,
104+) -> Result<Browsed> {
105+ Ok(browse_repo(
106+ &repo.handle,
107+ &repo.name,
108+ rev,
109+ path,
110+ &current_actor(cx).await?,
111+ &orgs(cx),
112+ &memberships(cx),
113+ &repos(cx),
114+ &queries(cx),
115+ )
116+ .await
117+ .map_err(server_error)?
118+ .ok_or_not_found()?)
119+}
120+
121+/// The tree and blob pages, which differ only in what git found at the path.
122+///
123+/// A component rather than a plain function because `view!` needs the request context
124+/// in scope, and a component is how a body of markup gets it — the same reason the
125+/// layout is a layout.
126+#[component]
127+async fn browsing(cx: &Cx, rev: Option<RefName>, path: RepoPath) -> Result {
128+ let repo = repo_for(cx).await?;
129+ let browsed = browsed_at(cx, &repo, rev.as_ref(), &path).await?;
130+ let clone = clone_url_for(cx, &repo);
131+
132+ view! {
133+ repo_bar(repo: &repo, rev: browsed_rev(&browsed), active: Tab::Files)
134+
135+ match &browsed {
136+ Browsed::Empty => {
137+ clone_url(url: clone.as_str())
138+ empty_repo(url: clone.as_str())
139+ },
140+ Browsed::Directory { rev, path, entries } => directory(
141+ handle: repo.handle.as_str(),
142+ name: repo.name.as_str(),
143+ rev: rev,
144+ path: path,
145+ entries: entries,
146+ ),
147+ Browsed::File { rev, path, file } => blob(
148+ handle: repo.handle.as_str(),
149+ name: repo.name.as_str(),
150+ rev: rev,
151+ path: path,
152+ file: file,
153+ ),
154+ }
155+ }
156+}
157+
158+/// The commit log page. A component for the same reason as [`browsing`].
159+#[component]
160+async fn history(cx: &Cx, rev: Option<RefName>) -> Result {
161+ let repo = repo_for(cx).await?;
162+
163+ let log = repo_log(
164+ &repo.handle,
165+ &repo.name,
166+ rev.as_ref(),
167+ &current_actor(cx).await?,
168+ &orgs(cx),
169+ &memberships(cx),
170+ &repos(cx),
171+ &queries(cx),
172+ )
173+ .await
174+ .map_err(server_error)?
175+ .ok_or_not_found()?;
176+
177+ view! {
178+ repo_bar(
179+ repo: &repo,
180+ rev: rev.as_ref().map(RefName::as_str).unwrap_or_default(),
181+ active: Tab::Log,
182+ )
183+ commit_log(commits: &log)
184+ }
185+}
186+
187+/// The revision a browse landed on, for display. Empty when there is none.
188+fn browsed_rev(browsed: &Browsed) -> &str {
189+ match browsed {
190+ Browsed::Empty => "",
191+ Browsed::Directory { rev, .. } | Browsed::File { rev, .. } => rev.as_str(),
192+ }
193+}
194+
195+// --- URLs -------------------------------------------------------------------------
196+
197+/// The URL for a path at a revision.
198+///
199+/// The revision is encoded whole, slashes included, so `feature/login` stays one
200+/// segment and the `/-/` separator keeps its meaning. The path keeps its slashes,
201+/// because they *are* segments.
202+pub(super) fn tree_url(handle: &str, name: &str, rev: &RefName, path: &RepoPath) -> String {
203+ let encoded = encode(rev.as_str(), false);
204+
205+ if path.is_root() {
206+ format!("/{handle}/repos/{name}/tree/{encoded}")
207+ } else {
208+ format!(
209+ "/{handle}/repos/{name}/tree/{encoded}/-/{}",
210+ encode(path.as_str(), true)
211+ )
212+ }
213+}
214+
215+/// The commit log's URL, at a revision or at the default branch.
216+fn log_url(handle: &str, name: &str, rev: &str) -> String {
217+ if rev.is_empty() {
218+ format!("/{handle}/repos/{name}/log")
219+ } else {
220+ format!("/{handle}/repos/{name}/log/{}", encode(rev, false))
221+ }
222+}
223+
224+/// Percent-encodes a URL segment.
225+///
226+/// Hand-rolled rather than pulled in as a dependency: it is the unreserved set from
227+/// RFC 3986 and nothing else. `keep_slash` is the difference between a path, whose
228+/// slashes are structure, and a revision, whose slashes are part of its name.
229+fn encode(value: &str, keep_slash: bool) -> String {
230+ let mut encoded = String::with_capacity(value.len());
231+
232+ for byte in value.bytes() {
233+ match byte {
234+ b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
235+ encoded.push(byte as char);
236+ }
237+ b'/' if keep_slash => encoded.push('/'),
238+ other => encoded.push_str(&format!("%{other:02X}")),
239+ }
240+ }
241+
242+ encoded
243+}
244+
245+// --- Formatting -------------------------------------------------------------------
246+
247+/// A file size, rounded for reading rather than for accounting.
248+fn size_of(bytes: u64) -> String {
249+ const UNITS: [&str; 4] = ["KB", "MB", "GB", "TB"];
250+
251+ if bytes < 1024 {
252+ return format!("{bytes} B");
253+ }
254+
255+ let mut value = bytes as f64 / 1024.0;
256+ let mut unit = UNITS[0];
257+
258+ for next in &UNITS[1..] {
259+ if value < 1024.0 {
260+ break;
261+ }
262+
263+ value /= 1024.0;
264+ unit = next;
265+ }
266+
267+ format!("{value:.1} {unit}")
268+}
269+
270+/// How long ago something happened, in words.
271+///
272+/// A commit's timestamp comes from whoever made it, so it can sit in the future — a
273+/// skewed clock, or a rewritten history. That reads as "just now" rather than as a
274+/// negative duration.
275+fn ago(time: SystemTime) -> String {
276+ let Ok(elapsed) = SystemTime::now().duration_since(time) else {
277+ return "just now".to_owned();
278+ };
279+
280+ let seconds = elapsed.as_secs();
281+
282+ let (count, unit) = match seconds {
283+ 0..=59 => return "just now".to_owned(),
284+ 60..=3599 => (seconds / 60, "minute"),
285+ 3600..=86_399 => (seconds / 3600, "hour"),
286+ 86_400..=2_591_999 => (seconds / 86_400, "day"),
287+ 2_592_000..=31_535_999 => (seconds / 2_592_000, "month"),
288+ _ => (seconds / 31_536_000, "year"),
289+ };
290+
291+ if count == 1 {
292+ format!("1 {unit} ago")
293+ } else {
294+ format!("{count} {unit}s ago")
295+ }
296+}
297+
298+/// The exact time, for the tooltip behind [`ago`].
299+fn timestamp(time: SystemTime) -> String {
300+ let seconds = time
301+ .duration_since(UNIX_EPOCH)
302+ .map(|since| since.as_secs() as i64)
303+ .unwrap_or(0);
304+
305+ let (year, month, day) = civil_from_days(seconds.div_euclid(86_400));
306+ let rest = seconds.rem_euclid(86_400);
307+
308+ format!(
309+ "{year:04}-{month:02}-{day:02} {:02}:{:02} UTC",
310+ rest / 3600,
311+ (rest % 3600) / 60
312+ )
313+}
314+
315+/// Days since the epoch to a calendar date, by Howard Hinnant's `civil_from_days`.
316+///
317+/// Written out rather than taken as a dependency: this is the whole of the date
318+/// handling Steid needs, and a date library is a large thing to add for one function.
319+fn civil_from_days(days: i64) -> (i64, u32, u32) {
320+ // Shift the epoch to 0000-03-01, which puts the leap day at the end of the year.
321+ let shifted = days + 719_468;
322+ let era = shifted.div_euclid(146_097);
323+ let day_of_era = shifted.rem_euclid(146_097);
324+
325+ let year_of_era =
326+ (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
327+ let year = year_of_era + era * 400;
328+ let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
329+
330+ let shifted_month = (5 * day_of_year + 2) / 153;
331+ let day = (day_of_year - (153 * shifted_month + 2) / 5 + 1) as u32;
332+ let month = if shifted_month < 10 {
333+ shifted_month + 3
334+ } else {
335+ shifted_month - 9
336+ } as u32;
337+
338+ (if month <= 2 { year + 1 } else { year }, month, day)
339+}
340+
341+// --- Views ------------------------------------------------------------------------
342+
343+/// The bar every repository page carries: where you are, and what else there is.
344+///
345+/// Kept out of the repository page's own header so that a tree, a file and a log all
346+/// read as the same repository rather than as three unrelated pages.
347+#[component]
348+pub(super) async fn repo_bar(repo: &RepoView, rev: &str, active: Tab) -> Result {
349+ let handle = repo.handle.as_str();
350+ let name = repo.name.as_str();
351+ let tab = |current| {
352+ if current {
353+ "text-foreground border-foreground"
354+ } else {
355+ "text-muted-foreground border-transparent hover:text-foreground"
356+ }
357+ };
358+
359+ view! {
360+ <header class="mb-6 border-b border-border pb-3">
361+ <p class="font-mono text-sm text-muted-foreground">
362+ <a href=(format!("/{handle}")) class="hover:text-foreground">"@" (handle)</a>
363+ " / "
364+ <a href=(format!("/{handle}/repos/{name}")) class="text-foreground hover:underline">
365+ (name)
366+ </a>
367+ if !repo.visibility.is_public() {
368+ " "
369+ badge(variant: BadgeVariant::Outline, "Private")
370+ }
371+ </p>
372+
373+ <nav class="mt-3 flex items-center gap-5 text-sm">
374+ <a
375+ href=(format!("/{handle}/repos/{name}"))
376+ class=(format!("-mb-3 border-b-2 pb-2 {}", tab(active == Tab::Files)))
377+ >"Files"</a>
378+ <a
379+ href=(log_url(handle, name, rev))
380+ class=(format!("-mb-3 border-b-2 pb-2 {}", tab(active == Tab::Log)))
381+ >"Commits"</a>
382+
383+ if !rev.is_empty() {
384+ <span class="ml-auto inline-flex items-center gap-1.5 font-mono text-xs text-muted-foreground">
385+ icon(data: iconify_icon!("feather:git-branch"), attrs: attributes! {
386+ class="size-3.5"
387+ })
388+ (rev)
389+ </span>
390+ }
391+ </nav>
392+ </header>
393+ }
394+}
395+
396+/// What a repository with no commits offers instead of a listing.
397+///
398+/// This is the state every freshly-created repository is in, so it is the first thing
399+/// its owner sees — the snippet is the point of the page, not decoration.
400+#[component]
401+pub(super) async fn empty_repo(url: &str) -> Result {
402+ let push = format!("git remote add origin {url}\ngit branch -M main\ngit push -u origin main");
403+
404+ view! {
405+ <div class="mt-6 rounded-lg border border-border px-4 py-5">
406+ <p class="text-sm text-muted-foreground">
407+ "This repository has no commits yet. Push one to see it here."
408+ </p>
409+ <p class="mt-4 text-xs font-medium uppercase tracking-wider text-muted-foreground">
410+ "Push an existing repository"
411+ </p>
412+ <pre class="mt-2 overflow-x-auto rounded-lg border border-border bg-muted px-4 py-3 font-mono text-sm">(push)</pre>
413+ </div>
414+ }
415+}
416+
417+/// The path you are at, with every ancestor linked.
418+///
419+/// The main way anyone moves around a tree: the last component is the current page and
420+/// is deliberately not a link, so the trail reads as a position rather than a menu.
421+#[component]
422+async fn crumbs(handle: &str, name: &str, rev: &RefName, path: &RepoPath) -> Result {
423+ let parts: Vec<&str> = path.components().collect();
424+ let mut walked = RepoPath::root();
425+ let mut trail: Vec<(String, String)> = Vec::new();
426+
427+ for (index, part) in parts.iter().enumerate() {
428+ walked = walked.join(part);
429+
430+ let href = if index + 1 == parts.len() {
431+ String::new()
432+ } else {
433+ tree_url(handle, name, rev, &walked)
434+ };
435+
436+ trail.push(((*part).to_owned(), href));
437+ }
438+
439+ view! {
440+ <div class="flex flex-wrap items-center gap-1 font-mono text-sm">
441+ <a
442+ href=(tree_url(handle, name, rev, &RepoPath::root()))
443+ class="text-muted-foreground hover:text-foreground"
444+ >(name)</a>
445+
446+ for (part, href) in &trail {
447+ <span class="text-muted-foreground">"/"</span>
448+ match href.is_empty() {
449+ true => <span class="font-medium">(part)</span>,
450+ false => <a href=(href) class="text-muted-foreground hover:text-foreground">(part)</a>,
451+ }
452+ }
453+ </div>
454+ }
455+}
456+
457+/// A directory listing.
458+///
459+/// Entries arrive ordered by the use case — directories first, then case-insensitively
460+/// by name — so nothing here re-sorts them.
461+#[component]
462+pub(super) async fn directory(
463+ handle: &str,
464+ name: &str,
465+ rev: &RefName,
466+ path: &RepoPath,
467+ entries: &[TreeEntry],
468+) -> Result {
469+ view! {
470+ <div class="overflow-hidden rounded-lg border border-border">
471+ <div class="border-b border-border px-4 py-2.5">
472+ crumbs(handle: handle, name: name, rev: rev, path: path)
473+ </div>
474+
475+ if entries.is_empty() {
476+ <p class="px-4 py-6 text-center text-sm text-muted-foreground">
477+ "This directory is empty."
478+ </p>
479+ } else {
480+ <ul class="divide-y divide-border text-sm">
481+ match path.parent() {
482+ Some(parent) => <li class="px-4 py-2">
483+ <a
484+ href=(tree_url(handle, name, rev, &parent))
485+ class="inline-flex items-center gap-2 font-mono text-muted-foreground hover:text-foreground"
486+ >
487+ icon(data: iconify_icon!("feather:corner-left-up"), attrs: attributes! {
488+ class="size-4"
489+ })
490+ ".."
491+ </a>
492+ </li>,
493+ None => "",
494+ }
495+
496+ for entry in entries {
497+ <li class="flex items-center gap-3 px-4 py-2">
498+ entry_row(
499+ handle: handle,
500+ name: name,
501+ rev: rev,
502+ path: path,
503+ entry: entry,
504+ )
505+ </li>
506+ }
507+ </ul>
508+ }
509+ </div>
510+ }
511+}
512+
513+/// One entry in a listing.
514+///
515+/// A symlink and a submodule are their own kinds, not files: a submodule is another
516+/// repository Steid cannot look inside, so it is labelled and left unlinked rather
517+/// than offered as a click that would 404.
518+#[component]
519+async fn entry_row(
520+ handle: &str,
521+ name: &str,
522+ rev: &RefName,
523+ path: &RepoPath,
524+ entry: &TreeEntry,
525+) -> Result {
526+ let href = tree_url(handle, name, rev, &path.join(&entry.name));
527+ let linkable = entry.kind != EntryKind::Submodule;
528+
529+ view! {
530+ <span class="text-muted-foreground">
531+ match entry.kind {
532+ EntryKind::Tree => icon(
533+ data: iconify_icon!("feather:folder"),
534+ label: "Directory",
535+ attrs: attributes! { class="size-4" },
536+ ),
537+ EntryKind::Blob => icon(
538+ data: iconify_icon!("feather:file"),
539+ label: "File",
540+ attrs: attributes! { class="size-4" },
541+ ),
542+ EntryKind::Symlink => icon(
543+ data: iconify_icon!("feather:link-2"),
544+ label: "Symlink",
545+ attrs: attributes! { class="size-4" },
546+ ),
547+ EntryKind::Submodule => icon(
548+ data: iconify_icon!("feather:package"),
549+ label: "Submodule",
550+ attrs: attributes! { class="size-4" },
551+ ),
552+ }
553+ </span>
554+
555+ match linkable {
556+ true => <a
557+ href=(href)
558+ class=(if entry.kind.is_tree() {
559+ "font-mono font-medium hover:underline"
560+ } else {
561+ "font-mono hover:underline"
562+ })
563+ >(&entry.name)</a>,
564+ false => <span class="font-mono">(&entry.name)</span>,
565+ }
566+
567+ match entry.kind {
568+ EntryKind::Symlink => badge(variant: BadgeVariant::Outline, "symlink"),
569+ EntryKind::Submodule => badge(variant: BadgeVariant::Outline, "submodule"),
570+ _ => "",
571+ }
572+
573+ <span class="ml-auto font-mono text-xs text-muted-foreground">
574+ match entry.size {
575+ Some(size) => (size_of(size)),
576+ None if entry.kind == EntryKind::Submodule => (entry.id.short()),
577+ None => "",
578+ }
579+ </span>
580+ }
581+}
582+
583+/// A single file.
584+///
585+/// Three outcomes, all of them a page rather than an error: text, something that is not
586+/// text, and something too big to be worth rendering. The last says how big, because
587+/// that is the only useful thing left to say about it.
588+#[component]
589+pub(super) async fn blob(
590+ handle: &str,
591+ name: &str,
592+ rev: &RefName,
593+ path: &RepoPath,
594+ file: &FileView,
595+) -> Result {
596+ view! {
597+ <div class="overflow-hidden rounded-lg border border-border">
598+ <div class="flex flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-2.5">
599+ crumbs(handle: handle, name: name, rev: rev, path: path)
600+ <span class="font-mono text-xs text-muted-foreground">(size_of(file.size))</span>
601+ </div>
602+
603+ match &file.text {
604+ Some(text) => source(text: text.as_str()),
605+ None if file.too_large => <p class="px-4 py-6 text-center text-sm text-muted-foreground">
606+ "This file is " (size_of(file.size)) ", which is too large to display. Clone the repository to read it."
607+ </p>,
608+ None => <p class="px-4 py-6 text-center text-sm text-muted-foreground">
609+ "This file cannot be displayed as text."
610+ </p>,
611+ }
612+ </div>
613+ }
614+}
615+
616+/// A file's contents, with line numbers.
617+///
618+/// A table rather than a `<pre>` with a gutter: the numbers stay put when the code
619+/// scrolls sideways, and selecting the code does not drag the numbers along with it.
620+#[component]
621+async fn source(text: &str) -> Result {
622+ view! {
623+ <div class="overflow-x-auto">
624+ <table class="w-full border-collapse font-mono text-xs leading-relaxed">
625+ <tbody>
626+ for (index, line) in text.lines().enumerate() {
627+ <tr>
628+ <td class="w-px select-none border-r border-border px-3 text-right align-top text-muted-foreground">
629+ ((index + 1).to_string())
630+ </td>
631+ <td class="whitespace-pre px-4 align-top">
632+ (if line.is_empty() { " " } else { line })
633+ </td>
634+ </tr>
635+ }
636+ </tbody>
637+ </table>
638+ </div>
639+ }
640+}
641+
642+/// The commit log — the most recent commits, newest first, and no paging in v1.
643+#[component]
644+async fn commit_log(commits: &[CommitSummary]) -> Result {
645+ view! {
646+ if commits.is_empty() {
647+ <p class="rounded-lg border border-border px-4 py-10 text-center text-sm text-muted-foreground">
648+ "No commits yet."
649+ </p>
650+ } else {
651+ <ul class="divide-y divide-border rounded-lg border border-border">
652+ for commit in commits {
653+ <li class="px-4 py-3">
654+ <div class="flex items-baseline justify-between gap-4">
655+ <p class="text-sm font-medium">(&commit.summary)</p>
656+ <code class="shrink-0 font-mono text-xs text-muted-foreground">
657+ (commit.id.short())
658+ </code>
659+ </div>
660+ <p class="mt-1 text-xs text-muted-foreground">
661+ (&commit.author_name)
662+ " committed "
663+ <span title=(timestamp(commit.committed_at))>(ago(commit.committed_at))</span>
664+ </p>
665+ </li>
666+ }
667+ </ul>
668+ }
669+ }
670+}
671+
672+#[cfg(test)]
673+mod tests {
674+ use std::time::Duration;
675+
676+ use super::*;
677+
678+ fn rev(value: &str) -> RefName {
679+ RefName::new(value).expect("valid revision")
680+ }
681+
682+ #[test]
683+ fn a_root_tree_url_has_no_separator() {
684+ assert_eq!(
685+ tree_url("ada", "steid", &rev("main"), &RepoPath::root()),
686+ "/ada/repos/steid/tree/main"
687+ );
688+ }
689+
690+ #[test]
691+ fn a_path_follows_the_separator_with_its_slashes_intact() {
692+ let path = RepoPath::new("src/domain/repo.rs").expect("valid");
693+
694+ assert_eq!(
695+ tree_url("ada", "steid", &rev("main"), &path),
696+ "/ada/repos/steid/tree/main/-/src/domain/repo.rs"
697+ );
698+ }
699+
700+ #[test]
701+ fn a_revisions_slashes_are_encoded_so_it_stays_one_segment() {
702+ // Otherwise `feature/login` would look like a revision plus a path, which is
703+ // the ambiguity the separator exists to remove.
704+ assert_eq!(
705+ tree_url("ada", "steid", &rev("feature/login"), &RepoPath::root()),
706+ "/ada/repos/steid/tree/feature%2Flogin"
707+ );
708+ }
709+
710+ #[test]
711+ fn names_needing_escaping_are_encoded() {
712+ let path = RepoPath::new("docs/a b#c.md").expect("valid");
713+
714+ assert_eq!(
715+ tree_url("ada", "steid", &rev("main"), &path),
716+ "/ada/repos/steid/tree/main/-/docs/a%20b%23c.md"
717+ );
718+ }
719+
720+ #[test]
721+ fn the_log_url_is_the_default_branch_when_no_revision_is_named() {
722+ assert_eq!(log_url("ada", "steid", ""), "/ada/repos/steid/log");
723+ assert_eq!(
724+ log_url("ada", "steid", "feature/login"),
725+ "/ada/repos/steid/log/feature%2Flogin"
726+ );
727+ }
728+
729+ #[test]
730+ fn sizes_read_as_sizes() {
731+ assert_eq!(size_of(0), "0 B");
732+ assert_eq!(size_of(999), "999 B");
733+ assert_eq!(size_of(1024), "1.0 KB");
734+ assert_eq!(size_of(1_048_576), "1.0 MB");
735+ assert_eq!(size_of(1_572_864), "1.5 MB");
736+ }
737+
738+ #[test]
739+ fn elapsed_time_reads_as_words() {
740+ let now = SystemTime::now();
741+ let since = |seconds| ago(now - Duration::from_secs(seconds));
742+
743+ assert_eq!(since(5), "just now");
744+ assert_eq!(since(60), "1 minute ago");
745+ assert_eq!(since(7200), "2 hours ago");
746+ assert_eq!(since(86_400 * 3), "3 days ago");
747+ assert_eq!(since(86_400 * 400), "1 year ago");
748+ }
749+
750+ #[test]
751+ fn a_commit_from_the_future_reads_as_now_rather_than_as_a_negative() {
752+ // A commit carries whoever made it's clock, so this happens.
753+ assert_eq!(
754+ ago(SystemTime::now() + Duration::from_secs(3600)),
755+ "just now"
756+ );
757+ }
758+
759+ #[test]
760+ fn timestamps_are_utc_calendar_dates() {
761+ assert_eq!(
762+ timestamp(UNIX_EPOCH + Duration::from_secs(0)),
763+ "1970-01-01 00:00 UTC"
764+ );
765+ // 2026-08-29T12:34:00Z
766+ assert_eq!(
767+ timestamp(UNIX_EPOCH + Duration::from_secs(1_788_006_840)),
768+ "2026-08-29 12:34 UTC"
769+ );
770+ // A leap day, which is what the calendar arithmetic exists to get right.
771+ assert_eq!(
772+ timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)),
773+ "2024-02-29 00:00 UTC"
774+ );
775+ }
776+}
src/infrastructure/web/context.rs+6 −0View file
@@ -24,6 +24,7 @@ use crate::{
2424 domain::{Actor, SessionTokenHash},
2525 infrastructure::{
2626 git::{DiskGitStorage, GitHttpBackend},
27+ git_query::DiskGitQuery,
2728 repository::{
2829 SqliteMembershipRepo, SqliteOrgRepo, SqliteRepoRepo, SqliteSessionRepo,
2930 SqliteTokenRepo, SqliteUserRepo,
@@ -71,6 +72,11 @@ pub fn tokens(cx: &Cx) -> SqliteTokenRepo {
7172 SqliteTokenRepo::new(pool(cx).clone())
7273 }
7374
75+/// Repository contents, read from the same data directory as [`storage`].
76+pub fn queries(cx: &Cx) -> DiskGitQuery {
77+ DiskGitQuery::new(app_context::<AppConfig>(cx).data_dir.clone())
78+}
79+
7480 /// The git smart-HTTP protocol, rooted at the same data directory as [`storage`].
7581 pub fn protocol(cx: &Cx) -> GitHttpBackend {
7682 GitHttpBackend::new(app_context::<AppConfig>(cx).data_dir.clone())
src/infrastructure/web/mod.rs+1 −0View file
@@ -1,6 +1,7 @@
11 //! The web surface: pages, forms, and the request-scoped helpers they use.
22
33 pub mod api;
4+pub mod browse;
45 pub mod context;
56 pub mod git;
67 pub mod layout;
src/infrastructure/web/repo.rs+44 −8View file
@@ -18,7 +18,7 @@ use topcoat::{
1818 };
1919
2020 use crate::{
21 application::{Error, NewRepo, RepoSummary, RepoView, create_repo, view_repo},
21+ application::{Browsed, Error, NewRepo, RepoSummary, RepoView, create_repo, view_repo},
2222 components::{
2323 badge::{BadgeVariant, badge},
2424 button::button,
@@ -28,10 +28,11 @@ use crate::{
2828 select::select,
2929 textarea::textarea,
3030 },
31 domain::{DomainError, RepoName, Repository, Visibility},
31+ domain::{DomainError, RepoName, RepoPath, Repository, Visibility},
3232 };
3333
3434 use super::{
35+ browse::{blob, browsed_at, directory, empty_repo},
3536 context::{
3637 current_actor, location, memberships, orgs, public_origin, repos, server_error, storage,
3738 },
@@ -58,7 +59,7 @@ fn optional(value: &str) -> Option<String> {
5859 ///
5960 /// A repository the viewer may not see and one that does not exist are the same
6061 /// answer here, deliberately — see [`view_repo`].
61async fn repo_for(cx: &Cx) -> Result<RepoView> {
62+pub(super) async fn repo_for(cx: &Cx) -> Result<RepoView> {
6263 let profile = profile_for(cx).await?;
6364 let name = RepoName::new(path_param::<Name>(cx)).map_err(|_| not_found())?;
6465 let actor = current_actor(cx).await?;
@@ -162,10 +163,16 @@ async fn create(cx: &Cx, Form(submitted): Form<CreateForm>) -> Result {
162163 }
163164 }
164165
166+/// The repository's own page: its default branch, at the root.
167+///
168+/// The listing is the page rather than a link to one — the reason to open a repository
169+/// is to see what is in it. An empty repository gets push instructions instead, which
170+/// is the only useful thing to show someone who has just created one.
165171 #[page("/{handle}/repos/{name}")]
166172 async fn repo_page(cx: &Cx) -> Result {
167173 let repo = repo_for(cx).await?;
168174 let clone = clone_url_for(cx, &repo);
175+ let browsed = browsed_at(cx, &repo, None, &RepoPath::root()).await?;
169176
170177 view! {
171178 <header class="mb-8">
@@ -193,9 +200,38 @@ async fn repo_page(cx: &Cx) -> Result {
193200
194201 clone_url(url: clone.as_str())
195202
196 <div class="mt-6 rounded-lg border border-border px-4 py-10 text-center">
197 <p class="text-sm text-muted-foreground">"This repository is empty."</p>
198 </div>
203+ match &browsed {
204+ Browsed::Empty => empty_repo(url: clone.as_str()),
205+ Browsed::Directory { rev, path, entries } => {
206+ <div class="mt-8 mb-3 flex items-center justify-between text-sm">
207+ <span class="inline-flex items-center gap-1.5 font-mono text-xs text-muted-foreground">
208+ (rev.as_str())
209+ </span>
210+ <a
211+ href=(format!("/{}/repos/{}/log", repo.handle, repo.name))
212+ class="text-muted-foreground hover:text-foreground"
213+ >"Commits"</a>
214+ </div>
215+ directory(
216+ handle: repo.handle.as_str(),
217+ name: repo.name.as_str(),
218+ rev: rev,
219+ path: path,
220+ entries: entries,
221+ )
222+ },
223+ // The root of a revision is always a directory, so this is unreachable in
224+ // practice — rendered rather than errored so it can never be a 500.
225+ Browsed::File { rev, path, file } => <div class="mt-8">
226+ blob(
227+ handle: repo.handle.as_str(),
228+ name: repo.name.as_str(),
229+ rev: rev,
230+ path: path,
231+ file: file,
232+ )
233+ </div>,
234+ }
199235 }
200236 }
201237
@@ -293,7 +329,7 @@ async fn new_repo_form(
293329 /// Built from the origin the page is being served on, so it is correct wherever the
294330 /// instance is deployed without anything having to be configured. A private repository
295331 /// gets the same URL: cloning it needs a token, not a different address.
296fn clone_url_for(cx: &Cx, repo: &RepoView) -> String {
332+pub(super) fn clone_url_for(cx: &Cx, repo: &RepoView) -> String {
297333 format!(
298334 "{}/{}/repos/{}.git",
299335 public_origin(cx),
@@ -307,7 +343,7 @@ fn clone_url_for(cx: &Cx, repo: &RepoView) -> String {
307343 /// Shown for every repository a viewer can see, including an empty one — an empty
308344 /// repository is exactly when someone needs this, because it is what they push to.
309345 #[component]
310async fn clone_url(url: &str) -> Result {
346+pub(super) async fn clone_url(url: &str) -> Result {
311347 view! {
312348 <div class="mt-6">
313349 <p class="text-xs font-medium uppercase tracking-wider text-muted-foreground">