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 · 4h 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_test.go +35−35

@@ -33,7 +33,7 @@ func TestCommitPageShowsThePatch(t *testing.T) {
33 33 }
34 34 sha := mustSHA(t, work)
35 35
36 code, body := f.get("/r/" + f.repo + "/commit/" + sha)
36 code, body := f.get("/" + f.repo + "/commit/" + sha)
37 37 if code != 200 {
38 38 t.Fatalf("commit page = %d", code)
39 39 }
@@ -56,11 +56,11 @@ func TestCommitPageShowsThePatch(t *testing.T) {
56 56 }
57 57 // The subject links back from the commit list, so the page is reachable by
58 58 // clicking rather than by knowing a sha.
59 if code, body := f.get("/r/" + f.repo + "/commits/main"); code != 200 || !strings.Contains(body, "/commit/"+sha) {
59 if code, body := f.get("/" + f.repo + "/commits/main"); code != 200 || !strings.Contains(body, "/commit/"+sha) {
60 60 t.Fatalf("commits page does not link to the commit (%d)", code)
61 61 }
62 62 // A sha that does not exist is a 404, not a 500.
63 if code, _ := f.get("/r/" + f.repo + "/commit/deadbeefdeadbeef"); code != 404 {
63 if code, _ := f.get("/" + f.repo + "/commit/deadbeefdeadbeef"); code != 404 {
64 64 t.Fatalf("unknown commit = %d, want 404", code)
65 65 }
66 66 }
@@ -90,7 +90,7 @@ func TestRequestPageShowsWhatWouldLand(t *testing.T) {
90 90 t.Fatalf("request push: %v", err)
91 91 }
92 92
93 code, body := f.get("/r/" + f.repo + "/changes/" + reqID)
93 code, body := f.get("/" + f.repo + "/changes/" + reqID)
94 94 if code != 200 {
95 95 t.Fatalf("request page = %d", code)
96 96 }
@@ -100,7 +100,7 @@ func TestRequestPageShowsWhatWouldLand(t *testing.T) {
100 100 }
101 101 }
102 102
103 code, body = f.get("/r/" + f.repo + "/changes/" + reqID + "/files")
103 code, body = f.get("/" + f.repo + "/changes/" + reqID + "/files")
104 104 if code != 200 {
105 105 t.Fatalf("request files page = %d", code)
106 106 }
@@ -121,7 +121,7 @@ func TestRequestPageShowsWhatWouldLand(t *testing.T) {
121 121 }
122 122 // Each file is a review of its own: the top of the list is selected by default,
123 123 // and any other row opens the same surface.
124 code, body = f.get("/r/" + f.repo + "/changes/" + reqID + "/files/readme.md")
124 code, body = f.get("/" + f.repo + "/changes/" + reqID + "/files/readme.md")
125 125 if code != 200 {
126 126 t.Fatalf("request file page = %d", code)
127 127 }
@@ -177,7 +177,7 @@ func TestMergedRequestStillShowsWhatLanded(t *testing.T) {
177 177 }
178 178 }
179 179
180 code, body := f.get("/r/" + f.repo + "/changes/" + reqID)
180 code, body := f.get("/" + f.repo + "/changes/" + reqID)
181 181 if code != 200 {
182 182 t.Fatalf("merged request page = %d", code)
183 183 }
@@ -189,7 +189,7 @@ func TestMergedRequestStillShowsWhatLanded(t *testing.T) {
189 189 if strings.Contains(body, "1 file(s) changed · merged into") {
190 190 t.Fatalf("the changes panel repeats the merged target the header already gives:\n%s", body)
191 191 }
192 code, body = f.get("/r/" + f.repo + "/changes/" + reqID + "/files")
192 code, body = f.get("/" + f.repo + "/changes/" + reqID + "/files")
193 193 if code != 200 {
194 194 t.Fatalf("merged request files page = %d", code)
195 195 }
@@ -245,14 +245,14 @@ func TestRequestRecordsTheBranchItCameFrom(t *testing.T) {
245 245 }
246 246 }
247 247
248 code, body := f.get("/r/" + f.repo + "/changes/" + id)
248 code, body := f.get("/" + f.repo + "/changes/" + id)
249 249 if code != 200 {
250 250 t.Fatalf("request page = %d", code)
251 251 }
252 252 if !strings.Contains(body, ">feature</code>") {
253 253 t.Fatalf("the request page does not name the branch it came from:\n%s", body)
254 254 }
255 _, body = f.get("/r/" + f.repo + "/changes")
255 _, body = f.get("/" + f.repo + "/changes")
256 256 if !strings.Contains(body, ">feature</code>") {
257 257 t.Fatalf("the request list does not name the branch it came from:\n%s", body)
258 258 }
@@ -291,7 +291,7 @@ func TestRequestRecordsTheBranchItCameFrom(t *testing.T) {
291 291 if !strings.Contains(record, "head: "+second) {
292 292 t.Fatalf("the request did not follow the branch to %s:\n%s", second, record)
293 293 }
294 _, body = f.get("/r/" + f.repo + "/changes/" + id)
294 _, body = f.get("/" + f.repo + "/changes/" + id)
295 295 if !strings.Contains(body, "/commit/"+second) {
296 296 t.Fatalf("the page does not show the followed tip %s:\n%s", second, body)
297 297 }
@@ -301,7 +301,7 @@ func TestRequestRecordsTheBranchItCameFrom(t *testing.T) {
301 301 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), ":refs/heads/feature"); err != nil {
302 302 t.Fatalf("delete branch: %s", errs)
303 303 }
304 _, body = f.get("/r/" + f.repo + "/changes/" + id)
304 _, body = f.get("/" + f.repo + "/changes/" + id)
305 305 if !strings.Contains(body, "branch deleted") || !strings.Contains(body, ">feature</code>") ||
306 306 !strings.Contains(body, "/commit/"+second) {
307 307 t.Fatalf("a deleted branch is not labelled, or its last head is gone:\n%s", body)
@@ -346,7 +346,7 @@ func TestCommitPageEscapesFileContent(t *testing.T) {
346 346 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
347 347 t.Fatalf("push: %s", errs)
348 348 }
349 _, body := f.get("/r/" + f.repo + "/commit/" + mustSHA(t, work))
349 _, body := f.get("/" + f.repo + "/commit/" + mustSHA(t, work))
350 350 if strings.Contains(body, "<script>alert(1)</script>") {
351 351 t.Fatalf("the diff put live markup into the page:\n%s", body)
352 352 }
@@ -368,7 +368,7 @@ func TestReadmeIsTheProjectOverview(t *testing.T) {
368 368 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
369 369 t.Fatalf("push: %s", errs)
370 370 }
371 code, body := f.get("/r/" + f.repo)
371 code, body := f.get("/" + f.repo)
372 372 if code != 200 {
373 373 t.Fatalf("overview page = %d", code)
374 374 }
@@ -376,25 +376,25 @@ func TestReadmeIsTheProjectOverview(t *testing.T) {
376 376 t.Fatalf("README was not rendered on the overview page:\n%s", body)
377 377 }
378 378 // The feed has its own tab, and the code tab still renders the README too.
379 if code, feed := f.get("/r/" + f.repo + "/activity"); code != 200 || !strings.Contains(feed, "Activity") {
379 if code, feed := f.get("/" + f.repo + "/activity"); code != 200 || !strings.Contains(feed, "Activity") {
380 380 t.Fatalf("repo activity page = %d", code)
381 381 }
382 if code, codePage := f.get("/r/" + f.repo + "/code"); code != 200 || !strings.Contains(codePage, "Demo project") {
382 if code, codePage := f.get("/" + f.repo + "/code"); code != 200 || !strings.Contains(codePage, "Demo project") {
383 383 t.Fatalf("code page = %d", code)
384 384 }
385 385 // Markdown opens as source like any other file; rendering is an explicit
386 386 // preview choice, not the default.
387 if code, body := f.get("/r/" + f.repo + "/blob/main/README.md"); code != 200 ||
387 if code, body := f.get("/" + f.repo + "/blob/main/README.md"); code != 200 ||
388 388 !strings.Contains(body, `data-highlight="markdown"`) || strings.Contains(body, "<h1>Demo project</h1>") {
389 389 t.Fatalf("markdown source blob = %d:\n%s", code, body)
390 390 }
391 if code, body := f.get("/r/" + f.repo + "/blob/main/README.md?preview=1"); code != 200 ||
391 if code, body := f.get("/" + f.repo + "/blob/main/README.md?preview=1"); code != 200 ||
392 392 !strings.Contains(body, "<h1>Demo project</h1>") {
393 393 t.Fatalf("markdown preview blob = %d:\n%s", code, body)
394 394 }
395 395 // The blob names its language and the browser colours it, so the server sends
396 396 // no token spans; what matters is that it is code, not rendered markdown.
397 if code, body := f.get("/r/" + f.repo + "/blob/main/main.go"); code != 200 ||
397 if code, body := f.get("/" + f.repo + "/blob/main/main.go"); code != 200 ||
398 398 !strings.Contains(body, `data-highlight="go"`) || !strings.Contains(body, "package main") ||
399 399 strings.Contains(body, `class="token`) {
400 400 t.Fatalf("source blob = %d", code)
@@ -413,15 +413,15 @@ func TestReadmeIsSanitised(t *testing.T) {
413 413 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
414 414 t.Fatalf("push: %s", errs)
415 415 }
416 _, body := f.get("/r/" + f.repo)
416 _, body := f.get("/" + f.repo)
417 417 if strings.Contains(body, "<script>alert(1)</script>") {
418 418 t.Fatalf("rendered README executed a tag:\n%s", body)
419 419 }
420 420 }
421 421
422 422 // TestReadmeRelativeLinksOpenTheFileTheyName is the bug this fixes: a relative
423 // link in a README resolves against the page it is rendered on, so `/r/demo` sent
424 // `docs/guide.md` to `/r/demo/docs/guide.md`. It has to resolve against the
423 // link in a README resolves against the page it is rendered on, so `/demo` sent
424 // `docs/guide.md` to `/demo/docs/guide.md`. It has to resolve against the
425 425 // document instead, on both surfaces that render the README, and a document in a
426 426 // subdirectory has to resolve its own links the same way.
427 427 func TestReadmeRelativeLinksOpenTheFileTheyName(t *testing.T) {
@@ -442,15 +442,15 @@ func TestReadmeRelativeLinksOpenTheFileTheyName(t *testing.T) {
442 442 }
443 443
444 444 want := []string{
445 `href="/r/demo/blob/main/docs/guide.md?preview=1"`,
446 `href="/r/demo/blob/main/LICENSE"`,
447 `href="/r/demo/blob/main/.vole/workflows/verify.yaml"`,
448 `href="/r/demo/tree/main/docs"`,
445 `href="/demo/blob/main/docs/guide.md?preview=1"`,
446 `href="/demo/blob/main/LICENSE"`,
447 `href="/demo/blob/main/.vole/workflows/verify.yaml"`,
448 `href="/demo/tree/main/docs"`,
449 449 `href="https://example.com/x"`,
450 450 `href="#demo"`,
451 `src="/r/demo/raw/main/img/shot.png"`,
451 `src="/demo/raw/main/img/shot.png"`,
452 452 }
453 for _, page := range []string{"/r/" + f.repo, "/r/" + f.repo + "/code"} {
453 for _, page := range []string{"/" + f.repo, "/" + f.repo + "/code"} {
454 454 code, body := f.get(page)
455 455 if code != 200 {
456 456 t.Fatalf("%s = %d", page, code)
@@ -463,13 +463,13 @@ func TestReadmeRelativeLinksOpenTheFileTheyName(t *testing.T) {
463 463 }
464 464
465 465 // A document below the root resolves against its own directory.
466 code, body := f.get("/r/" + f.repo + "/blob/main/docs/guide.md?preview=1")
466 code, body := f.get("/" + f.repo + "/blob/main/docs/guide.md?preview=1")
467 467 if code != 200 {
468 468 t.Fatalf("guide preview = %d", code)
469 469 }
470 470 for _, w := range []string{
471 `href="/r/demo/blob/main/README.md?preview=1"`,
472 `src="/r/demo/raw/main/docs/shot.png"`,
471 `href="/demo/blob/main/README.md?preview=1"`,
472 `src="/demo/raw/main/docs/shot.png"`,
473 473 } {
474 474 if !strings.Contains(body, w) {
475 475 t.Fatalf("guide preview does not contain %s:\n%s", w, body)
@@ -478,8 +478,8 @@ func TestReadmeRelativeLinksOpenTheFileTheyName(t *testing.T) {
478 478
479 479 // The source view shows the file's bytes, so the link stays as it was written
480 480 // there rather than becoming the URL the preview would use.
481 if _, body := f.get("/r/" + f.repo + "/blob/main/README.md"); !strings.Contains(body, "docs/guide.md") ||
482 strings.Contains(body, `href="/r/demo/blob/main/docs/guide.md?preview=1"`) {
481 if _, body := f.get("/" + f.repo + "/blob/main/README.md"); !strings.Contains(body, "docs/guide.md") ||
482 strings.Contains(body, `href="/demo/blob/main/docs/guide.md?preview=1"`) {
483 483 t.Fatalf("the source view is not the file as written:\n%s", body)
484 484 }
485 485 }
@@ -516,7 +516,7 @@ func TestChangeListFiltersMergedRequests(t *testing.T) {
516 516 t.Fatalf("merge: %v\n%s", err, errs)
517 517 }
518 518
519 code, body := f.get("/r/" + f.repo + "/changes?state=merged")
519 code, body := f.get("/" + f.repo + "/changes?state=merged")
520 520 if code != 200 {
521 521 t.Fatalf("merged list = %d", code)
522 522 }
@@ -538,7 +538,7 @@ func TestChangeListFiltersMergedRequests(t *testing.T) {
538 538
539 539 // Closed means closed, not "no longer open": a landing is reachable through
540 540 // its own filter and is not counted twice.
541 code, body = f.get("/r/" + f.repo + "/changes?state=closed")
541 code, body = f.get("/" + f.repo + "/changes?state=closed")
542 542 if code != 200 {
543 543 t.Fatalf("closed list = %d", code)
544 544 }