Serve nested repositories over HTTP #67

Merged
a73x merged 1 commit from worktree-agent-ab8e151c29539ab74 into main 2026-08-11 16:08:31 +00:00
a73x commented 2026-08-11 16:06:24 +00:00 (Migrated from git.a73x.sh)

Wild repos (agents/<name>) worked over SSH and were unreachable over HTTP: repos::discover read one level of repos_dir, so the repo was unlisted, 404'd at every path, and its refs/collab/* could not be fetched by an HTTP clone at all.

Discovery now recurses and repositories are named by their full path under repos_dir.

Naming rule — the same key governance writes its rules against (governance::repo_key): path relative to repos_dir, /-separated, trailing .git stripped from the last segment unless a sibling with the stripped name is itself a repository. That separates tools.git from tools; stripping both collapsed them onto one name and left whichever discover reached second permanently unreachable. Both are now listed, browsable and clonable under distinct names.

Routing rule — the longest leading run of path segments that names a repository is the repository; the rest is the sub-route. Longest wins because the repository is the concrete fact (a directory that exists) while the sub-route vocabulary is ours, so every repository on disk is reachable at its own address. A repository named agents/issues would shadow the issue list of a repository named agents; that is an operator-chosen collision, and preferring the shorter name would instead leave the nested repository with no address at all. Implemented by folding the name into one percent-encoded segment in middleware wrapped around the router (Router::layer runs after routing), so every route, handler and template is untouched.

Depth — bounded at 8 segments. discover walks every non-repository directory and is reached from an anonymous page; without a bound, whatever an operator leaves under repos_dir sets the cost of rendering the listing. resolve applies the same bound, so a repository is never resolvable but unlisted. Real layouts are 2 deep.

Caching — none, deliberately. resolve no longer scans: a name determines a path, so it probes it (O(depth) stats), which removes the full-scan-per-request the old comment flagged and with it the motive for a cache. A wild repo is created by an SSH push and must be visible on the very next HTTP request — the exact workflow it exists for — and governance::load already made the same uncached-per-request call for the same reason. A test drives create-over-SSH then GET-over-HTTP.

Safety — resolve validates before probing (no empty/dot-prefixed/.. segments, depth capped), and the walk down refuses to pass through a symlink, so a name cannot address anything outside repos_dir. A symlinked repository at a leaf is still discovered, as before. Dot-prefixed segments are skipped everywhere, which is what keeps .server/ (the SSH host key) out; it is the only reserved name under repos_dir.

Governance — the settings-repo default now lives in apply_governance_default, applied wherever an entry is built rather than only in discover, because the anonymous surface reaches a repo via resolve. agents/settings is an ordinary public repository and settings is still closed by default; tested through both paths.

entry_for_path now takes repos_dir so SSH and HTTP agree on what a repository is called. SSH never used the name, so this is a tightening only: a repository under a dot-prefixed segment or past the depth limit is no longer served over SSH either.

TDD throughout: the e2e reproduction was written first and failed with exactly the issue's table (href="/alpha" only, /agents/claude-a 404).

Tests: 5 end-to-end against a live server (nested repo listed + 200 + git fetch 'refs/collab/*' over real HTTP exercising both GET info/refs and POST upload-pack; tools vs tools.git; .server; traversal; SSH-create then HTTP-visible), plus unit tests for the naming rule, non-descent into repositories, the depth bound, traversal rejection, and the routing fold.

Full suite green, cargo clippy --all-targets silent.


Migrated from git-collab patche 02671b2a4f45837acbdc89a952753529b5ca64ad.

Original author: a73x dev@a73x.sh.

Fixes #64

Revision 1: 15a970d19fa8e44648dd0aff18e43827aeebdbf7; base eb3de6b1500bb2e5e0d5fea06ff163287772f29a

Wild repos (`agents/<name>`) worked over SSH and were unreachable over HTTP: `repos::discover` read one level of `repos_dir`, so the repo was unlisted, 404'd at every path, and its `refs/collab/*` could not be fetched by an HTTP clone at all. Discovery now recurses and repositories are named by their full path under `repos_dir`. **Naming rule** — the same key governance writes its rules against (`governance::repo_key`): path relative to `repos_dir`, `/`-separated, trailing `.git` stripped from the last segment *unless* a sibling with the stripped name is itself a repository. That separates `tools.git` from `tools`; stripping both collapsed them onto one name and left whichever `discover` reached second permanently unreachable. Both are now listed, browsable and clonable under distinct names. **Routing rule** — the longest leading run of path segments that names a repository is the repository; the rest is the sub-route. Longest wins because the repository is the concrete fact (a directory that exists) while the sub-route vocabulary is ours, so every repository on disk is reachable at its own address. A repository named `agents/issues` would shadow the issue list of a repository named `agents`; that is an operator-chosen collision, and preferring the shorter name would instead leave the nested repository with no address at all. Implemented by folding the name into one percent-encoded segment in middleware wrapped *around* the router (`Router::layer` runs after routing), so every route, handler and template is untouched. **Depth** — bounded at 8 segments. `discover` walks every non-repository directory and is reached from an anonymous page; without a bound, whatever an operator leaves under `repos_dir` sets the cost of rendering the listing. `resolve` applies the same bound, so a repository is never resolvable but unlisted. Real layouts are 2 deep. **Caching** — none, deliberately. `resolve` no longer scans: a name determines a path, so it probes it (O(depth) stats), which removes the full-scan-per-request the old comment flagged and with it the motive for a cache. A wild repo is created by an SSH push and must be visible on the very next HTTP request — the exact workflow it exists for — and `governance::load` already made the same uncached-per-request call for the same reason. A test drives create-over-SSH then GET-over-HTTP. **Safety** — `resolve` validates before probing (no empty/dot-prefixed/`..` segments, depth capped), and the walk down refuses to pass *through* a symlink, so a name cannot address anything outside `repos_dir`. A symlinked repository at a leaf is still discovered, as before. Dot-prefixed segments are skipped everywhere, which is what keeps `.server/` (the SSH host key) out; it is the only reserved name under `repos_dir`. **Governance** — the settings-repo default now lives in `apply_governance_default`, applied wherever an entry is built rather than only in `discover`, because the anonymous surface reaches a repo via `resolve`. `agents/settings` is an ordinary public repository and `settings` is still closed by default; tested through both paths. `entry_for_path` now takes `repos_dir` so SSH and HTTP agree on what a repository is called. SSH never used the name, so this is a tightening only: a repository under a dot-prefixed segment or past the depth limit is no longer served over SSH either. TDD throughout: the e2e reproduction was written first and failed with exactly the issue's table (`href="/alpha"` only, `/agents/claude-a` 404). Tests: 5 end-to-end against a live server (nested repo listed + 200 + `git fetch 'refs/collab/*'` over real HTTP exercising both GET info/refs and POST upload-pack; `tools` vs `tools.git`; `.server`; traversal; SSH-create then HTTP-visible), plus unit tests for the naming rule, non-descent into repositories, the depth bound, traversal rejection, and the routing fold. Full suite green, `cargo clippy --all-targets` silent. --- Migrated from git-collab patche `02671b2a4f45837acbdc89a952753529b5ca64ad`. Original author: a73x <dev@a73x.sh>. Fixes #64 Revision 1: `15a970d19fa8e44648dd0aff18e43827aeebdbf7`; base `eb3de6b1500bb2e5e0d5fea06ff163287772f29a`
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
a73x/git-collab!67
No description provided.