gitmote

gitmote  ·  history  ·  branches

docs: design the URLs epic — flat repo namespace, in-path refs, visibility

f5d20ddd9bdd197494c910ac062565d6776982e2
Atanas Minev <atmin@protonmail.com> · 2026-07-05 20:12

diff --git a/docs/architecture/urls.md b/docs/architecture/urls.md
new file mode 100644
index 0000000..c94fcca
--- /dev/null
+++ b/docs/architecture/urls.md
@@ -0,0 +1,176 @@
+# URLs
+
+gitmote is a **personal instance**: one owner, a handful of repos, a few invited
+people. That single fact sets the whole URL shape — repos live in a **flat,
+single-segment namespace** (`gitmote.example.com/<repo>`), not `user/repo`. There
+is no per-user namespace to disambiguate, so there is nothing to disambiguate.
+
+> **Status:** target design. It is implemented by the `tasks/urls-*` chain; the
+> sections here are reconciled into [storage.md](storage.md), [auth.md](auth.md),
+> and [request-flows.md](request-flows.md) as those tasks land. Until then the
+> code still serves the old `owner/repo` scheme.
+
+## The namespace
+
+Repos are a single path segment. Everything that is *not* repo content is either a
+verb under the repo or a top-level global route:
+
+| What | URL | Access |
+|------|-----|--------|
+| Dashboard / repo list | `/` | per viewer |
+| Repo landing (README + tree @ default branch) | `/<repo>` (+ git's own suffixes) | read |
+| Clone / fetch | `GET /<repo>/info/refs?service=git-upload-pack`, `POST /<repo>/git-upload-pack` | read |
+| Push | `POST /<repo>/git-receive-pack` | write |
+| Browse (dir or file) | `/<repo>/tree/<ref>/<path…>` | read |
+| File (explicit) | `/<repo>/blob/<ref>/<path…>` | read |
+| Raw bytes | `/<repo>/raw/<ref>/<path…>` | read |
+| History / one commit | `/<repo>/commits/<ref>/<path…>`, `/<repo>/commit/<sha>` | read |
+| Branches / tags | `/<repo>/refs` | read |
+| CI runs / one run | `/<repo>/runs`, `/<repo>/runs/<id>` | read |
+| Settings (default branch, visibility, rename, delete) | `/<repo>/settings` | admin |
+| Access (invite / revoke collaborators & spectators) | `/<repo>/access` | admin |
+| CI secrets | `/<repo>/secrets` | admin |
+| Login / logout | `/login`, `/logout` | — |
+| Users | `/users` | admin |
+| Personal access tokens | `/tokens` | signed-in |
+| Static assets | `/static/…` | — |
+| Liveness / version | `/healthz`, `/version` | — |
+
+**Reserved names are a small fixed set** — the top-level globals `login`,
+`logout`, `users`, `tokens`, `settings`, `new`, `search`, `api`, `metrics`,
+`internal`, `static`, `healthz`, `version` — plus two structural rules: a repo
+name may not **start with `.`** and may not be the bare **`-`** (nor be empty). A
+name is rejected against this at creation with a clear error. `internal` is
+*already* live (the CI report API, [request-flows.md](request-flows.md)); the rest
+are reserved ahead of need, because un-reserving later — once a repo owns the name
+— is a breaking rename, while reserving costs the single owner nothing.
+
+Nothing else is reserved: the content/action verbs (`tree`, `blob`, `raw`,
+`commits`, `runs`, …) sit at **segment 2**, so a repo *named* `tree` is legal
+(`/tree/tree/main/x` = repo `tree`, verb `tree`, ref `main`, path `x`). Git's own
+segment-2 suffixes (`info`, `git-upload-pack`, `git-receive-pack`, `HEAD`,
+`objects`) and the UI verbs are disjoint fixed vocabularies.
+
+Repo-name rules: a single segment, no `/`; not reserved (above); a trailing
+`.git` is stripped (`clone host/gitmote.git` == `host/gitmote`).
+
+## Content addressing
+
+Three content verbs, and the **ref lives in the path, never a query parameter**:
+
+- **`tree/<ref>/<path>`** — canonical and unified. A directory renders a listing;
+ a file renders its view (markdown / highlighted source). The server detects
+ which from the tree.
+- **`blob/<ref>/<path>`** — explicit "this is a file": renders the file. If the
+ path is actually a directory it **301s to `tree/<ref>/<path>`** (canonicalize,
+ don't 404). So `blob` self-heals and `tree` never guesses wrong.
+- **`raw/<ref>/<path>`** — the bytes (file only; a directory is 404).
+
+The UI and the markdown rewriter **emit `blob` for files and `tree` for
+directories** (self-describing, GitHub-familiar); both verbs stay tolerant as
+above so a hand-written or external link with the "wrong" verb still resolves.
+
+**Ref resolution is greedy.** `<ref>` and `<path>` both may contain slashes, so
+the server takes the **longest leading path prefix that names a real ref**
+(branch or tag from the materialized repo). `/<repo>/tree/feature/login/src/x.go`
+with a branch `feature/login` → ref `feature/login`, path `src/x.go`. An omitted
+ref (the bare `/<repo>` landing) means the repo's default branch.
+
+## Relative links in rendered content
+
+Ref-in-path is what makes relative links work: a link from
+`/<repo>/tree/main/docs/readme.md` resolves against that URL and keeps `<repo>`
+and `<ref>` for free. But two things a bare relative link can't carry, the
+renderer must supply, so **rendered markdown has its relative links rewritten**:
+
+- **Embedded assets** (`![](diagram.png)`) must point at `raw` bytes, not an HTML
+ view — the browser can't infer that, so images/embeds are rewritten to
+ `/<repo>/raw/<ref>/…`.
+- **Navigation links** are rewritten to the precise verb (`blob` for a file,
+ `tree` for a directory, decided by a tree lookup) with the current ref baked in.
+
+Absolute URLs, external `http(s)` links, and in-page anchors (`#…`) are left
+untouched.
+
+## Access & visibility
+
+Users exist so the owner can invite people; access is per-repo, read on every
+request from the `acls` table ([auth.md](auth.md)):
+
+- **`read` = spectator**, **`write` = collaborator**, **`admin`** = repo
+ management. The "invite" flows are just *create/select user + grant the ACL* —
+ no new perm model.
+- **`repos.visibility`** is `private` (default) or `public`. A **public** repo is
+ readable with **no token** (clone, fetch, and browse). Writes are **never**
+ anonymous — `git-receive-pack` and all management always require a `write`/
+ `admin` ACL, public or not.
+- Reading (browse + fetch) is gated by **repo-read** — public → anyone, private →
+ a `read` ACL — *not* by global admin. (Browse is admin-only today; this opens
+ it up, which is what makes spectators and public repos usable.)
+- **Force-pushing the default branch is admin-only.** A `write` collaborator may
+ fast-forward any branch (including the default) and may force-push or delete
+ *non-default* branches, but a **non-fast-forward or deletion of the default
+ branch requires `admin`**. History on the branch everyone tracks is protected
+ from a collaborator's rewrite; the ordinary day-to-day push (a fast-forward) is
+ unaffected. This is enforced on the write path, where the old→new relationship
+ is known (the ref CAS / pre-receive hook, [safety.md](safety.md)), not at the
+ initial request authorization.
+
+Per-repo *ownership* leaves the namespace entirely: repos are instance-level, the
+admin implicitly owns them, and ACLs grant everyone else. That is the core
+simplification the flat namespace buys.
+
+## Implementation seams
+
+Two non-obvious constraints the flat scheme creates:
+
+- **The browse routes share the mux with the git smart-HTTP catch-all.** Today the
+ UI is quarantined (browse under `/browse/…`, root as `GET /{$}`); the git handler
+ owns the `/` catch-all ([request-flows.md](request-flows.md)). Moving browse into
+ `/{repo}/…` puts it beside git's `info/refs`, `git-upload-pack`,
+ `git-receive-pack`, `HEAD`, and `objects/`. The rule that keeps clone/push
+ working: **register only enumerated verb routes** (`GET /{repo}`,
+ `GET /{repo}/tree/…`, `/{repo}/blob/…`, …) — **never** a broad
+ `/{repo}/{rest...}`, which would swallow the git endpoints. Go's ServeMux
+ (most-specific-wins) then routes verbs to the UI and everything else on
+ `/{repo}/…` to the git catch-all; the one-segment `GET /{repo}` landing is
+ distinct from `GET /{repo}/info/refs`.
+- **The default-branch force-push guard lives in the pre-receive hook.** It must
+ reject *per ref* so the client sees a clean "force-push to `main` requires admin"
+ — not a post-hoc failure. That point needs three inputs threaded to it: the
+ pusher's **ACL level** (admin vs write — the write path otherwise only
+ distinguishes ≥`write`), the repo's `default_branch`, and per-ref
+ **non-fast-forward / deletion** detection (old→new ancestry; a delete is a
+ new-SHA of all-zeros).
+
+## Why it looks like this
+
+- **No `/-/` separator.** GitLab needs `/-/` because it has arbitrarily nested
+ groups (`group/sub/…/project`), so the project↔verb boundary is ambiguous.
+ GitHub, with a fixed `owner/repo`, needs no marker. gitmote's flat
+ single-segment repo is even simpler — the verb is always segment 2 — so a
+ marker would buy nothing. Dropped everywhere.
+- **A reserved-name *list* is a multi-tenant problem.** It bites GitHub because
+ users pick names it doesn't control (hence its reserved-username list). On a
+ personal instance the owner names every repo, so a small fixed reserved set
+ (the globals above) is a non-issue — you simply never name a repo `login`.
+- **The `.`/`-` rules are escape hatches, not features.** Reserving names that
+ start with `.` and the bare `-` costs the owner nothing but guarantees room for
+ the unforeseen: a future well-known route lives under `.well-known/`, and a
+ future system namespace can reuse `-/…` — neither forcing a breaking rename of
+ an existing repo. That's why the word-list only needs names we want *pretty and
+ top-level*; everything else can hide behind a hatch. Reserving ahead is cheap
+ insurance; un-reserving after a repo claims a name is not.
+- **Greedy refs are safe.** The scary ambiguity (is `a/b` the ref `a` or `a/b`?)
+ mostly can't occur: git's ref store forbids a branch `a` and a branch `a/b`
+ from coexisting (a name can't be both a file and a directory under
+ `refs/heads`). The only residual case is a branch and a *tag* sharing a prefix,
+ resolved by a fixed precedence (branch wins). Pretty URLs, no `%2F` encoding
+ and its proxy/`net/http` footguns.
+- **Unify + tolerant `blob`.** One content verb means a relative link resolves
+ correctly whether the target is a file or a directory, without the link author
+ knowing which; `blob`/`tree` tolerance means an imprecise link still lands
+ somewhere valid instead of 404ing.
+- **Ref in the path, not the query.** A query-param ref (`?ref=master`) is dropped
+ by relative links — the concrete cause of today's "empty tree" on an in-repo
+ markdown link. In the path, it survives relative resolution and is shareable.
diff --git a/tasks/README.md b/tasks/README.md
index e78a585..0be8263 100644
--- a/tasks/README.md
+++ b/tasks/README.md
@@ -10,11 +10,26 @@ first-run auto-bootstrap (shrink the surface), public GHCR images with
CI-substrate docs (the cap). `docker run ghcr.io/atmin/gitmote` with a bucket and
credentials is now a working forge; kill/restart restores from S3.
-## Later
+## URLs, urls, urls
-- **URL redesign** — unify `/-/tree/` vs `/-/blob/` (or rewrite relative links in
- rendered markdown) so in-repo markdown links resolve correctly and carry the
- ref. (A relative link on a tree page currently resolves to `/-/tree/<file>` →
- "empty tree".)
+Make a personal instance's URLs simple and correct: a **flat single-segment repo
+namespace** (`host/<repo>`, no `owner/`, no `/-/`), content addressed in-path
+(`tree`/`blob`/`raw` + greedy ref, no `?ref=` query param), public/private
+visibility with spectators & collaborators, and rendered-markdown links that
+actually resolve. Greenfield — **reset the bucket + meta**, no migration. Design +
+rationale: [../docs/architecture/urls.md](../docs/architecture/urls.md). Order
+within the chain matters:
+
+- [urls-namespace-access.md](urls-namespace-access.md) — flat single-segment repos
+ + `visibility` (public → anonymous read; write always needs an ACL) + repo-read
+ browse authz + **admin-only force-push of the default branch**.
+- [urls-content-routing.md](urls-content-routing.md) — `tree`/`blob`/`raw` verbs,
+ ref-in-path greedy resolution, default-branch landing. *(needs -namespace.)*
+- [urls-markdown-links.md](urls-markdown-links.md) — rewrite rendered-markdown
+ relative links (nav → `blob`/`tree`) and embeds (→ `raw`), ref preserved.
+ *(needs -content-routing.)*
+- [urls-dashboard-ui.md](urls-dashboard-ui.md) — `/` dashboard, top-level globals
+ (`/login`, `/users`, …), per-repo `settings`/`access`/`secrets`. *(last —
+ reshapes the UI onto the new routes.)*
Speculative directions live in [../docs/evolution/](../docs/evolution/).
diff --git a/tasks/urls-content-routing.md b/tasks/urls-content-routing.md
new file mode 100644
index 0000000..acb3234
--- /dev/null
+++ b/tasks/urls-content-routing.md
@@ -0,0 +1,64 @@
+# Content routing — tree/blob/raw + ref-in-path
+
+Part of the **URLs** epic ([README](README.md)); implements the content-addressing
+section of [../docs/architecture/urls.md](../docs/architecture/urls.md). Needs the
+flat namespace ([urls-namespace-access.md](urls-namespace-access.md)).
+
+## Spec
+
+- Content verbs under `/<repo>/`: **`tree`** (unified — dir → listing, file →
+ view), **`blob`** (explicit file; a dir **301s to `tree`**), **`raw`** (bytes).
+- The **ref is in the path**, resolved **greedily**: the longest leading
+ path-prefix that names a real branch/tag. Cross-type prefix collision (a branch
+ and a tag) resolves branch-first. An omitted ref (the `/<repo>` landing) means
+ the default branch.
+- Also `commits`/`commit`/`refs`/`runs` under `/<repo>/`; **no `?ref=` query
+ param**, **no `/-/` marker**.
+
+## Current
+
+Browse is `/browse/<repo>/-/<action>/<arg>` with the **ref as a query parameter**
+([`browse.go`](../internal/webui/browse.go)): the path is split on `/-/` to find
+where the (slashed) repo name ends, `action` is `tree|blob|raw|commits|commit|
+runs|run`, and `resolve` reads `ref` from `r.URL.Query()` else the default branch.
+`tree` and `blob` are separate renderers; a relative link loses `?ref=` and can
+hit the wrong verb → "empty tree". This is the concrete breakage the epic fixes.
+
+## Change
+
+- **Routes** ([`webui.go`](../internal/webui/webui.go)): register
+ `GET /{repo}/tree/{rest...}`, `.../blob/...`, `.../raw/...`, `.../commits/...`,
+ `.../commit/{sha}`, `.../refs`, `.../runs`, `.../runs/{id}`, and the bare
+ `GET /{repo}` landing. Drop the `/browse/` prefix and the `/-/` split (repo is
+ one segment now). **Mux seam:** these now share the mux with the git smart-HTTP
+ catch-all — register **only these enumerated verbs**, never a broad
+ `/{repo}/{rest...}`, so git's `info/refs` / `*-pack` fall through to the git
+ handler (see [../docs/architecture/urls.md](../docs/architecture/urls.md) →
+ Implementation seams).
+- **Ref-in-path** ([`browse.go`](../internal/webui/browse.go) `resolve`): replace
+ the `?ref` read with greedy resolution against the repo's ref list —
+ `meta.ListRefs` ([`refs.go`](../internal/meta/refs.go), refs are source of
+ truth) for branches, plus tags. Longest-prefix match; branch-over-tag on a tie;
+ default branch when the tail has no ref (landing). A path that resolves to no
+ ref → 404.
+- **tree unified**: one handler renders a listing for a tree entry and the file
+ view for a blob, chosen by the tree lookup `resolve` already does.
+- **blob explicit**: render the file; if the entry is a tree, **301 to the `tree`
+ URL** (same ref+path).
+- **raw**: bytes as today, file-only (dir → 404).
+- **Link generation** in the browse templates emits `blob` for files and `tree`
+ for directories (it has the tree entry's type), with both verbs tolerant per
+ above. (Markdown-body links are handled in
+ [urls-markdown-links.md](urls-markdown-links.md).)
+
+## Verify
+
+- `/<repo>` → landing at default branch; `/<repo>/tree/main/docs` → listing;
+ `/<repo>/tree/main/docs/readme.md` → file view.
+- `/<repo>/blob/main/readme.md` → file view; `/<repo>/blob/main/docs` → **301** to
+ `/<repo>/tree/main/docs`; `/<repo>/raw/main/x` → bytes; `/raw` on a dir → 404.
+- **Slashed branch** `feature/x`: `/<repo>/tree/feature/x/README.md` resolves ref
+ `feature/x`, path `README.md`.
+- Unknown ref or path → 404 (not a 500, not an "empty tree").
+- `gofmt`/`golangci-lint`/`go test ./...` clean; golden **and** failure paths
+ (bad ref, blob-on-dir redirect, greedy tie-break).
diff --git a/tasks/urls-dashboard-ui.md b/tasks/urls-dashboard-ui.md
new file mode 100644
index 0000000..38cf0b8
--- /dev/null
+++ b/tasks/urls-dashboard-ui.md
@@ -0,0 +1,65 @@
+# Dashboard + UI reshape
+
+Part of the **URLs** epic ([README](README.md)); implements the top-level globals
+and per-repo management surface of
+[../docs/architecture/urls.md](../docs/architecture/urls.md). Last in the chain —
+it reshapes the UI onto the routes the earlier tasks established.
+
+## Spec
+
+- **`/` is the dashboard / repo list**, scoped to the viewer: public repos to
+ anyone, private repos to those with access, management affordances for admin.
+- **Global routes are top-level** (no `/ui/` prefix, no `/-/`): `/login`,
+ `/logout`, `/users`, `/tokens`, `/static/…`. `/healthz`, `/version` stay.
+- **Per-repo management** pages: `/<repo>/settings` (default branch, visibility
+ toggle, rename, delete), `/<repo>/access` (list ACLs; invite a **spectator**
+ = `read` or a **collaborator** = `write`; revoke), `/<repo>/secrets`.
+
+## Current
+
+Management lives under `/ui/*` behind `requireAdmin`, `/login` is top-level, and
+`GET /{$}` redirects to the repo list ([`webui.go`](../internal/webui/webui.go)).
+There is no per-repo settings/access page (ACLs are edited on a global
+`/ui/acls`), no visibility control, and no viewer-scoped dashboard — everything
+UI is admin-only. Static assets are at `/ui/static/…` and are `alwaysServable`
+past the leader gate ([`main.go`](../cmd/gitmote/main.go) `alwaysServable`).
+
+## Change
+
+- **Routes** ([`webui.go`](../internal/webui/webui.go) `Register`): move `/ui/repos`,
+ `/ui/users`, `/ui/tokens` → `/`, `/users`, `/tokens`; `/ui/static/` → `/static/`;
+ keep `/login`,`/logout`. Fold the global `/ui/acls` into per-repo
+ `/<repo>/access`; add `/<repo>/settings`, `/<repo>/secrets`.
+- **Dashboard** `GET /`: list repos filtered by the viewer (public + those the
+ viewer has an ACL on; all for admin), replacing the redirect.
+ [`meta.ListRepos`](../internal/meta/repos.go) exists but returns *all* repos —
+ add the viewer/visibility filter (join `visibility` + the viewer's ACLs) rather
+ than filtering in the handler.
+- **Access page** `/<repo>/access`: view ACLs and add/revoke `read`/`write`/`admin`,
+ worded as spectator/collaborator; **settings** exposes the `visibility` toggle
+ and default branch (feeds the force-push guard from
+ [urls-namespace-access.md](urls-namespace-access.md)).
+- **Authz**: management routes stay admin; the dashboard and per-repo read pages
+ use repo-read (public → anyone, private → ACL), consistent with the access task.
+- **Leader gate** ([`main.go`](../cmd/gitmote/main.go) `alwaysServable`): update the
+ static prefix to `/static/`.
+- **Templates/nav** ([`internal/webui/templates`](../internal/webui/templates)):
+ point every generated link at the new scheme; a signed-out visitor sees public
+ repos and a login link, not a 403 wall.
+- **Docs**: refresh the Management-UI / Quickstart sections of
+ [README.md](../README.md) and [ops.md](../docs/ops.md) to the new URLs.
+
+## Verify
+
+- `/` lists exactly the repos the viewer may see (anonymous → public only; ACL
+ user → their repos + public; admin → all).
+- `/login`, `/logout`, `/users`, `/tokens`, `/static/…` resolve; old `/ui/*` is
+ gone.
+- `/<repo>/settings` toggles `private`↔`public` and it takes effect (an anonymous
+ clone/browse starts/stops working accordingly).
+- `/<repo>/access` invites a spectator (read) and a collaborator (write) and
+ revokes them; the granted user's access changes accordingly.
+- A non-admin cannot reach `settings`/`users`; a signed-out visitor can browse a
+ public repo and reach `/login`.
+- `gofmt`/`golangci-lint`/`go test ./...` clean; golden **and** failure paths
+ (visibility scoping of the dashboard, non-admin management refusal).
diff --git a/tasks/urls-markdown-links.md b/tasks/urls-markdown-links.md
new file mode 100644
index 0000000..330a8e4
--- /dev/null
+++ b/tasks/urls-markdown-links.md
@@ -0,0 +1,56 @@
+# Markdown link & embed rewriting
+
+Part of the **URLs** epic ([README](README.md)); implements the "relative links in
+rendered content" section of
+[../docs/architecture/urls.md](../docs/architecture/urls.md). Needs content
+routing ([urls-content-routing.md](urls-content-routing.md)) — it rewrites links
+*into* those verbs.
+
+## Spec
+
+Rendered markdown resolves in-repo relative links correctly, preserving the ref:
+
+- **Embedded assets** (`![](img.png)`, other embeds) → `/<repo>/raw/<ref>/<path>`
+ (bytes, so the image actually renders).
+- **Navigation links** (`[x](./doc.md)`, `[y](../sub/)`) → the precise verb:
+ `blob` for a file, `tree` for a directory (decided by a tree lookup), with the
+ current ref baked in.
+- **Untouched:** absolute URLs, external `http(s)`/`mailto:`, and in-page anchors
+ (`#section`).
+
+## Current
+
+The markdown renderer ([`internal/render`](../internal/render)) emits links
+verbatim. A relative link therefore resolves against the *browser's* current URL,
+which (pre-epic) carries `?ref=` in the query — dropped on a relative navigation —
+and can't distinguish tree from blob, so an in-repo link lands on "empty tree".
+Even after content routing puts the ref in the path, embedded images still need
+the `raw` verb, which a bare relative link can't express — so rewriting is
+required regardless.
+
+## Change
+
+- Give the render/browse seam the context to rewrite: the repo name, the resolved
+ ref, the current file's directory, and a **tree-stat** callback (is `<path>` a
+ blob, a tree, or absent?) — the materialized repo already backs this
+ ([`internal/repo`](../internal/repo)).
+- In the markdown pipeline ([`internal/render`](../internal/render), consumed by
+ [`browse.go`](../internal/webui/browse.go)), post-process the rendered tree: for
+ each relative link/image, normalize it against the current dir, then map to
+ `/<repo>/{raw|blob|tree}/<ref>/<path>` — `raw` for image/embed nodes, else
+ `blob`/`tree` by the stat. Leave absolute/external/anchor targets alone.
+- A relative link to a **missing** path is left as-is (or rendered inert) rather
+ than fabricating a wrong-verb URL — a dangling link stays visibly dangling.
+
+## Verify
+
+- A link to a sibling `*.md` → `/<repo>/blob/<ref>/…` with the ref preserved, and
+ following it renders the target (the original "empty tree" bug is gone).
+- A link to a subdirectory → `/<repo>/tree/<ref>/…`; a `../` up-link resolves to
+ the parent dir.
+- `![](diagram.png)` → `/<repo>/raw/<ref>/…` and the image renders.
+- External `https://…` links, `mailto:`, and `#anchor` are unchanged.
+- A link to a nonexistent path does not 500 and is not rewritten into a false
+ blob/tree URL.
+- `gofmt`/`golangci-lint`/`go test ./...` clean; golden **and** failure paths
+ (missing target, external-link passthrough, image→raw).
diff --git a/tasks/urls-namespace-access.md b/tasks/urls-namespace-access.md
new file mode 100644
index 0000000..b4bc6a2
--- /dev/null
+++ b/tasks/urls-namespace-access.md
@@ -0,0 +1,102 @@
+# Flat repo namespace + visibility & access
+
+Part of the **URLs** epic ([README](README.md)); implements the namespace and
+access sections of [../docs/architecture/urls.md](../docs/architecture/urls.md).
+First in the chain — everything else routes off single-segment repos.
+
+## Spec
+
+- Repos are a **single path segment** (`gitmote`, not `atmin/gitmote`). Reachable
+ at `/<repo>` for git. Reserved names (enforced at create): the top-level globals
+ `login`, `logout`, `users`, `tokens`, `settings`, `new`, `search`, `api`,
+ `metrics`, `internal`, `static`, `healthz`, `version`; plus two structural
+ rules — a name may not start with `.` or be the bare `-` (nor be empty). A
+ trailing `.git` is stripped. (`internal` is already the CI report-API route;
+ the rest are reserved ahead of need — the `.`/`-` rules keep future system
+ routes possible without a breaking rename.)
+- **Visibility** per repo: `private` (default) or `public`. A public repo is
+ readable with **no token** (clone/fetch/browse); writes and management are
+ **never** anonymous.
+- The ACL perms already exist — **`read` = spectator, `write` = collaborator,
+ `admin`**. Reading is gated by **repo-read** (public → anyone, private → a
+ `read` ACL), not by global admin.
+- **Force-pushing the default branch is admin-only.** A `write` collaborator may
+ fast-forward any branch and force-push/delete *non-default* branches, but a
+ **non-fast-forward or deletion of the default branch requires `admin`**.
+- Greenfield: no users yet, so **reset** rather than migrate.
+
+## Current
+
+`repos.name` is `owner/repo`, `UNIQUE` with an embedded slash
+([`schema.sql`](../internal/meta/schema.sql), [`repos.go`](../internal/meta/repos.go)).
+The git handler and writer route on `/<owner>/<repo>/…`
+([`internal/githttp`](../internal/githttp), [`cmd/gitmote/main.go`](../cmd/gitmote/main.go)).
+[`auth.Guard.Authorize`](../internal/auth/guard.go) requires a valid token + a
+matching ACL for every request; there is no anonymous path and no `visibility`
+column. Browse is **admin-only** ([`webui.go`](../internal/webui/webui.go),
+`h.requireAdmin(h.browse)`). Bootstrap and the dev/e2e scripts use `atmin/gitmote`.
+
+## Change
+
+- **Reset (greenfield).** Wipe the bucket + meta and re-bootstrap a single repo
+ `gitmote`; no migration path is written. Locally that's `make dev-reset`; in prod
+ it's dropping the bucket's `objects/` and `meta` prefixes, then re-bootstrapping
+ ([ops.md](../docs/ops.md)). Because the DB is fresh, the schema change is
+ `schema.sql` only — no guarded `migrate()` entry needed.
+- **Schema** ([`schema.sql`](../internal/meta/schema.sql)): `repos.name` stays
+ `UNIQUE` but now holds a single segment; add
+ `visibility TEXT NOT NULL DEFAULT 'private' CHECK (visibility IN ('private','public'))`.
+- **Repo-name validation** in `CreateRepo` ([`repos.go`](../internal/meta/repos.go)):
+ reject a `/`, any reserved global (see Spec), a leading `.`, the bare `-`, and
+ empty; strip a trailing `.git`. Keep the reserved set in one place (a package
+ var) so routes and validation can't drift. Add `GetRepo`-side `.git` tolerance
+ so `clone host/<repo>.git` resolves.
+- **Routing**: parse `/<repo>/…` (drop the owner segment) in
+ [`internal/githttp`](../internal/githttp) and the mux wiring in
+ [`main.go`](../cmd/gitmote/main.go). The git handler keeps the `/` catch-all;
+ the UI verb routes ([urls-content-routing.md](urls-content-routing.md)) share
+ that space, so **only enumerated verbs** may be registered — never a broad
+ `/{repo}/{rest...}` — or git's `info/refs` / `*-pack` endpoints get swallowed
+ (see the mux seam in [../docs/architecture/urls.md](../docs/architecture/urls.md)).
+- **Authz** ([`guard.go`](../internal/auth/guard.go)): when `perm == read` and the
+ repo is `public`, allow with no token; otherwise the existing token+ACL path.
+ `write`/`admin` are unchanged (never anonymous). Add a `visibility`-aware repo
+ read the UI and git-read paths share.
+- **Default-branch force-push guard** in the **pre-receive hook** path (per-ref, so
+ the client gets a clean rejection — not a post-hoc CAS failure;
+ [`internal/githttp`](../internal/githttp), [`internal/hookrpc`](../internal/hookrpc)).
+ A non-fast-forward or deletion of `repos.default_branch` is rejected unless the
+ pusher holds `admin`; fast-forwards and all pushes to other branches need only
+ `write`. Three inputs must reach that point: the pusher's **ACL level** (admin vs
+ write — `classify` today only gates ≥`write`, so admin must be resolved
+ separately, e.g. `GetACL`/`is_admin`), the repo's `default_branch`, and per-ref
+ **non-ff / delete** detection (old→new ancestry; delete = all-zeros new SHA). The
+ rejection is a real refusal, not a silent drop.
+- **Browse authz** ([`webui.go`](../internal/webui/webui.go)): gate browse/read on
+ repo-read (public → anyone, private → `read` ACL), replacing `requireAdmin` on
+ the read routes; management routes stay admin.
+- **Bootstrap** ([`internal/bootstrap`](../internal/bootstrap),
+ [`main.go`](../cmd/gitmote/main.go)): default/require a single-segment repo name;
+ update the dev/e2e scripts and README/ops examples to `gitmote`.
+- **Docs**: reconcile [storage.md](../docs/architecture/storage.md) (repos.name
+ example), [auth.md](../docs/architecture/auth.md) (visibility + anonymous read),
+ and drop the `owner/repo` phrasing from [urls.md](../docs/architecture/urls.md)'s
+ Status once landed.
+
+## Verify
+
+- `git clone/push http://<host>/gitmote` works (no owner segment).
+- **Public repo:** anonymous `clone`/`fetch` and browse succeed; anonymous push is
+ rejected; a `write`-ACL user can push.
+- **Private repo:** anonymous read is refused; a `read`-ACL user (spectator) can
+ clone/browse but not push; a `write`-ACL user (collaborator) can push.
+- **Force-push guard:** a `write` collaborator's fast-forward to the default
+ branch succeeds; their **non-fast-forward** to it is **refused**; the same
+ force-push to a non-default branch succeeds; an `admin` can force-push the
+ default branch. (Golden + failure both tested — a force that isn't refused is a
+ bug, per CONTRIBUTING.)
+- Creating a repo named `login`/`api`/`internal` (any reserved global), one
+ starting with `.`, the bare `-`, containing `/`, or empty is rejected with a
+ clear error; `gitmote.git` normalizes to `gitmote`.
+- `gofmt`/`golangci-lint`/`go test ./...` clean; golden **and** failure paths
+ (anonymous-write refusal, private-read refusal, reserved-name rejection).