diff --git a/cmd/gitmote/main_test.go b/cmd/gitmote/main_test.go
index 32f0e04..524acfe 100644
--- a/cmd/gitmote/main_test.go
+++ b/cmd/gitmote/main_test.go
@@ -15,10 +15,12 @@ import (
"testing"
"time"
+ "github.com/atmin/gitmote/internal/auth"
"github.com/atmin/gitmote/internal/ci"
"github.com/atmin/gitmote/internal/meta"
"github.com/atmin/gitmote/internal/repo"
"github.com/atmin/gitmote/internal/store"
+ "github.com/atmin/gitmote/internal/webui"
"github.com/atmin/s3lite"
)
@@ -95,6 +97,57 @@ func TestGitHandlerMountedAtRoot(t *testing.T) {
}
}
+// TestUIRoutesDoNotShadowGitEndpoints locks the flat-namespace mux seam: the
+// browse verbs (/{repo}/tree/…) share the mux with git's own /{repo}/info/refs.
+// Only enumerated verbs are registered, so git's smart-HTTP suffixes must still
+// fall through to the catch-all while browse verbs and the bare landing reach
+// the UI (docs/architecture/urls.md → Implementation seams).
+func TestUIRoutesDoNotShadowGitEndpoints(t *testing.T) {
+ ctx := context.Background()
+ m, err := meta.Open(ctx, meta.Config{LocalPath: filepath.Join(t.TempDir(), "meta.sqlite3")})
+ if err != nil {
+ t.Fatalf("meta.Open: %v", err)
+ }
+ t.Cleanup(func() { _ = m.Close() })
+ mz := repo.New(m, store.NewMem(), t.TempDir())
+ ui, err := webui.New(m, mz, store.NewMem(), auth.NewGuard(m), nil, []byte("k"), slog.New(slog.DiscardHandler))
+ if err != nil {
+ t.Fatalf("webui.New: %v", err)
+ }
+
+ gitHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusTeapot)
+ })
+ srv := httptest.NewServer(newHandler(gitHandler, ui, nil, nil))
+ defer srv.Close()
+
+ client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
+ return http.ErrUseLastResponse
+ }}
+ get := func(p string) int {
+ resp, err := client.Get(srv.URL + p)
+ if err != nil {
+ t.Fatalf("GET %s: %v", p, err)
+ }
+ resp.Body.Close()
+ return resp.StatusCode
+ }
+
+ // Git's segment-2 suffixes fall through to the git handler.
+ for _, p := range []string{"/some-repo/info/refs?service=git-upload-pack", "/some-repo/HEAD"} {
+ if code := get(p); code != http.StatusTeapot {
+ t.Errorf("GET %s = %d, want 418 (git catch-all)", p, code)
+ }
+ }
+ // Browse verbs and the bare landing reach the UI (unknown repo, anonymous →
+ // login redirect), never the git handler.
+ for _, p := range []string{"/some-repo", "/some-repo/tree/main"} {
+ if code := get(p); code == http.StatusTeapot {
+ t.Errorf("GET %s hit the git handler; want the UI", p)
+ }
+ }
+}
+
func TestLeaderGate(t *testing.T) {
// A follower must not answer metadata-derived requests with a stale snapshot;
// the git/read catch-all is 503'd. Health/version stay up so a rolling deploy
diff --git a/docs/architecture/urls.md b/docs/architecture/urls.md
index 6155de4..52a5d95 100644
--- a/docs/architecture/urls.md
+++ b/docs/architecture/urls.md
@@ -8,9 +8,11 @@ is no per-user namespace to disambiguate, so there is nothing to disambiguate.
> **Status:** partially landed. The **flat single-segment namespace** and
> **access & visibility** (public → anonymous read, repo-read browse, admin-only
> default-branch force-push) are implemented and reconciled into
-> [storage.md](storage.md) and [auth.md](auth.md). Still to land from the
-> `tasks/urls-*` chain: the `tree`/`blob`/`raw` content routing, rendered-markdown
-> link rewriting, and the dashboard UI on the new routes.
+> [storage.md](storage.md) and [auth.md](auth.md); the **`tree`/`blob`/`raw`
+> content routing** (ref-in-path greedy resolution, unified `tree`, self-healing
+> `blob`, the bare `/{repo}` landing) is implemented per the sections below. Still
+> to land from the `tasks/urls-*` chain: rendered-markdown link rewriting, and the
+> dashboard UI on the new routes.
## The namespace
diff --git a/internal/repo/browse.go b/internal/repo/browse.go
index 6d44123..9229e61 100644
--- a/internal/repo/browse.go
+++ b/internal/repo/browse.go
@@ -83,6 +83,33 @@ func Tree(ctx context.Context, dir, sha, treePath string) ([]TreeEntry, error) {
return parseTree(out), nil
}
+// EntryType reports whether p names a "tree" or a "blob" within commit sha,
+// returning ErrNotFound when it names neither. An empty path is the repo root, a
+// tree. It lets the unified browse handler choose the listing vs the file view,
+// and the blob handler redirect a directory to its tree URL.
+func EntryType(ctx context.Context, dir, sha, p string) (string, error) {
+ if p == "" {
+ return "tree", nil
+ }
+ if err := safeRev(sha); err != nil {
+ return "", err
+ }
+ if err := safePath(p); err != nil {
+ return "", err
+ }
+ // Without a trailing slash, ls-tree reports the entry itself (one row), so its
+ // type field distinguishes a directory from a file.
+ out, err := runGitOut(ctx, dir, "ls-tree", "--long", "--end-of-options", sha, "--", strings.TrimSuffix(p, "/"))
+ if err != nil {
+ return "", err
+ }
+ entries := parseTree(out)
+ if len(entries) == 0 {
+ return "", ErrNotFound
+ }
+ return entries[0].Type, nil
+}
+
// parseTree parses `git ls-tree --long` output: "<mode> <type> <sha> <size>\t<name>".
func parseTree(out []byte) []TreeEntry {
var entries []TreeEntry
diff --git a/internal/webui/browse.go b/internal/webui/browse.go
index a8394e1..b2eafe8 100644
--- a/internal/webui/browse.go
+++ b/internal/webui/browse.go
@@ -18,39 +18,156 @@ import (
// if present, becomes the "more" marker rather than being silently dropped.
const logLimit = 100
-// browse dispatches a /browse/{repo}/-/{action}/... request. Repo names contain
-// slashes and Go's trailing wildcard can't carry a suffix, so the path is split
-// manually on the "/-/" marker (GitLab's convention) into the repo name and an
-// action tail; ref travels as a query parameter so a slashed branch needs no
-// disambiguation from the path.
-func (h *Handler) browse(w http.ResponseWriter, r *http.Request) {
- repoName, tail, ok := strings.Cut(r.PathValue("rest"), "/-/")
- if !ok || repoName == "" {
+// browseLanding serves the bare /{repo}: the README + tree at the default
+// branch (an empty ref selects it, an empty path is the repo root).
+func (h *Handler) browseLanding(w http.ResponseWriter, r *http.Request) {
+ repoName := r.PathValue("repo")
+ if !h.authorizeRead(w, r, repoName) {
+ return
+ }
+ c, _, ok := h.resolve(w, r, repoName, "")
+ if !ok {
+ return
+ }
+ h.renderTree(w, r, c, "")
+}
+
+// browseTreeRoute serves /{repo}/tree/<ref>/<path>: the unified content verb. It
+// renders a directory listing for a tree and the file view for a blob, chosen by
+// the entry's type.
+func (h *Handler) browseTreeRoute(w http.ResponseWriter, r *http.Request) {
+ repoName := r.PathValue("repo")
+ if !h.authorizeRead(w, r, repoName) {
+ return
+ }
+ c, p, ok := h.resolve(w, r, repoName, r.PathValue("rest"))
+ if !ok {
+ return
+ }
+ typ, err := repo.EntryType(r.Context(), c.dir, c.sha, p)
+ if errors.Is(err, repo.ErrNotFound) {
http.NotFound(w, r)
return
}
+ if err != nil {
+ h.serverError(w, "entry type", err)
+ return
+ }
+ if typ == "blob" {
+ h.renderBlob(w, r, c, p)
+ return
+ }
+ h.renderTree(w, r, c, p)
+}
+
+// browseBlobRoute serves /{repo}/blob/<ref>/<path>: an explicit file view. A
+// path that is actually a directory 301s to its tree URL (canonicalize, don't
+// 404), so blob self-heals and tree never guesses wrong.
+func (h *Handler) browseBlobRoute(w http.ResponseWriter, r *http.Request) {
+ repoName := r.PathValue("repo")
if !h.authorizeRead(w, r, repoName) {
return
}
- action, arg, _ := strings.Cut(tail, "/")
- switch action {
- case "tree":
- h.browseTree(w, r, repoName, arg)
- case "blob":
- h.browseBlob(w, r, repoName, arg)
- case "raw":
- h.browseRaw(w, r, repoName, arg)
- case "commits":
- h.browseCommits(w, r, repoName)
- case "commit":
- h.browseCommit(w, r, repoName, arg)
- case "runs":
- h.ciRuns(w, r, repoName)
- case "run":
- h.ciRun(w, r, repoName, arg)
- default:
+ c, p, ok := h.resolve(w, r, repoName, r.PathValue("rest"))
+ if !ok {
+ return
+ }
+ typ, err := repo.EntryType(r.Context(), c.dir, c.sha, p)
+ if errors.Is(err, repo.ErrNotFound) {
http.NotFound(w, r)
+ return
+ }
+ if err != nil {
+ h.serverError(w, "entry type", err)
+ return
+ }
+ if typ == "tree" {
+ http.Redirect(w, r, treeURL(c.repo.Name, c.ref, p), http.StatusMovedPermanently)
+ return
+ }
+ h.renderBlob(w, r, c, p)
+}
+
+// browseRawRoute serves /{repo}/raw/<ref>/<path>: the file's bytes. It is
+// file-only — a directory 404s (BlobSize fails on a non-blob).
+func (h *Handler) browseRawRoute(w http.ResponseWriter, r *http.Request) {
+ repoName := r.PathValue("repo")
+ if !h.authorizeRead(w, r, repoName) {
+ return
+ }
+ c, p, ok := h.resolve(w, r, repoName, r.PathValue("rest"))
+ if !ok {
+ return
}
+ h.renderRaw(w, r, c, p)
+}
+
+// browseCommitsRoute serves /{repo}/commits/<ref>/<path>: the history of ref,
+// optionally scoped to a path.
+func (h *Handler) browseCommitsRoute(w http.ResponseWriter, r *http.Request) {
+ repoName := r.PathValue("repo")
+ if !h.authorizeRead(w, r, repoName) {
+ return
+ }
+ c, p, ok := h.resolve(w, r, repoName, r.PathValue("rest"))
+ if !ok {
+ return
+ }
+ h.renderCommits(w, r, c, p)
+}
+
+// browseCommitRoute serves /{repo}/commit/<sha>: one commit's metadata and diff.
+// The URL carries no ref, so the header context resolves the default branch.
+func (h *Handler) browseCommitRoute(w http.ResponseWriter, r *http.Request) {
+ repoName := r.PathValue("repo")
+ if !h.authorizeRead(w, r, repoName) {
+ return
+ }
+ c, _, ok := h.resolve(w, r, repoName, "")
+ if !ok {
+ return
+ }
+ h.renderCommit(w, r, c, r.PathValue("sha"))
+}
+
+// browseRefsRoute serves /{repo}/refs: the repo's branches and tags.
+func (h *Handler) browseRefsRoute(w http.ResponseWriter, r *http.Request) {
+ repoName := r.PathValue("repo")
+ if !h.authorizeRead(w, r, repoName) {
+ return
+ }
+ c, _, ok := h.resolve(w, r, repoName, "")
+ if !ok {
+ return
+ }
+ h.render(w, "browse_refs.html", refsData{
+ browseBase: h.browseHeader(r, c),
+ Refs: h.refChoices(r, c.repo.ID),
+ })
+}
+
+// ciRunsRoute serves /{repo}/runs (the list) and /{repo}/runs/<id>[/job/…] (one
+// run or a job log), sharing the repo-read gate with the other browse verbs.
+func (h *Handler) ciRunsRoute(w http.ResponseWriter, r *http.Request) {
+ repoName := r.PathValue("repo")
+ if !h.authorizeRead(w, r, repoName) {
+ return
+ }
+ if rest := r.PathValue("rest"); rest != "" {
+ h.ciRun(w, r, repoName, rest)
+ return
+ }
+ h.ciRuns(w, r, repoName)
+}
+
+// treeURL builds the canonical tree URL for a ref+path (ref and path may both
+// contain slashes; both live in the path, never a query).
+func treeURL(repoName, ref, p string) string {
+ u := "/" + repoName + "/tree/" + ref
+ if p != "" {
+ u += "/" + p
+ }
+ return u
}
// authorizeRead gates a browse request on repo-read: a public repo is readable
@@ -108,39 +225,93 @@ type browseCtx struct {
sha string
}
-// resolve materializes the repo, picks the ref (query "ref", else the default
-// branch), and resolves it to a commit. It writes a 404 for an unknown repo or
-// ref (and a 500 for anything else) and returns ok=false in those cases.
-func (h *Handler) resolve(w http.ResponseWriter, r *http.Request, repoName string) (browseCtx, bool) {
+// resolve turns a repo name and an in-path "rest" (ref + path, both possibly
+// slashed) into a browseCtx and the remaining path. The ref is resolved greedily
+// against the repo's refs — the longest leading prefix of rest that names a real
+// branch or tag (branch beats a tag on a tie); an empty rest selects the default
+// branch. It writes a 404 for an unknown repo, a rest that names no ref, or a
+// ref that fails to resolve (and a 500 for anything else), returning ok=false.
+func (h *Handler) resolve(w http.ResponseWriter, r *http.Request, repoName, rest string) (browseCtx, string, bool) {
ctx := r.Context()
rp, err := h.md.GetRepo(ctx, repoName)
if errors.Is(err, meta.ErrNotFound) {
http.NotFound(w, r)
- return browseCtx{}, false
+ return browseCtx{}, "", false
}
if err != nil {
h.serverError(w, "get repo", err)
- return browseCtx{}, false
+ return browseCtx{}, "", false
}
- ref := strings.TrimSpace(r.URL.Query().Get("ref"))
- if ref == "" {
- ref = rp.DefaultBranch
+ refs, err := h.md.ListRefs(ctx, rp.ID)
+ if err != nil {
+ h.serverError(w, "list refs", err)
+ return browseCtx{}, "", false
+ }
+ ref, treePath, branch, ok := greedyRef(refs, rest, rp.DefaultBranch)
+ if !ok {
+ http.NotFound(w, r) // rest names no ref
+ return browseCtx{}, "", false
}
dir, err := h.mz.Materialize(ctx, repoName)
if err != nil {
h.serverError(w, "materialize repo", err)
- return browseCtx{}, false
+ return browseCtx{}, "", false
}
- sha, err := repo.ResolveRef(ctx, dir, ref)
+ // Resolve the fully-qualified ref so a branch beats a same-named tag (git's
+ // bare-name precedence favors tags) and an annotated tag peels to its commit.
+ sha, err := repo.ResolveRef(ctx, dir, qualify(ref, branch))
if errors.Is(err, repo.ErrNotFound) {
http.NotFound(w, r)
- return browseCtx{}, false
+ return browseCtx{}, "", false
}
if err != nil {
h.serverError(w, "resolve ref", err)
- return browseCtx{}, false
+ return browseCtx{}, "", false
}
- return browseCtx{repo: rp, dir: dir, ref: ref, sha: sha}, true
+ return browseCtx{repo: rp, dir: dir, ref: ref, sha: sha}, treePath, true
+}
+
+// greedyRef splits rest into a ref name and the remaining path: the longest
+// leading prefix of rest that names a real branch or tag. A branch beats a tag
+// on the same prefix; a longer prefix beats a shorter one regardless of type. An
+// empty rest selects defaultBranch with an empty path. branch reports whether
+// the chosen ref is a branch (to qualify it for resolution); ok is false when no
+// prefix names a ref.
+func greedyRef(refs []meta.Ref, rest, defaultBranch string) (name, treePath string, branch, ok bool) {
+ branches := make(map[string]bool)
+ tags := make(map[string]bool)
+ for _, rf := range refs {
+ if n, found := strings.CutPrefix(rf.Name, "refs/heads/"); found {
+ branches[n] = true
+ } else if n, found := strings.CutPrefix(rf.Name, "refs/tags/"); found {
+ tags[n] = true
+ }
+ }
+ rest = strings.Trim(rest, "/")
+ if rest == "" {
+ return defaultBranch, "", true, true
+ }
+ segs := strings.Split(rest, "/")
+ for i := len(segs); i > 0; i-- {
+ cand := strings.Join(segs[:i], "/")
+ tail := strings.Join(segs[i:], "/")
+ if branches[cand] {
+ return cand, tail, true, true
+ }
+ if tags[cand] {
+ return cand, tail, false, true
+ }
+ }
+ return "", "", false, false
+}
+
+// qualify makes a ref name unambiguous for resolution: refs/heads/<name> for a
+// branch, refs/tags/<name> for a tag.
+func qualify(name string, branch bool) string {
+ if branch {
+ return "refs/heads/" + name
+ }
+ return "refs/tags/" + name
}
// base fills the shared browse header (nav identity, repo, selected ref, and the
@@ -177,11 +348,7 @@ func (h *Handler) refChoices(r *http.Request, repoID int64) []refChoice {
return choices
}
-func (h *Handler) browseTree(w http.ResponseWriter, r *http.Request, repoName, treePath string) {
- c, ok := h.resolve(w, r, repoName)
- if !ok {
- return
- }
+func (h *Handler) renderTree(w http.ResponseWriter, r *http.Request, c browseCtx, treePath string) {
entries, err := repo.Tree(r.Context(), c.dir, c.sha, treePath)
if errors.Is(err, repo.ErrNotFound) {
http.NotFound(w, r)
@@ -203,11 +370,7 @@ func (h *Handler) browseTree(w http.ResponseWriter, r *http.Request, repoName, t
h.render(w, "browse_tree.html", data)
}
-func (h *Handler) browseBlob(w http.ResponseWriter, r *http.Request, repoName, blobPath string) {
- c, ok := h.resolve(w, r, repoName)
- if !ok {
- return
- }
+func (h *Handler) renderBlob(w http.ResponseWriter, r *http.Request, c browseCtx, blobPath string) {
content, size, binary, err := repo.Blob(r.Context(), c.dir, c.sha, blobPath)
if errors.Is(err, repo.ErrNotFound) {
http.NotFound(w, r)
@@ -244,11 +407,7 @@ func (h *Handler) browseBlob(w http.ResponseWriter, r *http.Request, repoName, b
h.render(w, "browse_blob.html", data)
}
-func (h *Handler) browseRaw(w http.ResponseWriter, r *http.Request, repoName, blobPath string) {
- c, ok := h.resolve(w, r, repoName)
- if !ok {
- return
- }
+func (h *Handler) renderRaw(w http.ResponseWriter, r *http.Request, c browseCtx, blobPath string) {
// Confirm the blob exists (404 before any bytes are written), then stream
// it — never buffering the whole object.
if _, err := repo.BlobSize(r.Context(), c.dir, c.sha, blobPath); errors.Is(err, repo.ErrNotFound) {
@@ -273,16 +432,11 @@ func (h *Handler) browseRaw(w http.ResponseWriter, r *http.Request, repoName, bl
w.Header().Set("Content-Disposition", "attachment; filename=\""+path.Base(blobPath)+"\"")
w.Header().Set("X-Content-Type-Options", "nosniff")
if _, err := io.Copy(w, rc); err != nil {
- h.log.Error("stream blob", "repo", repoName, "path", blobPath, "error", err)
+ h.log.Error("stream blob", "repo", c.repo.Name, "path", blobPath, "error", err)
}
}
-func (h *Handler) browseCommits(w http.ResponseWriter, r *http.Request, repoName string) {
- c, ok := h.resolve(w, r, repoName)
- if !ok {
- return
- }
- scope := strings.TrimSpace(r.URL.Query().Get("path"))
+func (h *Handler) renderCommits(w http.ResponseWriter, r *http.Request, c browseCtx, scope string) {
commits, more, err := repo.Log(r.Context(), c.dir, c.sha, scope, logLimit)
if errors.Is(err, repo.ErrNotFound) {
http.NotFound(w, r)
@@ -301,11 +455,7 @@ func (h *Handler) browseCommits(w http.ResponseWriter, r *http.Request, repoName
})
}
-func (h *Handler) browseCommit(w http.ResponseWriter, r *http.Request, repoName, sha string) {
- c, ok := h.resolve(w, r, repoName)
- if !ok {
- return
- }
+func (h *Handler) renderCommit(w http.ResponseWriter, r *http.Request, c browseCtx, sha string) {
commit, diff, err := repo.Show(r.Context(), c.dir, sha)
if errors.Is(err, repo.ErrNotFound) {
http.NotFound(w, r)
@@ -321,7 +471,7 @@ func (h *Handler) browseCommit(w http.ResponseWriter, r *http.Request, repoName,
if got, err := h.md.LatestRunForSHA(r.Context(), c.repo.ID, commit.SHA); err == nil {
run = got
} else if !errors.Is(err, meta.ErrNotFound) {
- h.log.Error("latest run for sha", "repo", repoName, "sha", commit.SHA, "error", err)
+ h.log.Error("latest run for sha", "repo", c.repo.Name, "sha", commit.SHA, "error", err)
}
h.render(w, "browse_commit.html", commitData{
browseBase: h.browseHeader(r, c),
diff --git a/internal/webui/browse_test.go b/internal/webui/browse_test.go
index 738e8c0..810aa3e 100644
--- a/internal/webui/browse_test.go
+++ b/internal/webui/browse_test.go
@@ -92,6 +92,60 @@ func (x *harness) seedBrowseRepo(name, branch string) (head, first string) {
return head, first
}
+// TestBrowseBlobOnDirRedirects: blob on a directory 301s to the tree URL,
+// canonicalizing rather than 404ing (the self-healing verb).
+func TestBrowseBlobOnDirRedirects(t *testing.T) {
+ x := newHarness(t)
+ x.seedBrowseRepo("app", "main")
+ session := x.login(x.mintTokenFor(x.admin.ID))
+
+ rec := x.do(http.MethodGet, "/app/blob/main/sub", nil, session)
+ if rec.Code != http.StatusMovedPermanently {
+ t.Fatalf("blob-on-dir = %d, want 301 (%s)", rec.Code, rec.Body)
+ }
+ if loc := rec.Header().Get("Location"); loc != "/app/tree/main/sub" {
+ t.Fatalf("blob-on-dir redirected to %q, want /app/tree/main/sub", loc)
+ }
+}
+
+// TestBrowseGreedyRef covers ref-in-path resolution: a slashed branch, and a
+// branch that beats a same-named tag on a tie.
+func TestBrowseGreedyRef(t *testing.T) {
+ x := newHarness(t)
+ head, first := x.seedBrowseRepo("app", "main")
+ session := x.login(x.mintTokenFor(x.admin.ID))
+ ctx := context.Background()
+ r, _ := x.md.GetRepo(ctx, "app")
+
+ // A slashed branch, plus a branch and a tag sharing the name "rel" (branch at
+ // head, tag at the first commit) to exercise the branch-over-tag tie-break.
+ if err := x.md.CASRef(ctx, r.ID, "refs/heads/feature/x", meta.ZeroSHA, head); err != nil {
+ t.Fatalf("CASRef feature/x: %v", err)
+ }
+ if err := x.md.CASRef(ctx, r.ID, "refs/heads/rel", meta.ZeroSHA, head); err != nil {
+ t.Fatalf("CASRef branch rel: %v", err)
+ }
+ if err := x.md.CASRef(ctx, r.ID, "refs/tags/rel", meta.ZeroSHA, first); err != nil {
+ t.Fatalf("CASRef tag rel: %v", err)
+ }
+
+ // Slashed branch: ref "feature/x", path "README.md" → the rendered README.
+ rec := x.do(http.MethodGet, "/app/tree/feature/x/README.md", nil, session)
+ if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "<h1") {
+ t.Fatalf("slashed-branch blob = %d (%s)", rec.Code, rec.Body)
+ }
+
+ // Tie-break: "rel" resolves to the branch (head, which has println) not the
+ // tag (first, which does not).
+ rec = x.do(http.MethodGet, "/app/blob/rel/hello.go", nil, session)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("tie-break blob = %d (%s)", rec.Code, rec.Body)
+ }
+ if !strings.Contains(rec.Body.String(), "println") {
+ t.Fatalf("tie-break resolved the tag, not the branch:\n%s", rec.Body)
+ }
+}
+
func write(t *testing.T, dir, rel, content string) {
t.Helper()
full := filepath.Join(dir, rel)
@@ -126,7 +180,7 @@ func TestBrowseAccessControl(t *testing.T) {
stranger, _ := x.md.CreateUser(ctx, "stranger")
strangerSession := x.sessionFor(stranger.ID)
- const target = "/browse/app/-/tree/"
+ const target = "/app/tree/main"
if rec := x.do(http.MethodGet, target, nil, adminSession); rec.Code != http.StatusOK {
t.Errorf("admin browse private = %d, want 200", rec.Code)
@@ -155,27 +209,27 @@ func TestBrowseTreeAndBlob(t *testing.T) {
x.seedBrowseRepo("app", "main")
session := x.login(x.mintTokenFor(x.admin.ID))
- // Root tree lists the top-level entries.
- rec := x.do(http.MethodGet, "/browse/app/-/tree/", nil, session)
+ // The bare repo landing lists the top-level entries at the default branch.
+ rec := x.do(http.MethodGet, "/app", nil, session)
if rec.Code != http.StatusOK {
- t.Fatalf("tree = %d (%s)", rec.Code, rec.Body)
+ t.Fatalf("landing = %d (%s)", rec.Code, rec.Body)
}
body := rec.Body.String()
for _, want := range []string{"hello.go", "sub", "bin.dat"} {
if !strings.Contains(body, want) {
- t.Fatalf("tree body missing %q:\n%s", want, body)
+ t.Fatalf("landing body missing %q:\n%s", want, body)
}
}
// Subdirectory tree.
- rec = x.do(http.MethodGet, "/browse/app/-/tree/sub?ref=main", nil, session)
+ rec = x.do(http.MethodGet, "/app/tree/main/sub", nil, session)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "note.txt") {
t.Fatalf("sub tree = %d (%s)", rec.Code, rec.Body)
}
// Text blob renders its content (highlighting splits tokens into spans, so
// assert on a single token that survives).
- rec = x.do(http.MethodGet, "/browse/app/-/blob/hello.go?ref=main", nil, session)
+ rec = x.do(http.MethodGet, "/app/blob/main/hello.go", nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("blob = %d (%s)", rec.Code, rec.Body)
}
@@ -184,7 +238,7 @@ func TestBrowseTreeAndBlob(t *testing.T) {
}
// Binary blob shows the download affordance, not garbage.
- rec = x.do(http.MethodGet, "/browse/app/-/blob/bin.dat?ref=main", nil, session)
+ rec = x.do(http.MethodGet, "/app/blob/main/bin.dat", nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("binary blob = %d", rec.Code)
}
@@ -198,7 +252,7 @@ func TestBrowseRawDownload(t *testing.T) {
x.seedBrowseRepo("app", "main")
session := x.login(x.mintTokenFor(x.admin.ID))
- rec := x.do(http.MethodGet, "/browse/app/-/raw/sub/note.txt?ref=main", nil, session)
+ rec := x.do(http.MethodGet, "/app/raw/main/sub/note.txt", nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("raw = %d (%s)", rec.Code, rec.Body)
}
@@ -215,7 +269,7 @@ func TestBrowseRefSwitcher(t *testing.T) {
x.seedBrowseRepo("app", "main")
session := x.login(x.mintTokenFor(x.admin.ID))
- rec := x.do(http.MethodGet, "/browse/app/-/tree/", nil, session)
+ rec := x.do(http.MethodGet, "/app/tree/main", nil, session)
body := rec.Body.String()
// The switcher lists both the branch and the tag from meta.ListRefs.
if !strings.Contains(body, `value="main"`) || !strings.Contains(body, `value="v1"`) {
@@ -228,7 +282,7 @@ func TestBrowseCommitsAndCommit(t *testing.T) {
head, _ := x.seedBrowseRepo("app", "main")
session := x.login(x.mintTokenFor(x.admin.ID))
- rec := x.do(http.MethodGet, "/browse/app/-/commits?ref=main", nil, session)
+ rec := x.do(http.MethodGet, "/app/commits/main", nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("commits = %d (%s)", rec.Code, rec.Body)
}
@@ -237,7 +291,7 @@ func TestBrowseCommitsAndCommit(t *testing.T) {
t.Fatalf("commits body missing subjects:\n%s", body)
}
- rec = x.do(http.MethodGet, "/browse/app/-/commit/"+head, nil, session)
+ rec = x.do(http.MethodGet, "/app/commit/"+head, nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("commit = %d (%s)", rec.Code, rec.Body)
}
@@ -252,7 +306,7 @@ func TestBrowseHighlightAndMarkdown(t *testing.T) {
session := x.login(x.mintTokenFor(x.admin.ID))
// A .go blob is syntax-highlighted: chroma class spans, not a bare <pre>.
- rec := x.do(http.MethodGet, "/browse/app/-/blob/hello.go?ref=main", nil, session)
+ rec := x.do(http.MethodGet, "/app/blob/main/hello.go", nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("go blob = %d (%s)", rec.Code, rec.Body)
}
@@ -261,13 +315,13 @@ func TestBrowseHighlightAndMarkdown(t *testing.T) {
}
// A .md blob renders markdown in place of highlighted source.
- rec = x.do(http.MethodGet, "/browse/app/-/blob/README.md?ref=main", nil, session)
+ rec = x.do(http.MethodGet, "/app/blob/main/README.md", nil, session)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "<h1") {
t.Fatalf("md blob not rendered:\n%s", rec.Body)
}
// The tree page renders the directory's README below the listing.
- rec = x.do(http.MethodGet, "/browse/app/-/tree/", nil, session)
+ rec = x.do(http.MethodGet, "/app/tree/main", nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("tree = %d", rec.Code)
}
@@ -285,7 +339,7 @@ func TestBrowseMermaid(t *testing.T) {
// A markdown blob with a mermaid fence renders the diagram container AND pulls
// in the mermaid script.
- rec := x.do(http.MethodGet, "/browse/app/-/blob/diagram.md?ref=main", nil, session)
+ rec := x.do(http.MethodGet, "/app/blob/main/diagram.md", nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("diagram blob = %d (%s)", rec.Code, rec.Body)
}
@@ -297,7 +351,7 @@ func TestBrowseMermaid(t *testing.T) {
}
// A markdown blob without a diagram must NOT pull in the script (conditional).
- rec = x.do(http.MethodGet, "/browse/app/-/blob/README.md?ref=main", nil, session)
+ rec = x.do(http.MethodGet, "/app/blob/main/README.md", nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("readme blob = %d", rec.Code)
}
@@ -324,7 +378,7 @@ func TestBrowseBlobSizeGuard(t *testing.T) {
session := x.login(x.mintTokenFor(x.admin.ID))
// A blob over the cap falls back to a plain <pre>, no chroma markup.
- rec := x.do(http.MethodGet, "/browse/app/-/blob/big.go?ref=main", nil, session)
+ rec := x.do(http.MethodGet, "/app/blob/main/big.go", nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("big blob = %d", rec.Code)
}
@@ -344,12 +398,12 @@ func TestBrowseNotFoundAndTraversal(t *testing.T) {
cases := []struct {
name, target string
}{
- {"unknown repo", "/browse/ghost/-/tree/?ref=main"},
- {"unknown ref", "/browse/app/-/tree/?ref=nope"},
- // The path arrives as an uncleaned query param, so it reaches the
- // reader's safePath guard rather than being normalized by the mux.
- {"path traversal", "/browse/app/-/commits?ref=main&path=../etc/passwd"},
- {"missing blob", "/browse/app/-/blob/nope.txt?ref=main"},
+ {"unknown repo", "/ghost/tree/main"},
+ // No leading prefix names a ref, so greedy resolution finds none → 404.
+ {"unknown ref", "/app/tree/nope"},
+ {"unknown ref with path", "/app/tree/nope/sub"},
+ {"missing blob", "/app/blob/main/nope.txt"},
+ {"missing tree path", "/app/tree/main/nope"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
diff --git a/internal/webui/ci_test.go b/internal/webui/ci_test.go
index 12f74b1..cb68975 100644
--- a/internal/webui/ci_test.go
+++ b/internal/webui/ci_test.go
@@ -51,22 +51,22 @@ func TestCIRunsListAndDetail(t *testing.T) {
_, runID, jobID := x.seedRun("app", "deadbeefcafe1234", "ok\n")
// Runs list links to the run and shows the short SHA.
- rec := x.do(http.MethodGet, "/browse/app/-/runs", nil, session)
+ rec := x.do(http.MethodGet, "/app/runs", nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("runs list: %d (%s)", rec.Code, rec.Body)
}
body := rec.Body.String()
- if !strings.Contains(body, "/browse/app/-/run/"+itoa(runID)) || !strings.Contains(body, "deadbeef") {
+ if !strings.Contains(body, "/app/runs/"+itoa(runID)) || !strings.Contains(body, "deadbeef") {
t.Errorf("runs list missing run link or short sha: %s", body)
}
// Run detail shows the job and links to its log.
- rec = x.do(http.MethodGet, "/browse/app/-/run/"+itoa(runID), nil, session)
+ rec = x.do(http.MethodGet, "/app/runs/"+itoa(runID), nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("run detail: %d (%s)", rec.Code, rec.Body)
}
if !strings.Contains(rec.Body.String(), "ci.yml") ||
- !strings.Contains(rec.Body.String(), "/run/"+itoa(runID)+"/job/"+itoa(jobID)+"/log") {
+ !strings.Contains(rec.Body.String(), "/runs/"+itoa(runID)+"/job/"+itoa(jobID)+"/log") {
t.Errorf("run detail missing job or log link: %s", rec.Body)
}
}
@@ -77,7 +77,7 @@ func TestCIJobLogRendersSafely(t *testing.T) {
// Green "ok" via ANSI, then HTML that must be escaped.
_, runID, jobID := x.seedRun("app", "sha1", "\x1b[32mok\x1b[0m <b>x</b>\n")
- rec := x.do(http.MethodGet, "/browse/app/-/run/"+itoa(runID)+"/job/"+itoa(jobID)+"/log", nil, session)
+ rec := x.do(http.MethodGet, "/app/runs/"+itoa(runID)+"/job/"+itoa(jobID)+"/log", nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("log view: %d (%s)", rec.Code, rec.Body)
}
@@ -104,7 +104,7 @@ func TestCIRunNotFound(t *testing.T) {
t.Fatal(err)
}
// Unknown run id → 404.
- if rec := x.do(http.MethodGet, "/browse/app/-/run/9999", nil, session); rec.Code != http.StatusNotFound {
+ if rec := x.do(http.MethodGet, "/app/runs/9999", nil, session); rec.Code != http.StatusNotFound {
t.Errorf("unknown run = %d, want 404", rec.Code)
}
}
@@ -121,7 +121,7 @@ func TestCIRunCrossRepoIsNotFound(t *testing.T) {
t.Fatal(err)
}
// A run that belongs to app must not be reachable under other.
- rec := x.do(http.MethodGet, "/browse/other/-/run/"+itoa(runID), nil, session)
+ rec := x.do(http.MethodGet, "/other/runs/"+itoa(runID), nil, session)
if rec.Code != http.StatusNotFound {
t.Errorf("cross-repo run = %d, want 404", rec.Code)
}
@@ -129,8 +129,8 @@ func TestCIRunCrossRepoIsNotFound(t *testing.T) {
func TestCIRunsRequireAdmin(t *testing.T) {
x := newHarness(t)
- // Unauthenticated GET is redirected to login like the rest of /browse.
- if rec := x.do(http.MethodGet, "/browse/app/-/runs", nil, nil); rec.Code != http.StatusSeeOther {
+ // Unauthenticated GET is redirected to login like the rest of browsing.
+ if rec := x.do(http.MethodGet, "/app/runs", nil, nil); rec.Code != http.StatusSeeOther {
t.Errorf("unauth runs = %d, want 303 redirect", rec.Code)
}
}
@@ -147,17 +147,17 @@ func TestCICommitBadge(t *testing.T) {
}
// The commit with a run shows a badge linking to it.
- rec := x.do(http.MethodGet, "/browse/repo/-/commit/"+head, nil, session)
+ rec := x.do(http.MethodGet, "/repo/commit/"+head, nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("commit page: %d (%s)", rec.Code, rec.Body)
}
- if !strings.Contains(rec.Body.String(), "/browse/repo/-/run/"+itoa(run.ID)) {
+ if !strings.Contains(rec.Body.String(), "/repo/runs/"+itoa(run.ID)) {
t.Errorf("commit with a run is missing the CI badge: %s", rec.Body)
}
// A commit without a run shows no badge.
- rec = x.do(http.MethodGet, "/browse/repo/-/commit/"+first, nil, session)
- if strings.Contains(rec.Body.String(), "/-/run/") {
+ rec = x.do(http.MethodGet, "/repo/commit/"+first, nil, session)
+ if strings.Contains(rec.Body.String(), "/repo/runs/") {
t.Errorf("commit without a run should have no badge: %s", rec.Body)
}
}
diff --git a/internal/webui/data.go b/internal/webui/data.go
index de6b89e..2e8b155 100644
--- a/internal/webui/data.go
+++ b/internal/webui/data.go
@@ -57,7 +57,7 @@ type secretsData struct {
// --- browse ---
// refChoice is one entry in the ref switcher: a display name (short, e.g.
-// "main") and the value to pass as ?ref=.
+// "main") and the value the switcher puts in the tree URL's ref segment.
type refChoice struct {
Name string
Ref string
@@ -73,8 +73,8 @@ type crumb struct {
// and the switcher's options, on top of the shared nav/flash base.
type browseBase struct {
base
- Repo string // full repo name (may contain slashes)
- Ref string // selected ref (query value), defaults to the repo's default branch
+ Repo string // repo name (a single path segment)
+ Ref string // selected ref (in-path), defaults to the repo's default branch
Refs []refChoice // branches and tags for the switcher
}
@@ -97,6 +97,13 @@ type blobData struct {
Size int64
}
+type refsData struct {
+ browseBase
+ Path string // always empty; present so the shared browse_head renders
+ Crumbs []crumb // always nil; present so the shared browse_head renders
+ Refs []refChoice // the repo's branches and tags
+}
+
type commitsData struct {
browseBase
Path string // path scope, if any
diff --git a/internal/webui/templates/browse_blob.html b/internal/webui/templates/browse_blob.html
index e89b5c7..ae989aa 100644
--- a/internal/webui/templates/browse_blob.html
+++ b/internal/webui/templates/browse_blob.html
@@ -1,8 +1,8 @@
{{define "browse_blob.html"}}{{template "top" .}}
{{template "browse_head" .}}
-<p>{{.Size}} bytes · <a href="/browse/{{.Repo}}/-/raw/{{.Path}}?ref={{.Ref}}">raw</a></p>
+<p>{{.Size}} bytes · <a href="/{{.Repo}}/raw/{{.Ref}}/{{.Path}}">raw</a></p>
{{if .Binary}}
-<p>Binary file — <a href="/browse/{{.Repo}}/-/raw/{{.Path}}?ref={{.Ref}}">download</a>.</p>
+<p>Binary file — <a href="/{{.Repo}}/raw/{{.Ref}}/{{.Path}}">download</a>.</p>
{{else if .Rendered}}
<div class="markdown-body">{{.Rendered}}</div>
{{else if .Highlighted}}
diff --git a/internal/webui/templates/browse_commit.html b/internal/webui/templates/browse_commit.html
index 3954d6d..71433c3 100644
--- a/internal/webui/templates/browse_commit.html
+++ b/internal/webui/templates/browse_commit.html
@@ -4,7 +4,7 @@
<p>
<code>{{.Commit.SHA}}</code><br>
{{.Commit.Author}} <{{.Commit.Email}}> · {{.Commit.When.Format "2006-01-02 15:04"}}
- {{if .Run}}· CI <a href="/browse/{{.Repo}}/-/run/{{.Run.ID}}" style="color:{{statusColor .Run.Status}}">{{.Run.Status}}</a>{{end}}
+ {{if .Run}}· CI <a href="/{{.Repo}}/runs/{{.Run.ID}}" style="color:{{statusColor .Run.Status}}">{{.Run.Status}}</a>{{end}}
</p>
{{if .Commit.Body}}<pre>{{.Commit.Body}}</pre>{{end}}
<pre>{{.Diff}}</pre>
diff --git a/internal/webui/templates/browse_commits.html b/internal/webui/templates/browse_commits.html
index ea903f3..bc51418 100644
--- a/internal/webui/templates/browse_commits.html
+++ b/internal/webui/templates/browse_commits.html
@@ -6,7 +6,7 @@
<tbody>
{{range .Commits}}
<tr>
- <td><a href="/browse/{{$.Repo}}/-/commit/{{.SHA}}?ref={{$.Ref}}"><code>{{slice .SHA 0 10}}</code></a></td>
+ <td><a href="/{{$.Repo}}/commit/{{.SHA}}"><code>{{slice .SHA 0 10}}</code></a></td>
<td>{{.Subject}}</td>
<td>{{.Author}}</td>
<td>{{.When.Format "2006-01-02 15:04"}}</td>
diff --git a/internal/webui/templates/browse_head.html b/internal/webui/templates/browse_head.html
index 15c7f61..878b953 100644
--- a/internal/webui/templates/browse_head.html
+++ b/internal/webui/templates/browse_head.html
@@ -1,19 +1,20 @@
{{define "browse_head"}}
<h1>{{.Repo}}</h1>
<p>
- <a href="/browse/{{.Repo}}/-/tree/?ref={{.Ref}}">{{.Repo}}</a>
- {{range .Crumbs}} / <a href="/browse/{{$.Repo}}/-/tree/{{.Path}}?ref={{$.Ref}}">{{.Name}}</a>{{end}}
+ <a href="/{{.Repo}}/tree/{{.Ref}}">{{.Repo}}</a>
+ {{range .Crumbs}} / <a href="/{{$.Repo}}/tree/{{$.Ref}}/{{.Path}}">{{.Name}}</a>{{end}}
·
- <a href="/browse/{{.Repo}}/-/commits?ref={{.Ref}}{{if .Path}}&path={{.Path}}{{end}}">history</a>
+ <a href="/{{.Repo}}/commits/{{.Ref}}{{if .Path}}/{{.Path}}{{end}}">history</a>
+ ·
+ <a href="/{{.Repo}}/refs">branches</a>
</p>
{{if .Refs}}
-<form class="inline" method="get" action="/browse/{{.Repo}}/-/tree/{{.Path}}">
+<form class="inline">
<label>Ref
- <select name="ref" onchange="this.form.submit()">
+ <select onchange="location.href='/{{.Repo}}/tree/'+this.value{{if .Path}}+'/{{.Path}}'{{end}}">
{{range .Refs}}<option value="{{.Ref}}"{{if eq .Ref $.Ref}} selected{{end}}>{{.Name}}</option>{{end}}
</select>
</label>
- <noscript><button type="submit">Go</button></noscript>
</form>
{{end}}
{{end}}
diff --git a/internal/webui/templates/browse_refs.html b/internal/webui/templates/browse_refs.html
new file mode 100644
index 0000000..c62870e
--- /dev/null
+++ b/internal/webui/templates/browse_refs.html
@@ -0,0 +1,16 @@
+{{define "browse_refs.html"}}{{template "top" .}}
+{{template "browse_head" .}}
+<h1>Branches & tags</h1>
+{{if .Refs}}
+<table>
+ <thead><tr><th>Ref</th></tr></thead>
+ <tbody>
+ {{range .Refs}}
+ <tr><td><a href="/{{$.Repo}}/tree/{{.Ref}}">{{.Name}}</a></td></tr>
+ {{end}}
+ </tbody>
+</table>
+{{else}}
+<p>No branches or tags yet.</p>
+{{end}}
+{{template "bottom" .}}{{end}}
diff --git a/internal/webui/templates/browse_tree.html b/internal/webui/templates/browse_tree.html
index a512b98..c57f1f8 100644
--- a/internal/webui/templates/browse_tree.html
+++ b/internal/webui/templates/browse_tree.html
@@ -4,15 +4,15 @@
<thead><tr><th>Name</th><th>Mode</th><th>Size</th></tr></thead>
<tbody>
{{if .Path}}
- <tr><td colspan="3"><a href="/browse/{{.Repo}}/-/tree/{{parentPath .Path}}?ref={{.Ref}}">..</a></td></tr>
+ <tr><td colspan="3"><a href="/{{.Repo}}/tree/{{.Ref}}/{{parentPath .Path}}">..</a></td></tr>
{{end}}
{{range .Entries}}
<tr>
{{if eq .Type "tree"}}
- <td>📁 <a href="/browse/{{$.Repo}}/-/tree/{{.Path}}?ref={{$.Ref}}">{{.Name}}/</a></td>
+ <td>📁 <a href="/{{$.Repo}}/tree/{{$.Ref}}/{{.Path}}">{{.Name}}/</a></td>
<td>{{.Mode}}</td><td></td>
{{else if eq .Type "blob"}}
- <td>📄 <a href="/browse/{{$.Repo}}/-/blob/{{.Path}}?ref={{$.Ref}}">{{.Name}}</a></td>
+ <td>📄 <a href="/{{$.Repo}}/blob/{{$.Ref}}/{{.Path}}">{{.Name}}</a></td>
<td>{{.Mode}}</td><td>{{.Size}}</td>
{{else}}
<td>🔗 {{.Name}}</td><td>{{.Mode}}</td><td></td>
diff --git a/internal/webui/templates/ci_log.html b/internal/webui/templates/ci_log.html
index 4cf7698..96d9a02 100644
--- a/internal/webui/templates/ci_log.html
+++ b/internal/webui/templates/ci_log.html
@@ -2,7 +2,7 @@
<h1>{{.Job.Name}} — run #{{.Run.ID}}</h1>
<p>
<span style="color:{{statusColor .Job.Status}}">{{.Job.Status}}</span>
- · <a href="/browse/{{.Repo}}/-/run/{{.Run.ID}}">run</a>
+ · <a href="/{{.Repo}}/runs/{{.Run.ID}}">run</a>
· {{.Repo}}
</p>
diff --git a/internal/webui/templates/ci_run.html b/internal/webui/templates/ci_run.html
index 92ee8b9..6c308d5 100644
--- a/internal/webui/templates/ci_run.html
+++ b/internal/webui/templates/ci_run.html
@@ -3,8 +3,8 @@
<p>
<span style="color:{{statusColor .Run.Status}}">{{.Run.Status}}</span>
· {{.Run.Ref}} · <code>{{short .Run.SHA}}</code>
- · <a href="/browse/{{.Repo}}/-/commit/{{.Run.SHA}}">commit</a>
- · <a href="/browse/{{.Repo}}/-/runs">all runs</a>
+ · <a href="/{{.Repo}}/commit/{{.Run.SHA}}">commit</a>
+ · <a href="/{{.Repo}}/runs">all runs</a>
</p>
{{if .Jobs}}
@@ -15,7 +15,7 @@
<tr>
<td>{{.Name}}</td>
<td><span style="color:{{statusColor .Status}}">{{.Status}}</span></td>
- <td><a href="/browse/{{$.Repo}}/-/run/{{$.Run.ID}}/job/{{.ID}}/log">log</a></td>
+ <td><a href="/{{$.Repo}}/runs/{{$.Run.ID}}/job/{{.ID}}/log">log</a></td>
</tr>
{{end}}
</tbody>
diff --git a/internal/webui/templates/ci_runs.html b/internal/webui/templates/ci_runs.html
index c35c3ff..ffbb512 100644
--- a/internal/webui/templates/ci_runs.html
+++ b/internal/webui/templates/ci_runs.html
@@ -1,6 +1,6 @@
{{define "ci_runs.html"}}{{template "top" .}}
<h1>CI runs — {{.Repo}}</h1>
-<p><a href="/browse/{{.Repo}}/-/tree/">files</a> · <a href="/browse/{{.Repo}}/-/commits">history</a></p>
+<p><a href="/{{.Repo}}">files</a> · <a href="/{{.Repo}}/commits">history</a></p>
{{if .Runs}}
<table>
@@ -8,7 +8,7 @@
<tbody>
{{range .Runs}}
<tr>
- <td><a href="/browse/{{$.Repo}}/-/run/{{.ID}}">#{{.ID}}</a></td>
+ <td><a href="/{{$.Repo}}/runs/{{.ID}}">#{{.ID}}</a></td>
<td><span style="color:{{statusColor .Status}}">{{.Status}}</span></td>
<td>{{.Ref}}</td>
<td><code>{{short .SHA}}</code></td>
diff --git a/internal/webui/templates/repos.html b/internal/webui/templates/repos.html
index 25d80b0..4ce547f 100644
--- a/internal/webui/templates/repos.html
+++ b/internal/webui/templates/repos.html
@@ -7,7 +7,7 @@
<tbody>
{{range .Repos}}
<tr>
- <td><a href="/browse/{{.Name}}/-/tree/">{{.Name}}</a> · <a href="/browse/{{.Name}}/-/runs">CI</a></td>
+ <td><a href="/{{.Name}}">{{.Name}}</a> · <a href="/{{.Name}}/runs">CI</a></td>
<td>{{.DefaultBranch}}</td>
<td>
<form class="inline" method="post" action="/ui/repos/default-branch">
diff --git a/internal/webui/webui.go b/internal/webui/webui.go
index 05c2f7b..dc01cc9 100644
--- a/internal/webui/webui.go
+++ b/internal/webui/webui.go
@@ -165,10 +165,31 @@ func (h *Handler) Register(mux *http.ServeMux) {
// Read-only browsing. Gated on repo-read (public → anyone, private → an ACL),
// not on admin — this is what makes spectators and public repos usable; the
- // per-request check lives in browse via authorizeRead. The action tail is
- // split manually on "/-/" (see browse), so one subtree handler covers all of
- // tree/blob/raw/commits/commit.
- mux.HandleFunc("GET /browse/{rest...}", h.browse)
+ // per-request check lives in each handler via authorizeRead. The repo is a
+ // single path segment and the ref lives in the path (greedily resolved), so
+ // each content verb is its own enumerated route. Only these fixed verbs are
+ // registered — never a broad /{repo}/{rest...} — so git's own /{repo}/info/refs,
+ // /{repo}/git-upload-pack, … fall through to the smart-HTTP catch-all at "/"
+ // (docs/architecture/urls.md → Implementation seams).
+ // Each verb registers a bare form and a "/{rest...}" form: a "/{rest...}"
+ // pattern is a subtree, so ServeMux would otherwise 307-redirect the bare
+ // "/{repo}/runs" to "/{repo}/runs/". The bare form (empty rest) selects the
+ // default branch, or the list for runs.
+ mux.HandleFunc("GET /{repo}/tree", h.browseTreeRoute)
+ mux.HandleFunc("GET /{repo}/tree/{rest...}", h.browseTreeRoute)
+ mux.HandleFunc("GET /{repo}/blob", h.browseBlobRoute)
+ mux.HandleFunc("GET /{repo}/blob/{rest...}", h.browseBlobRoute)
+ mux.HandleFunc("GET /{repo}/raw", h.browseRawRoute)
+ mux.HandleFunc("GET /{repo}/raw/{rest...}", h.browseRawRoute)
+ mux.HandleFunc("GET /{repo}/commits", h.browseCommitsRoute)
+ mux.HandleFunc("GET /{repo}/commits/{rest...}", h.browseCommitsRoute)
+ mux.HandleFunc("GET /{repo}/commit/{sha}", h.browseCommitRoute)
+ mux.HandleFunc("GET /{repo}/refs", h.browseRefsRoute)
+ mux.HandleFunc("GET /{repo}/runs", h.ciRunsRoute)
+ mux.HandleFunc("GET /{repo}/runs/{rest...}", h.ciRunsRoute)
+ // The bare repo landing: README + tree at the default branch. One segment, so
+ // distinct from git's GET /{repo}/info/refs.
+ mux.HandleFunc("GET /{repo}", h.browseLanding)
// The vendored mermaid library for diagram rendering, served same-origin.
// Public and non-sensitive (a pinned copy of an open-source library), so it
diff --git a/tasks/README.md b/tasks/README.md
index d317d33..e3560e7 100644
--- a/tasks/README.md
+++ b/tasks/README.md
@@ -18,12 +18,12 @@ namespace** (`host/<repo>`, no `owner/`, no `/-/`), content addressed in-path
visibility with spectators & collaborators, and rendered-markdown links that
actually resolve. Greenfield — **reset the bucket + meta**, no migration. Design +
rationale: [../docs/architecture/urls.md](../docs/architecture/urls.md). Order
-within the chain matters — the **flat namespace + visibility & access** slice has
-landed (single-segment repos, `visibility`, repo-read browse authz, admin-only
-default-branch force-push); the rest remain:
+within the chain matters — the **flat namespace + visibility & access** and the
+**content routing** slices have landed (single-segment repos, `visibility`,
+repo-read browse authz, admin-only default-branch force-push; `tree`/`blob`/`raw`
+verbs with ref-in-path greedy resolution and the bare `/<repo>` landing); the
+rest remain:
-- [urls-content-routing.md](urls-content-routing.md) — `tree`/`blob`/`raw` verbs,
- ref-in-path greedy resolution, default-branch landing. *(needs -namespace.)*
- [urls-markdown-links.md](urls-markdown-links.md) — rewrite rendered-markdown
relative links (nav → `blob`/`tree`) and embeds (→ `raw`), ref preserved.
*(needs -content-routing.)*
diff --git a/tasks/urls-content-routing.md b/tasks/urls-content-routing.md
deleted file mode 100644
index acb3234..0000000
--- a/tasks/urls-content-routing.md
+++ /dev/null
@@ -1,64 +0,0 @@
-# Content routing — tree/blob/raw + ref-in-path
-
-Part of the **URLs** epic ([README](README.md)); implements the content-addressing
-section of [../docs/architecture/urls.md](../docs/architecture/urls.md). Needs the
-flat namespace ([urls-namespace-access.md](urls-namespace-access.md)).
-
-## Spec
-
-- Content verbs under `/<repo>/`: **`tree`** (unified — dir → listing, file →
- view), **`blob`** (explicit file; a dir **301s to `tree`**), **`raw`** (bytes).
-- The **ref is in the path**, resolved **greedily**: the longest leading
- path-prefix that names a real branch/tag. Cross-type prefix collision (a branch
- and a tag) resolves branch-first. An omitted ref (the `/<repo>` landing) means
- the default branch.
-- Also `commits`/`commit`/`refs`/`runs` under `/<repo>/`; **no `?ref=` query
- param**, **no `/-/` marker**.
-
-## Current
-
-Browse is `/browse/<repo>/-/<action>/<arg>` with the **ref as a query parameter**
-([`browse.go`](../internal/webui/browse.go)): the path is split on `/-/` to find
-where the (slashed) repo name ends, `action` is `tree|blob|raw|commits|commit|
-runs|run`, and `resolve` reads `ref` from `r.URL.Query()` else the default branch.
-`tree` and `blob` are separate renderers; a relative link loses `?ref=` and can
-hit the wrong verb → "empty tree". This is the concrete breakage the epic fixes.
-
-## Change
-
-- **Routes** ([`webui.go`](../internal/webui/webui.go)): register
- `GET /{repo}/tree/{rest...}`, `.../blob/...`, `.../raw/...`, `.../commits/...`,
- `.../commit/{sha}`, `.../refs`, `.../runs`, `.../runs/{id}`, and the bare
- `GET /{repo}` landing. Drop the `/browse/` prefix and the `/-/` split (repo is
- one segment now). **Mux seam:** these now share the mux with the git smart-HTTP
- catch-all — register **only these enumerated verbs**, never a broad
- `/{repo}/{rest...}`, so git's `info/refs` / `*-pack` fall through to the git
- handler (see [../docs/architecture/urls.md](../docs/architecture/urls.md) →
- Implementation seams).
-- **Ref-in-path** ([`browse.go`](../internal/webui/browse.go) `resolve`): replace
- the `?ref` read with greedy resolution against the repo's ref list —
- `meta.ListRefs` ([`refs.go`](../internal/meta/refs.go), refs are source of
- truth) for branches, plus tags. Longest-prefix match; branch-over-tag on a tie;
- default branch when the tail has no ref (landing). A path that resolves to no
- ref → 404.
-- **tree unified**: one handler renders a listing for a tree entry and the file
- view for a blob, chosen by the tree lookup `resolve` already does.
-- **blob explicit**: render the file; if the entry is a tree, **301 to the `tree`
- URL** (same ref+path).
-- **raw**: bytes as today, file-only (dir → 404).
-- **Link generation** in the browse templates emits `blob` for files and `tree`
- for directories (it has the tree entry's type), with both verbs tolerant per
- above. (Markdown-body links are handled in
- [urls-markdown-links.md](urls-markdown-links.md).)
-
-## Verify
-
-- `/<repo>` → landing at default branch; `/<repo>/tree/main/docs` → listing;
- `/<repo>/tree/main/docs/readme.md` → file view.
-- `/<repo>/blob/main/readme.md` → file view; `/<repo>/blob/main/docs` → **301** to
- `/<repo>/tree/main/docs`; `/<repo>/raw/main/x` → bytes; `/raw` on a dir → 404.
-- **Slashed branch** `feature/x`: `/<repo>/tree/feature/x/README.md` resolves ref
- `feature/x`, path `README.md`.
-- Unknown ref or path → 404 (not a 500, not an "empty tree").
-- `gofmt`/`golangci-lint`/`go test ./...` clean; golden **and** failure paths
- (bad ref, blob-on-dir redirect, greedy tie-break).