vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Serve repositories from the root of the URL space
ed03b3d1fa16ef6b55e2773aaf10e3be13282c3a · Keenan Nemetz · 9h ago · parent d37d301f
Serve repositories from the root of the URL space A repository is now /<name> and its Git transport /<name>.git, so the URL a person opens, the URL a clone uses, and the Go module path are the same name. The forge's own pages moved under /-/ (admin, accounts, the directory, the feed, sign-in, assets), a prefix no repository name can start with: names are [a-z0-9...] and cannot begin with "-". That leaves the router with two disjoint spaces and removes the /r/ and /git/ prefixes, which existed only to keep repositories away from the forge's own paths. Routes: repoRoutes lose /r/; global, auth and admin routes gain /-/; assets move to /-/static/ because a subtree pattern like /static/ cannot coexist with one-segment-wildcard repo routes — Go's mux reports them as conflicting neither-more-specific patterns. The catch-all still renders the styled 404. Transport: githttp.Route parses /<name>.git/<rest> instead of a /git/ prefix, and forgepaths.go hands such requests to the proxy before the mux, because a wildcard fills a whole segment and cannot match "<name>.git". LFS stays on the mux (<name>.git/info/lfs/...), and the Go module discovery probe moves to the same pre-router so a package path under a public repository is answered even when its first segments name a page tab. Names: repo.ValidateName now refuses the four root paths the forge must keep — static, robots.txt, sitemap.xml, sitemap-pages.xml. Everything else is an ordinary repository name, including admin, u, and activity. Docs and the packaged URLs follow the new layout; robots.txt, the sitemaps and the instance activity feed point at the new paths. No aliases: the old /r/ and /git/ URLs are gone. Verified with ./vole workflow check and ./vole workflow run verify.
server/home_test.go +16−16
| @@ -30,8 +30,8 @@ func TestHomeIsALandingPage(t *testing.T) { | |||
| 30 | 30 | for _, want := range []string{ | |
| 31 | 31 | `class="hero"`, "<h1>Acme Forge</h1>", | |
| 32 | 32 | `readme-card`, `class="md readme-body"`, "What this forge is for.", | |
| 33 | "Repositories", `class="repo-card"`, `href="/r/demo"`, | ||
| 34 | "Latest", `href="/activity"`, | ||
| 33 | "Repositories", `class="repo-card"`, `href="/demo"`, | ||
| 34 | "Latest", `href="/-/activity"`, | ||
| 35 | 35 | } { | |
| 36 | 36 | if !strings.Contains(body, want) { | |
| 37 | 37 | t.Fatalf("home is missing %q:\n%s", want, body) | |
| @@ -80,7 +80,7 @@ func TestHomePutsNewRepositoryInTheHero(t *testing.T) { | |||
| 80 | 80 | _, body := f.browserGet(t, "/", cookie) | |
| 81 | 81 | ||
| 82 | 82 | hero := strings.Index(body, `class="hero"`) | |
| 83 | button := strings.Index(body, `href="/repos/new"`) | ||
| 83 | button := strings.Index(body, `href="/-/repos/new"`) | ||
| 84 | 84 | grid := strings.Index(body, `class="home-grid"`) | |
| 85 | 85 | if hero < 0 || button < 0 || grid < 0 || hero > button || button > grid { | |
| 86 | 86 | t.Fatalf("the New repository action is not in the page header (hero=%d button=%d grid=%d):\n%s", hero, button, grid, body) | |
| @@ -94,14 +94,14 @@ func TestHomeHasTheCleanTopBar(t *testing.T) { | |||
| 94 | 94 | ||
| 95 | 95 | _, body := f.get("/") | |
| 96 | 96 | for _, want := range []string{ | |
| 97 | `class="topbar"`, `class="topnav"`, `href="/activity"`, | ||
| 98 | `class="topsearch"`, `class="topsearch-box"`, `href="/login"`, | ||
| 97 | `class="topbar"`, `class="topnav"`, `href="/-/activity"`, | ||
| 98 | `class="topsearch"`, `class="topsearch-box"`, `href="/-/login"`, | ||
| 99 | 99 | } { | |
| 100 | 100 | if !strings.Contains(body, want) { | |
| 101 | 101 | t.Fatalf("home is missing the top-bar marker %q:\n%s", want, body) | |
| 102 | 102 | } | |
| 103 | 103 | } | |
| 104 | for _, unwanted := range []string{`class="rail"`, `src="/static/nav.js"`, `>Projects</a>`, `>Repositories</a>`, `href="/runners"`, `href="/admin"`} { | ||
| 104 | for _, unwanted := range []string{`class="rail"`, `src="/-/static/nav.js"`, `>Projects</a>`, `>Repositories</a>`, `href="/-/runners"`, `href="/-/admin"`} { | ||
| 105 | 105 | if strings.Contains(body, unwanted) { | |
| 106 | 106 | t.Fatalf("home still has %q:\n%s", unwanted, body) | |
| 107 | 107 | } | |
| @@ -109,12 +109,12 @@ func TestHomeHasTheCleanTopBar(t *testing.T) { | |||
| 109 | 109 | ||
| 110 | 110 | _, cookie := f.browserSession(t, f.ownerID) | |
| 111 | 111 | _, body = f.browserGet(t, "/", cookie) | |
| 112 | for _, want := range []string{`class="account-menu"`, `class="account-pop"`, `href="/account"`, `action="/logout"`} { | ||
| 112 | for _, want := range []string{`class="account-menu"`, `class="account-pop"`, `href="/-/account"`, `action="/-/logout"`} { | ||
| 113 | 113 | if !strings.Contains(body, want) { | |
| 114 | 114 | t.Fatalf("signed-in top bar is missing %q:\n%s", want, body) | |
| 115 | 115 | } | |
| 116 | 116 | } | |
| 117 | if strings.Contains(body, `href="/admin"`) { | ||
| 117 | if strings.Contains(body, `href="/-/admin"`) { | ||
| 118 | 118 | t.Fatalf("non-admin sees the Status link:\n%s", body) | |
| 119 | 119 | } | |
| 120 | 120 | ||
| @@ -122,7 +122,7 @@ func TestHomeHasTheCleanTopBar(t *testing.T) { | |||
| 122 | 122 | t.Fatal(err) | |
| 123 | 123 | } | |
| 124 | 124 | _, body = f.browserGet(t, "/", cookie) | |
| 125 | if !strings.Contains(body, `href="/admin"`) { | ||
| 125 | if !strings.Contains(body, `href="/-/admin"`) { | ||
| 126 | 126 | t.Fatalf("instance admin does not see the Status link:\n%s", body) | |
| 127 | 127 | } | |
| 128 | 128 | } | |
| @@ -133,7 +133,7 @@ func TestHomeSearchShowsProjectCards(t *testing.T) { | |||
| 133 | 133 | f.publish(f.repo) | |
| 134 | 134 | ||
| 135 | 135 | _, body := f.get("/?q=demo") | |
| 136 | for _, want := range []string{"Results", `class="repo-card"`, `href="/r/demo"`, "clear search"} { | ||
| 136 | for _, want := range []string{"Results", `class="repo-card"`, `href="/demo"`, "clear search"} { | ||
| 137 | 137 | if !strings.Contains(body, want) { | |
| 138 | 138 | t.Fatalf("search results are missing %q:\n%s", want, body) | |
| 139 | 139 | } | |
| @@ -194,26 +194,26 @@ func TestInstanceActivityListsOnlyVisibleRepositories(t *testing.T) { | |||
| 194 | 194 | t.Fatalf("private issue: %v\n%s", err, errs) | |
| 195 | 195 | } | |
| 196 | 196 | ||
| 197 | code, body := f.get("/activity") | ||
| 197 | code, body := f.get("/-/activity") | ||
| 198 | 198 | if code != 200 { | |
| 199 | 199 | t.Fatalf("activity = %d", code) | |
| 200 | 200 | } | |
| 201 | for _, want := range []string{`href="/r/demo"`, "public-issue-marker", "v1.0.0", "released"} { | ||
| 201 | for _, want := range []string{`href="/demo"`, "public-issue-marker", "v1.0.0", "released"} { | ||
| 202 | 202 | if !strings.Contains(body, want) { | |
| 203 | 203 | t.Fatalf("activity feed is missing %q:\n%s", want, body) | |
| 204 | 204 | } | |
| 205 | 205 | } | |
| 206 | for _, unwanted := range []string{"private-issue-marker", `href="/r/secret"`, "public-commit", "private-commit", "committed"} { | ||
| 206 | for _, unwanted := range []string{"private-issue-marker", `href="/secret"`, "public-commit", "private-commit", "committed"} { | ||
| 207 | 207 | if strings.Contains(body, unwanted) { | |
| 208 | 208 | t.Fatalf("activity feed contains %q:\n%s", unwanted, body) | |
| 209 | 209 | } | |
| 210 | 210 | } | |
| 211 | 211 | ||
| 212 | _, body = f.get("/activity?kind=issues") | ||
| 212 | _, body = f.get("/-/activity?kind=issues") | ||
| 213 | 213 | if !strings.Contains(body, "public-issue-marker") || strings.Contains(body, "v1.0.0") { | |
| 214 | 214 | t.Fatalf("issue filter is wrong:\n%s", body) | |
| 215 | 215 | } | |
| 216 | _, body = f.get("/activity?kind=releases") | ||
| 216 | _, body = f.get("/-/activity?kind=releases") | ||
| 217 | 217 | if !strings.Contains(body, "v1.0.0") || strings.Contains(body, "public-issue-marker") { | |
| 218 | 218 | t.Fatalf("release filter is wrong:\n%s", body) | |
| 219 | 219 | } | |
| @@ -244,7 +244,7 @@ func TestFirstRunOnboardingIsAnInstanceAdminPage(t *testing.T) { | |||
| 244 | 244 | if resp.StatusCode != 200 { | |
| 245 | 245 | t.Fatalf("home = %d", resp.StatusCode) | |
| 246 | 246 | } | |
| 247 | for _, want := range []string{"No repositories yet", "New repository", "Or from the instance directory", "--owner keenan", `href="/repos/new"`} { | ||
| 247 | for _, want := range []string{"No repositories yet", "New repository", "Or from the instance directory", "--owner keenan", `href="/-/repos/new"`} { | ||
| 248 | 248 | if !strings.Contains(body, want) { | |
| 249 | 249 | t.Fatalf("first-run page is missing %q:\n%s", want, body) | |
| 250 | 250 | } | |