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/ui_test.go +93−93

@@ -31,7 +31,7 @@ func TestInstanceNameReachesThePageChrome(t *testing.T) {
31 31 t.Parallel()
32 32 f := newFixtureWith(t, func(c *config.Config) { c.InstanceName = "Acme Forge" })
33 33 f.publish(f.repo)
34 for _, path := range []string{"/", "/r/" + f.repo, "/r/" + f.repo + "/issues", "/no/such/page"} {
34 for _, path := range []string{"/", "/" + f.repo, "/" + f.repo + "/issues", "/no/such/page"} {
35 35 code, body := f.get(path)
36 36 if code != 200 && code != 404 {
37 37 t.Fatalf("%s = %d", path, code)
@@ -69,14 +69,14 @@ func TestRepositoryPagesShareTheTabHeader(t *testing.T) {
69 69 f := newFixture(t)
70 70 f.publish(f.repo)
71 71 for _, path := range []string{
72 "/r/" + f.repo,
73 "/r/" + f.repo + "/activity",
74 "/r/" + f.repo + "/code",
75 "/r/" + f.repo + "/issues",
76 "/r/" + f.repo + "/changes",
77 "/r/" + f.repo + "/releases",
78 "/r/" + f.repo + "/runs",
79 "/r/" + f.repo + "/locks",
72 "/" + f.repo,
73 "/" + f.repo + "/activity",
74 "/" + f.repo + "/code",
75 "/" + f.repo + "/issues",
76 "/" + f.repo + "/changes",
77 "/" + f.repo + "/releases",
78 "/" + f.repo + "/runs",
79 "/" + f.repo + "/locks",
80 80 } {
81 81 code, body := f.get(path)
82 82 if code != 200 {
@@ -128,7 +128,7 @@ func TestRepoTabsCountOpenIssuesAndRequests(t *testing.T) {
128 128
129 129 // The header is shared, so a page that shows nothing but code must carry the
130 130 // same counts as the lists themselves.
131 code, body := f.get("/r/" + f.repo + "/code")
131 code, body := f.get("/" + f.repo + "/code")
132 132 if code != 200 {
133 133 t.Fatalf("code page = %d", code)
134 134 }
@@ -148,7 +148,7 @@ func TestRepoTabShowsNameAndVisibility(t *testing.T) {
148 148
149 149 // A new repository is private until its ACL default is raised to read.
150 150 _, cookie := f.browserSession(t, f.ownerID)
151 resp, body := f.browserGet(t, "/r/"+f.repo, cookie)
151 resp, body := f.browserGet(t, "/"+f.repo, cookie)
152 152 if resp.StatusCode != 200 || !strings.Contains(body, `class="repotab active"`) ||
153 153 !strings.Contains(body, `title="Private repository"`) || !strings.Contains(body, "demo</div>") {
154 154 t.Fatalf("private repo header = %d:\n%s", resp.StatusCode, body)
@@ -158,7 +158,7 @@ func TestRepoTabShowsNameAndVisibility(t *testing.T) {
158 158 }
159 159
160 160 f.publish(f.repo)
161 _, body = f.get("/r/" + f.repo + "/code")
161 _, body = f.get("/" + f.repo + "/code")
162 162 if !strings.Contains(body, `title="Public repository"`) || !strings.Contains(body, ">Overview</a>") {
163 163 t.Fatalf("public repo header does not show the globe state or Overview tab:\n%s", body)
164 164 }
@@ -167,7 +167,7 @@ func TestRepoTabShowsNameAndVisibility(t *testing.T) {
167 167 func TestStaticAssetsAreServedFromTheBinary(t *testing.T) {
168 168 t.Parallel()
169 169 f := newFixture(t)
170 code, body := f.get("/static/vole.css")
170 code, body := f.get("/-/static/vole.css")
171 171 if code != 200 {
172 172 t.Fatalf("stylesheet = %d", code)
173 173 }
@@ -190,9 +190,9 @@ func TestEverforestThemesAndAccountPicker(t *testing.T) {
190 190 t.Fatalf("landing page = %d", code)
191 191 }
192 192 for _, want := range []string{
193 `src="/static/theme.js"`,
194 `href="/static/everforest.css"`,
195 `href="/static/palettes.css"`,
193 `src="/-/static/theme.js"`,
194 `href="/-/static/everforest.css"`,
195 `href="/-/static/palettes.css"`,
196 196 `data-theme="everforest-dark-medium"`,
197 197 `data-font-scale="medium"`,
198 198 } {
@@ -203,14 +203,14 @@ func TestEverforestThemesAndAccountPicker(t *testing.T) {
203 203 if strings.Contains(shell, "data-theme-select") {
204 204 t.Error("the theme picker belongs on the account page, not in the global bar")
205 205 }
206 if script, sheet := strings.Index(shell, "/static/theme.js"), strings.Index(shell, "/static/vole.css"); script < 0 || sheet < 0 || script > sheet {
206 if script, sheet := strings.Index(shell, "/-/static/theme.js"), strings.Index(shell, "/-/static/vole.css"); script < 0 || sheet < 0 || script > sheet {
207 207 t.Error("theme.js must run before the stylesheet so a stored choice does not flash")
208 208 }
209 209
210 210 _, cookie := f.browserSession(t, f.ownerID)
211 resp, account := f.browserGet(t, "/account", cookie)
211 resp, account := f.browserGet(t, "/-/account", cookie)
212 212 if resp.StatusCode != 200 {
213 t.Fatalf("/account = %d", resp.StatusCode)
213 t.Fatalf("/-/account = %d", resp.StatusCode)
214 214 }
215 215 if !strings.Contains(account, "data-theme-select") || !strings.Contains(account, `value="system"`) {
216 216 t.Fatal("the account page does not carry the theme picker")
@@ -246,9 +246,9 @@ func TestEverforestThemesAndAccountPicker(t *testing.T) {
246 246 }
247 247 }
248 248
249 code, css := f.get("/static/everforest.css")
249 code, css := f.get("/-/static/everforest.css")
250 250 if code != 200 {
251 t.Fatalf("/static/everforest.css = %d", code)
251 t.Fatalf("/-/static/everforest.css = %d", code)
252 252 }
253 253 for _, variant := range []string{
254 254 "everforest-dark-hard",
@@ -269,9 +269,9 @@ func TestEverforestThemesAndAccountPicker(t *testing.T) {
269 269 t.Fatal("the Everforest sheet has an unscoped body rule and would restyle the fallback")
270 270 }
271 271
272 code, palettes := f.get("/static/palettes.css")
272 code, palettes := f.get("/-/static/palettes.css")
273 273 if code != 200 {
274 t.Fatalf("/static/palettes.css = %d", code)
274 t.Fatalf("/-/static/palettes.css = %d", code)
275 275 }
276 276 for _, variant := range []string{
277 277 "catppuccin-mocha",
@@ -293,9 +293,9 @@ func TestEverforestThemesAndAccountPicker(t *testing.T) {
293 293 t.Fatal("the palette sheet has an unscoped body rule and would restyle the fallback")
294 294 }
295 295
296 code, base := f.get("/static/vole.css")
296 code, base := f.get("/-/static/vole.css")
297 297 if code != 200 {
298 t.Fatalf("/static/vole.css = %d", code)
298 t.Fatalf("/-/static/vole.css = %d", code)
299 299 }
300 300 for _, want := range []string{`--font-scale: 0.9`, `--font-scale: 1.1`, `font-size: calc(13.2px * var(--font-scale))`} {
301 301 if !strings.Contains(base, want) {
@@ -303,9 +303,9 @@ func TestEverforestThemesAndAccountPicker(t *testing.T) {
303 303 }
304 304 }
305 305
306 code, script := f.get("/static/theme.js")
306 code, script := f.get("/-/static/theme.js")
307 307 if code != 200 {
308 t.Fatalf("/static/theme.js = %d", code)
308 t.Fatalf("/-/static/theme.js = %d", code)
309 309 }
310 310 for _, want := range []string{"localStorage", "dataset.theme", "dataset.themes", "prefers-color-scheme", "everforest-light-medium", "dataset.fontScale", "vole-font-scale"} {
311 311 if !strings.Contains(script, want) {
@@ -320,9 +320,9 @@ func TestAccountTablesUseThePanelSurface(t *testing.T) {
320 320 t.Parallel()
321 321 f := newFixture(t)
322 322 _, cookie := f.browserSession(t, f.ownerID)
323 resp, body := f.browserGet(t, "/account", cookie)
323 resp, body := f.browserGet(t, "/-/account", cookie)
324 324 if resp.StatusCode != 200 {
325 t.Fatalf("/account = %d", resp.StatusCode)
325 t.Fatalf("/-/account = %d", resp.StatusCode)
326 326 }
327 327 for _, heading := range []string{"SSH keys", "Sessions"} {
328 328 at := strings.Index(body, "<h2>"+heading+"</h2>")
@@ -343,7 +343,7 @@ func TestAccountTablesUseThePanelSurface(t *testing.T) {
343 343 func TestCommentCardStyleDoesNotMatchPrismTokens(t *testing.T) {
344 344 t.Parallel()
345 345 f := newFixture(t)
346 code, css := f.get("/static/vole.css")
346 code, css := f.get("/-/static/vole.css")
347 347 if code != 200 {
348 348 t.Fatalf("stylesheet = %d", code)
349 349 }
@@ -369,7 +369,7 @@ func TestHTMXDoesNotInjectInlineStyles(t *testing.T) {
369 369 !strings.Contains(shell, `"includeIndicatorStyles":false`) {
370 370 t.Fatal("the shell does not turn off HTMX's inline indicator styles")
371 371 }
372 code, css := f.get("/static/vole.css")
372 code, css := f.get("/-/static/vole.css")
373 373 if code != 200 {
374 374 t.Fatalf("stylesheet = %d", code)
375 375 }
@@ -386,13 +386,13 @@ func TestTheEmblemShipsAndTheShellNamesIt(t *testing.T) {
386 386 f := newFixture(t)
387 387
388 388 for _, name := range []string{"logo.png", "vole-head.png", "vole-head-solid.png", "favicon-32.png", "favicon-32-dark.png", "favicon-16.png", "favicon-16-dark.png"} {
389 resp, err := http.Get(f.http.URL + "/static/" + name)
389 resp, err := http.Get(f.http.URL + "/-/static/" + name)
390 390 if err != nil {
391 391 t.Fatal(err)
392 392 }
393 393 if resp.StatusCode != http.StatusOK {
394 394 resp.Body.Close()
395 t.Fatalf("/static/%s = %d", name, resp.StatusCode)
395 t.Fatalf("/-/static/%s = %d", name, resp.StatusCode)
396 396 }
397 397 if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "png") {
398 398 resp.Body.Close()
@@ -413,21 +413,21 @@ func TestTheEmblemShipsAndTheShellNamesIt(t *testing.T) {
413 413 if code != http.StatusOK {
414 414 t.Fatalf("landing page = %d", code)
415 415 }
416 if !strings.Contains(shell, `href="/static/favicon-32.png"`) ||
417 !strings.Contains(shell, `href="/static/favicon-32-dark.png"`) ||
418 !strings.Contains(shell, `href="/static/favicon.svg"`) ||
416 if !strings.Contains(shell, `href="/-/static/favicon-32.png"`) ||
417 !strings.Contains(shell, `href="/-/static/favicon-32-dark.png"`) ||
418 !strings.Contains(shell, `href="/-/static/favicon.svg"`) ||
419 419 !strings.Contains(shell, `media="(prefers-color-scheme: dark)"`) {
420 420 t.Error("the shell does not name the light/dark fallback and SVG favicons")
421 421 }
422 422 if !strings.Contains(shell, `<span class="mark"`) {
423 423 t.Error("the header does not use the head mark")
424 424 }
425 code, css := f.get("/static/vole.css")
425 code, css := f.get("/-/static/vole.css")
426 426 if code != http.StatusOK {
427 427 t.Fatalf("stylesheet = %d", code)
428 428 }
429 if !strings.Contains(css, `url("/static/vole-head.png")`) ||
430 !strings.Contains(css, `url("/static/vole-head-solid.png")`) ||
429 if !strings.Contains(css, `url("/-/static/vole-head.png")`) ||
430 !strings.Contains(css, `url("/-/static/vole-head-solid.png")`) ||
431 431 !strings.Contains(css, "--mark-fill: currentColor") ||
432 432 !strings.Contains(css, "--mark-outline: transparent") ||
433 433 !strings.Contains(css, "--mark-fill: var(--bg)") ||
@@ -435,14 +435,14 @@ func TestTheEmblemShipsAndTheShellNamesIt(t *testing.T) {
435 435 t.Error("the head mark does not carry the emblem and light-outline treatment")
436 436 }
437 437
438 code, svg := f.get("/static/favicon.svg")
438 code, svg := f.get("/-/static/favicon.svg")
439 439 if code != http.StatusOK || !strings.Contains(svg, "<svg") {
440 t.Fatalf("/static/favicon.svg = %d and does not look like an SVG", code)
440 t.Fatalf("/-/static/favicon.svg = %d and does not look like an SVG", code)
441 441 }
442 442 if !strings.Contains(svg, "prefers-color-scheme") || !strings.Contains(svg, "#000") || !strings.Contains(svg, "#fff") {
443 443 t.Error("the SVG favicon does not switch between black and white with the browser preference")
444 444 }
445 if _, script := f.get("/static/theme.js"); strings.Contains(script, "favicon") {
445 if _, script := f.get("/-/static/theme.js"); strings.Contains(script, "favicon") {
446 446 t.Error("the app theme must not choose the favicon; the browser window preference does")
447 447 }
448 448 if strings.Contains(shell, "logo.svg") {
@@ -465,13 +465,13 @@ func TestSelfHostedFontsAreEmbedded(t *testing.T) {
465 465 "jetbrains-mono-italic-latin.woff2",
466 466 "jetbrains-mono-italic-latin-ext.woff2",
467 467 } {
468 resp, err := http.Get(f.http.URL + "/static/fonts/" + name)
468 resp, err := http.Get(f.http.URL + "/-/static/fonts/" + name)
469 469 if err != nil {
470 470 t.Fatal(err)
471 471 }
472 472 if resp.StatusCode != http.StatusOK {
473 473 resp.Body.Close()
474 t.Fatalf("/static/fonts/%s = %d", name, resp.StatusCode)
474 t.Fatalf("/-/static/fonts/%s = %d", name, resp.StatusCode)
475 475 }
476 476 if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "font/woff2") {
477 477 resp.Body.Close()
@@ -488,25 +488,25 @@ func TestSelfHostedFontsAreEmbedded(t *testing.T) {
488 488 }
489 489 }
490 490
491 code, css := f.get("/static/vole.css")
491 code, css := f.get("/-/static/vole.css")
492 492 if code != http.StatusOK {
493 t.Fatalf("/static/vole.css = %d", code)
493 t.Fatalf("/-/static/vole.css = %d", code)
494 494 }
495 495 for _, want := range []string{
496 496 `@font-face`,
497 497 `font-family: "Lexend"`,
498 498 `font-family: "JetBrains Mono"`,
499 `/static/fonts/lexend-latin.woff2`,
500 `/static/fonts/jetbrains-mono-latin.woff2`,
499 `/-/static/fonts/lexend-latin.woff2`,
500 `/-/static/fonts/jetbrains-mono-latin.woff2`,
501 501 } {
502 502 if !strings.Contains(css, want) {
503 503 t.Errorf("the stylesheet does not carry %s", want)
504 504 }
505 505 }
506 506 for _, name := range []string{"OFL-Lexend.txt", "OFL-JetBrainsMono.txt"} {
507 code, body := f.get("/static/fonts/" + name)
507 code, body := f.get("/-/static/fonts/" + name)
508 508 if code != http.StatusOK || !strings.Contains(body, "SIL OPEN FONT LICENSE") {
509 t.Errorf("/static/fonts/%s is missing or does not look like the OFL text", name)
509 t.Errorf("/-/static/fonts/%s is missing or does not look like the OFL text", name)
510 510 }
511 511 }
512 512 }
@@ -519,11 +519,11 @@ func TestRunsPageListsQueuedWork(t *testing.T) {
519 519 if _, err := f.srv.Runs.Enqueue(run, nil); err != nil {
520 520 t.Fatal(err)
521 521 }
522 code, body := f.get("/r/" + f.repo + "/runs")
522 code, body := f.get("/" + f.repo + "/runs")
523 523 if code != 200 {
524 524 t.Fatalf("runs page = %d", code)
525 525 }
526 for _, want := range []string{"unit-tests", "/r/" + f.repo + "/runs/" + run.ID, "Queued"} {
526 for _, want := range []string{"unit-tests", "/" + f.repo + "/runs/" + run.ID, "Queued"} {
527 527 if !strings.Contains(body, want) {
528 528 t.Fatalf("runs page does not show %q:\n%s", want, body)
529 529 }
@@ -541,7 +541,7 @@ func TestOverviewAndActivityPages(t *testing.T) {
541 541 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
542 542 t.Fatalf("push: %s", errs)
543 543 }
544 code, body := f.get("/r/" + f.repo)
544 code, body := f.get("/" + f.repo)
545 545 if code != 200 {
546 546 t.Fatalf("overview = %d", code)
547 547 }
@@ -552,12 +552,12 @@ func TestOverviewAndActivityPages(t *testing.T) {
552 552 }
553 553 // The activity tab is a separate page. Commits are not feed events: they
554 554 // belong to the overview and the code browser.
555 code, body = f.get("/r/" + f.repo + "/activity")
555 code, body = f.get("/" + f.repo + "/activity")
556 556 if code != 200 || !strings.Contains(body, "Activity") || strings.Contains(body, "committed") {
557 557 t.Fatalf("activity page = %d:\n%s", code, body)
558 558 }
559 559 // Code browsing moved to its own tab, and that page still lists the tree.
560 if code, codePage := f.get("/r/" + f.repo + "/code"); code != 200 || !strings.Contains(codePage, "main.go") {
560 if code, codePage := f.get("/" + f.repo + "/code"); code != 200 || !strings.Contains(codePage, "main.go") {
561 561 t.Fatalf("code page = %d:\n%s", code, codePage)
562 562 }
563 563 }
@@ -600,15 +600,15 @@ func TestOverviewShowsRecentOpenIssuesAndChanges(t *testing.T) {
600 600 t.Fatalf("requests push: %v", err)
601 601 }
602 602
603 code, body := f.get("/r/" + f.repo)
603 code, body := f.get("/" + f.repo)
604 604 if code != 200 {
605 605 t.Fatalf("overview = %d", code)
606 606 }
607 607 for _, want := range []string{
608 608 "Recent issues", "Recent changes",
609 609 "Overview open issue", "Overview open change",
610 `href="/r/` + f.repo + `/issues/` + openIssueID + `"`,
611 `href="/r/` + f.repo + `/changes/` + openChangeID + `"`,
610 `href="/` + f.repo + `/issues/` + openIssueID + `"`,
611 `href="/` + f.repo + `/changes/` + openChangeID + `"`,
612 612 } {
613 613 if !strings.Contains(body, want) {
614 614 t.Fatalf("overview does not show %q:\n%s", want, body)
@@ -659,15 +659,15 @@ func TestOverviewContributorsAreAccountsNotCommitAuthors(t *testing.T) {
659 659 t.Fatalf("push merged change: %v", err)
660 660 }
661 661
662 code, body := f.get("/r/" + f.repo)
662 code, body := f.get("/" + f.repo)
663 663 if code != 200 {
664 664 t.Fatalf("overview = %d", code)
665 665 }
666 666 for _, want := range []string{
667 667 "Contributors",
668 `href="/u/wilbur"`,
668 `href="/-/u/wilbur"`,
669 669 `data-tip="1 change merged"`, `data-tip="2 issues fixed"`, `data-tip="1 issue reported"`,
670 `href="/u/keenan"`, `data-tip="1 review"`,
670 `href="/-/u/keenan"`, `data-tip="1 review"`,
671 671 } {
672 672 if !strings.Contains(body, want) {
673 673 t.Fatalf("overview does not show %q:\n%s", want, body)
@@ -688,7 +688,7 @@ func TestCodeBrowserTreeAndFragments(t *testing.T) {
688 688 t.Fatalf("push: %s", errs)
689 689 }
690 690
691 code, body := f.get("/r/" + f.repo + "/code")
691 code, body := f.get("/" + f.repo + "/code")
692 692 if code != 200 {
693 693 t.Fatalf("code browser = %d", code)
694 694 }
@@ -702,7 +702,7 @@ func TestCodeBrowserTreeAndFragments(t *testing.T) {
702 702 }
703 703
704 704 // A file click is a fragment: the tree stays put and the main pane is replaced.
705 resp, fragment := f.htmxGet(t, "/r/"+f.repo+"/blob/main/internal/run.go", "")
705 resp, fragment := f.htmxGet(t, "/"+f.repo+"/blob/main/internal/run.go", "")
706 706 if resp.StatusCode != 200 || !strings.Contains(fragment, `id="code-main"`) ||
707 707 !strings.Contains(fragment, `data-mode="blob"`) ||
708 708 strings.Contains(fragment, "<!doctype html>") ||
@@ -713,25 +713,25 @@ func TestCodeBrowserTreeAndFragments(t *testing.T) {
713 713
714 714 // A directory click replaces the main pane with a listing whose child links
715 715 // carry the directory path rather than only the leaf name.
716 resp, fragment = f.htmxGet(t, "/r/"+f.repo+"/tree/main/internal", "")
716 resp, fragment = f.htmxGet(t, "/"+f.repo+"/tree/main/internal", "")
717 717 if resp.StatusCode != 200 || !strings.Contains(fragment, `data-mode="tree"`) ||
718 !strings.Contains(fragment, "/r/"+f.repo+"/blob/main/internal/run.go") {
718 !strings.Contains(fragment, "/"+f.repo+"/blob/main/internal/run.go") {
719 719 t.Fatalf("tree fragment = %d:\n%s", resp.StatusCode, fragment)
720 720 }
721 721
722 722 // The branch crumb returns to the Code overview, not a second directory
723 723 // listing inside the main pane.
724 resp, fragment = f.htmxGet(t, "/r/"+f.repo+"/code?ref=main", "")
724 resp, fragment = f.htmxGet(t, "/"+f.repo+"/code?ref=main", "")
725 725 if resp.StatusCode != 200 || !strings.Contains(fragment, `data-mode="overview"`) ||
726 726 !strings.Contains(fragment, "<h1>Code browser</h1>") {
727 727 t.Fatalf("branch crumb fragment = %d:\n%s", resp.StatusCode, fragment)
728 728 }
729 if !strings.Contains(body, `href="/r/`+f.repo+`/code?ref=main"`) {
729 if !strings.Contains(body, `href="/`+f.repo+`/code?ref=main"`) {
730 730 t.Fatalf("branch crumb does not target the code overview:\n%s", body)
731 731 }
732 732
733 733 // The branch root still has its own directory URL, used by the ".." row.
734 resp, fragment = f.htmxGet(t, "/r/"+f.repo+"/tree/main", "")
734 resp, fragment = f.htmxGet(t, "/"+f.repo+"/tree/main", "")
735 735 if resp.StatusCode != 200 || !strings.Contains(fragment, `data-mode="tree"`) ||
736 736 !strings.Contains(fragment, "main.go") {
737 737 t.Fatalf("root tree fragment = %d:\n%s", resp.StatusCode, fragment)
@@ -739,7 +739,7 @@ func TestCodeBrowserTreeAndFragments(t *testing.T) {
739 739
740 740 // A direct directory visit renders the same shell, with the folder open in
741 741 // the tree and a link back to its parent.
742 code, body = f.get("/r/" + f.repo + "/tree/main/internal")
742 code, body = f.get("/" + f.repo + "/tree/main/internal")
743 743 if code != 200 || !strings.Contains(body, `id="file-tree"`) ||
744 744 !strings.Contains(body, "internal/run.go") || !strings.Contains(body, "..") {
745 745 t.Fatalf("directory page = %d:\n%s", code, body)
@@ -760,7 +760,7 @@ func TestCodeBrowserRefPickers(t *testing.T) {
760 760
761 761 // Search and the ref pickers share one row under the page header, and the
762 762 // tag picker lists tags beside the branch picker.
763 code, body := f.get("/r/" + f.repo + "/code")
763 code, body := f.get("/" + f.repo + "/code")
764 764 if code != 200 {
765 765 t.Fatalf("code page = %d", code)
766 766 }
@@ -779,7 +779,7 @@ func TestCodeBrowserRefPickers(t *testing.T) {
779 779
780 780 // Opening a tag selects it in the tag picker and leaves the branch picker
781 781 // unclaimed rather than pretending the first branch is open.
782 code, body = f.get("/r/" + f.repo + "/code?ref=v1.0.0")
782 code, body = f.get("/" + f.repo + "/code?ref=v1.0.0")
783 783 if code != 200 {
784 784 t.Fatalf("tag page = %d", code)
785 785 }
@@ -820,9 +820,9 @@ func TestFilterRowsShareOneShape(t *testing.T) {
820 820 path string
821 821 labels []string
822 822 }{
823 {"/r/" + f.repo + "/issues", []string{"Label", "Assignee"}},
824 {"/r/" + f.repo + "/activity", []string{"Actor", "Assignee"}},
825 {"/r/" + f.repo + "/code", []string{"Branch"}},
823 {"/" + f.repo + "/issues", []string{"Label", "Assignee"}},
824 {"/" + f.repo + "/activity", []string{"Actor", "Assignee"}},
825 {"/" + f.repo + "/code", []string{"Branch"}},
826 826 } {
827 827 code, body := f.get(row.path)
828 828 if code != 200 {
@@ -848,10 +848,10 @@ func TestFilterRowsShareOneShape(t *testing.T) {
848 848
849 849 // Text search is the same button on every page: an icon and the word Search.
850 850 for _, path := range []string{
851 "/r/" + f.repo + "/issues",
852 "/r/" + f.repo + "/changes",
853 "/r/" + f.repo + "/search",
854 "/r/" + f.repo + "/code",
851 "/" + f.repo + "/issues",
852 "/" + f.repo + "/changes",
853 "/" + f.repo + "/search",
854 "/" + f.repo + "/code",
855 855 } {
856 856 code, body := f.get(path)
857 857 if code != 200 {
@@ -874,7 +874,7 @@ func TestSourceBlobNamesItsLanguageAndEscapes(t *testing.T) {
874 874 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
875 875 t.Fatalf("push: %s", errs)
876 876 }
877 code, body := f.get("/r/" + f.repo + "/blob/main/main.go")
877 code, body := f.get("/" + f.repo + "/blob/main/main.go")
878 878 if code != 200 {
879 879 t.Fatalf("blob = %d", code)
880 880 }
@@ -902,7 +902,7 @@ func TestActivityFeedIncludesIssuesAndFilters(t *testing.T) {
902 902 t.Fatalf("issues push: %s", errs)
903 903 }
904 904
905 _, body := f.get("/r/" + f.repo + "/activity")
905 _, body := f.get("/" + f.repo + "/activity")
906 906 for _, want := range []string{"Activity issue", "opened issue", "v1.0.0", "released"} {
907 907 if !strings.Contains(body, want) {
908 908 t.Fatalf("activity feed does not show %q:\n%s", want, body)
@@ -911,21 +911,21 @@ func TestActivityFeedIncludesIssuesAndFilters(t *testing.T) {
911 911 if strings.Contains(body, "committed") {
912 912 t.Fatalf("activity feed still shows commits:\n%s", body)
913 913 }
914 _, body = f.get("/r/" + f.repo + "/activity?kind=issues")
914 _, body = f.get("/" + f.repo + "/activity?kind=issues")
915 915 if !strings.Contains(body, "Activity issue") {
916 916 t.Fatalf("issue filter dropped the issue:\n%s", body)
917 917 }
918 918 if strings.Contains(body, "v1.0.0") {
919 919 t.Fatalf("issue filter kept a release:\n%s", body)
920 920 }
921 resp, fragment := f.htmxGet(t, "/r/"+f.repo+"/activity?kind=issues", "")
921 resp, fragment := f.htmxGet(t, "/"+f.repo+"/activity?kind=issues", "")
922 922 if resp.StatusCode != 200 || !strings.Contains(fragment, `id="activity-feed"`) ||
923 923 !strings.Contains(fragment, "Activity issue") {
924 924 t.Fatalf("issue activity fragment = %d:\n%s", resp.StatusCode, fragment)
925 925 }
926 926 }
927 927
928 // Every script is a static asset: the page references it under /static/ and
928 // Every script is a static asset: the page references it under /-/static/ and
929 929 // nothing is inlined into a template, so the CSP stays script-src 'self' and the
930 930 // script is editable as JavaScript rather than as a Go string. The passkey
931 931 // ceremony used to be the exception and is not one any more; its old URL is gone
@@ -933,18 +933,18 @@ func TestActivityFeedIncludesIssuesAndFilters(t *testing.T) {
933 933 func TestPasskeyScriptIsAStaticAsset(t *testing.T) {
934 934 t.Parallel()
935 935 f := newFixture(t)
936 code, body := f.get("/static/auth.js")
936 code, body := f.get("/-/static/auth.js")
937 937 if code != 200 {
938 t.Fatalf("/static/auth.js = %d", code)
938 t.Fatalf("/-/static/auth.js = %d", code)
939 939 }
940 940 if !strings.Contains(body, "navigator.credentials") {
941 t.Fatalf("/static/auth.js is not the ceremony script:\n%s", body)
941 t.Fatalf("/-/static/auth.js is not the ceremony script:\n%s", body)
942 942 }
943 943 if code, _ := f.get("/auth.js"); code != 404 {
944 944 t.Fatalf("/auth.js = %d, want 404: the script has one URL", code)
945 945 }
946 if _, page := f.get("/login"); !strings.Contains(page, `src="/static/auth.js"`) {
947 t.Fatal("the page shell does not load the passkey script from /static/")
946 if _, page := f.get("/-/login"); !strings.Contains(page, `src="/-/static/auth.js"`) {
947 t.Fatal("the page shell does not load the passkey script from /-/static/")
948 948 }
949 949 }
950 950
@@ -971,11 +971,11 @@ func TestFilterRowPutsFiltersBeforeSearch(t *testing.T) {
971 971 path string
972 972 chips bool
973 973 }{
974 {"/r/" + f.repo + "/issues", true},
975 {"/r/" + f.repo + "/changes", true},
976 {"/r/" + f.repo + "/activity?actor=keenan", true},
977 {"/r/" + f.repo + "/code", false},
978 {"/r/" + f.repo + "/search", false},
974 {"/" + f.repo + "/issues", true},
975 {"/" + f.repo + "/changes", true},
976 {"/" + f.repo + "/activity?actor=keenan", true},
977 {"/" + f.repo + "/code", false},
978 {"/" + f.repo + "/search", false},
979 979 } {
980 980 code, body := f.get(row.path)
981 981 if code != 200 {