Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0: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 · 5h 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/web/profile_test.go +38−38

@@ -111,9 +111,9 @@ func (s *site) repo(t *testing.T, name, owner string, readableByAnyone bool, iss
111 111 func (s *site) serve(t *testing.T, as *identity.Account, path string) *httptest.ResponseRecorder {
112 112 t.Helper()
113 113 mux := http.NewServeMux()
114 mux.HandleFunc("GET /u", s.web.PeoplePage)
115 mux.HandleFunc("GET /u/{$}", s.web.PeoplePage)
116 mux.HandleFunc("GET /u/{name}", s.web.ProfilePage)
114 mux.HandleFunc("GET /-/u", s.web.PeoplePage)
115 mux.HandleFunc("GET /-/u/{$}", s.web.PeoplePage)
116 mux.HandleFunc("GET /-/u/{name}", s.web.ProfilePage)
117 117 r := httptest.NewRequest("GET", path, nil)
118 118 if as != nil {
119 119 r = r.WithContext(context.WithValue(r.Context(), principalKey{},
@@ -140,7 +140,7 @@ func TestProfileCountsOnlyWhatTheViewerCanRead(t *testing.T) {
140 140 t.Parallel()
141 141 s := newSite(t)
142 142
143 anon := s.serve(t, nil, "/u/alice")
143 anon := s.serve(t, nil, "/-/u/alice")
144 144 if anon.Code != http.StatusOK {
145 145 t.Fatalf("anonymous profile = %d: %s", anon.Code, anon.Body)
146 146 }
@@ -160,7 +160,7 @@ func TestProfileCountsOnlyWhatTheViewerCanRead(t *testing.T) {
160 160 t.Fatalf("the profile does not ask crawlers to leave it alone:\n%s", body)
161 161 }
162 162
163 signed := s.serve(t, s.account(t, "alice"), "/u/alice")
163 signed := s.serve(t, s.account(t, "alice"), "/-/u/alice")
164 164 if signed.Code != http.StatusOK {
165 165 t.Fatalf("signed-in profile = %d", signed.Code)
166 166 }
@@ -172,7 +172,7 @@ func TestProfileCountsOnlyWhatTheViewerCanRead(t *testing.T) {
172 172 func TestProfileOfAnUnknownNameIsNotFound(t *testing.T) {
173 173 t.Parallel()
174 174 s := newSite(t)
175 rw := s.serve(t, nil, "/u/nobody")
175 rw := s.serve(t, nil, "/-/u/nobody")
176 176 if rw.Code != http.StatusNotFound {
177 177 t.Fatalf("unknown name = %d, want 404", rw.Code)
178 178 }
@@ -189,7 +189,7 @@ func TestRetiredProfileStillOpens(t *testing.T) {
189 189 if _, err := s.ident.Retire("bob", "alice"); err != nil {
190 190 t.Fatal(err)
191 191 }
192 rw := s.serve(t, nil, "/u/bob")
192 rw := s.serve(t, nil, "/-/u/bob")
193 193 if rw.Code != http.StatusOK {
194 194 t.Fatalf("retired profile = %d", rw.Code)
195 195 }
@@ -201,12 +201,12 @@ func TestRetiredProfileStillOpens(t *testing.T) {
201 201 func TestProfileFilterLinksAndHeading(t *testing.T) {
202 202 t.Parallel()
203 203 s := newSite(t)
204 body := s.serve(t, nil, "/u/alice").Body.String()
204 body := s.serve(t, nil, "/-/u/alice").Body.String()
205 205 for _, want := range []string{
206 `href="/u/alice?type=issues"`,
207 `href="/u/alice?type=changes"`,
208 `href="/u/alice?type=reviews"`,
209 `href="/u/alice?type=comments"`,
206 `href="/-/u/alice?type=issues"`,
207 `href="/-/u/alice?type=changes"`,
208 `href="/-/u/alice?type=reviews"`,
209 `href="/-/u/alice?type=comments"`,
210 210 "Issues opened",
211 211 "Changes reviewed",
212 212 } {
@@ -216,8 +216,8 @@ func TestProfileFilterLinksAndHeading(t *testing.T) {
216 216 }
217 217 // The filtered page says which filter is on, so a bookmarked URL is not a
218 218 // mystery.
219 filtered := s.serve(t, nil, "/u/alice?type=reviews").Body.String()
220 if !strings.Contains(filtered, `class="chip active" href="/u/alice?type=reviews"`) {
219 filtered := s.serve(t, nil, "/-/u/alice?type=reviews").Body.String()
220 if !strings.Contains(filtered, `class="chip active" href="/-/u/alice?type=reviews"`) {
221 221 t.Fatalf("the reviews filter is not marked active:\n%s", filtered)
222 222 }
223 223 }
@@ -226,7 +226,7 @@ func TestPeopleListsEveryAccountAndFilters(t *testing.T) {
226 226 t.Parallel()
227 227 s := newSite(t)
228 228
229 all := s.serve(t, nil, "/u")
229 all := s.serve(t, nil, "/-/u")
230 230 if all.Code != http.StatusOK {
231 231 t.Fatalf("directory = %d: %s", all.Code, all.Body)
232 232 }
@@ -241,12 +241,12 @@ func TestPeopleListsEveryAccountAndFilters(t *testing.T) {
241 241 t.Fatal("the directory does not ask crawlers to leave it alone")
242 242 }
243 243
244 filtered := s.serve(t, nil, "/u?q=ali").Body.String()
244 filtered := s.serve(t, nil, "/-/u?q=ali").Body.String()
245 245 if !strings.Contains(filtered, "alice") || strings.Contains(filtered, "adam") {
246 246 t.Fatalf("the name filter returned the wrong people:\n%s", filtered)
247 247 }
248 248
249 empty := s.serve(t, nil, "/u?q=nobody").Body.String()
249 empty := s.serve(t, nil, "/-/u?q=nobody").Body.String()
250 250 if !strings.Contains(empty, "No one matches") {
251 251 t.Fatalf("an empty search has no empty state:\n%s", empty)
252 252 }
@@ -261,17 +261,17 @@ func TestRetiredAccountsAreMarkedAndFilterable(t *testing.T) {
261 261 t.Fatal(err)
262 262 }
263 263
264 everyone := s.serve(t, nil, "/u").Body.String()
265 if !strings.Contains(everyone, `href="/u/bob"`) || !strings.Contains(everyone, `class="state retired"`) {
264 everyone := s.serve(t, nil, "/-/u").Body.String()
265 if !strings.Contains(everyone, `href="/-/u/bob"`) || !strings.Contains(everyone, `class="state retired"`) {
266 266 t.Fatalf("the retired account is not marked in the table:\n%s", everyone)
267 267 }
268 268
269 active := s.serve(t, nil, "/u?status=active").Body.String()
270 if strings.Contains(active, `href="/u/bob"`) {
269 active := s.serve(t, nil, "/-/u?status=active").Body.String()
270 if strings.Contains(active, `href="/-/u/bob"`) {
271 271 t.Fatalf("the active filter kept a retired account:\n%s", active)
272 272 }
273 retired := s.serve(t, nil, "/u?status=retired").Body.String()
274 if !strings.Contains(retired, `href="/u/bob"`) || strings.Contains(retired, `href="/u/alice"`) {
273 retired := s.serve(t, nil, "/-/u?status=retired").Body.String()
274 if !strings.Contains(retired, `href="/-/u/bob"`) || strings.Contains(retired, `href="/-/u/alice"`) {
275 275 t.Fatalf("the retired filter returned the wrong accounts:\n%s", retired)
276 276 }
277 277 }
@@ -284,10 +284,10 @@ func TestDirectorySortsByItsHeaders(t *testing.T) {
284 284
285 285 // Default is the name, A to Z. alice opened the fixture's issues; adam and bob
286 286 // have none, so a count order is a different question.
287 body := s.serve(t, nil, "/u").Body.String()
288 adam := strings.Index(body, `href="/u/adam"`)
289 alice := strings.Index(body, `href="/u/alice"`)
290 bob := strings.Index(body, `href="/u/bob"`)
287 body := s.serve(t, nil, "/-/u").Body.String()
288 adam := strings.Index(body, `href="/-/u/adam"`)
289 alice := strings.Index(body, `href="/-/u/alice"`)
290 bob := strings.Index(body, `href="/-/u/bob"`)
291 291 if adam < 0 || alice < 0 || bob < 0 || !(adam < alice && alice < bob) {
292 292 t.Fatalf("the default order is not A to Z: adam=%d alice=%d bob=%d", adam, alice, bob)
293 293 }
@@ -299,8 +299,8 @@ func TestDirectorySortsByItsHeaders(t *testing.T) {
299 299 t.Fatalf("the Issues header does not ask for the largest first:\n%s", body)
300 300 }
301 301
302 ordered := s.serve(t, nil, "/u?sort=issues&dir=desc").Body.String()
303 if strings.Index(ordered, `href="/u/alice"`) > strings.Index(ordered, `href="/u/adam"`) {
302 ordered := s.serve(t, nil, "/-/u?sort=issues&dir=desc").Body.String()
303 if strings.Index(ordered, `href="/-/u/alice"`) > strings.Index(ordered, `href="/-/u/adam"`) {
304 304 t.Fatalf("the issues order does not put the only author first:\n%s", ordered)
305 305 }
306 306 if !strings.Contains(ordered, `aria-sort="descending"`) {
@@ -422,22 +422,22 @@ func TestParseProfileFilter(t *testing.T) {
422 422
423 423 func TestProfileAndPeopleURLsKeepTheFilter(t *testing.T) {
424 424 t.Parallel()
425 if got := profileURL("alice", "")(1); got != "/u/alice" {
425 if got := profileURL("alice", "")(1); got != "/-/u/alice" {
426 426 t.Fatalf("first page = %q", got)
427 427 }
428 428 // A filtered page keeps the filter the reader chose, in the page's spelling.
429 if got := profileURL("alice", "reviews")(2); got != "/u/alice?page=2&type=reviews" {
429 if got := profileURL("alice", "reviews")(2); got != "/-/u/alice?page=2&type=reviews" {
430 430 t.Fatalf("filtered page = %q", got)
431 431 }
432 432 // The default directory URL stays short.
433 if got := peopleURL("", "", peopleSort{key: sortName}, 1); got != "/u" {
433 if got := peopleURL("", "", peopleSort{key: sortName}, 1); got != "/-/u" {
434 434 t.Fatalf("directory first page = %q", got)
435 435 }
436 if got := peopleURL("ali", "", peopleSort{key: sortName}, 2); got != "/u?page=2&q=ali" {
436 if got := peopleURL("ali", "", peopleSort{key: sortName}, 2); got != "/-/u?page=2&q=ali" {
437 437 t.Fatalf("searched page = %q", got)
438 438 }
439 439 // The search, the status and the order all survive a page link.
440 if got := peopleURL("a", "active", peopleSort{key: sortReviews, desc: true}, 2); got != "/u?dir=desc&page=2&q=a&sort=reviews&status=active" {
440 if got := peopleURL("a", "active", peopleSort{key: sortReviews, desc: true}, 2); got != "/-/u?dir=desc&page=2&q=a&sort=reviews&status=active" {
441 441 t.Fatalf("filtered and sorted page = %q", got)
442 442 }
443 443 }
@@ -451,7 +451,7 @@ func TestUserRefLinksOnlyAccounts(t *testing.T) {
451 451 w := s.web
452 452
453 453 known := w.userRef("alice")
454 if !known.Known || known.Href != "/u/alice" || known.Name != "alice" {
454 if !known.Known || known.Href != "/-/u/alice" || known.Name != "alice" {
455 455 t.Fatalf("known account = %+v", known)
456 456 }
457 457 unknown := w.userRef("someone-who-never-registered")
@@ -474,15 +474,15 @@ func TestDirectoryPaginates(t *testing.T) {
474 474 }
475 475 }
476 476
477 first := s.serve(t, nil, "/u").Body.String()
477 first := s.serve(t, nil, "/-/u").Body.String()
478 478 if got := strings.Count(first, `class="people-name"`); got != peoplePage {
479 479 t.Fatalf("first page shows %d rows, want %d", got, peoplePage)
480 480 }
481 if !strings.Contains(first, `href="/u?page=2"`) {
481 if !strings.Contains(first, `href="/-/u?page=2"`) {
482 482 t.Fatalf("the first page has no link to the second:\n%s", first)
483 483 }
484 484
485 second := s.serve(t, nil, "/u?sort=joined&dir=desc&page=2").Body.String()
485 second := s.serve(t, nil, "/-/u?sort=joined&dir=desc&page=2").Body.String()
486 486 want := 3 + peoplePage + 5 - peoplePage // the fixture's accounts plus the extra ones
487 487 if got := strings.Count(second, `class="people-name"`); got != want {
488 488 t.Fatalf("second page shows %d rows, want %d", got, want)