diff --git a/cmd/gitmote/main.go b/cmd/gitmote/main.go
index 5405db7..7835203 100644
--- a/cmd/gitmote/main.go
+++ b/cmd/gitmote/main.go
@@ -228,8 +228,9 @@ func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *w
guard := auth.NewGuard(md)
cacheRoot := envOr("GITMOTE_CACHE", filepath.Join(os.TempDir(), "gitmote-repos"))
+ materializer := repo.New(md, objs, cacheRoot)
handler, err := githttp.New(githttp.Config{
- Materializer: repo.New(md, objs, cacheRoot),
+ Materializer: materializer,
Authorizer: guard,
Writer: writer,
IsWritable: md.IsLeader,
@@ -240,7 +241,7 @@ func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *w
return nil, nil, noop, err
}
- ui, err := buildUI(md, guard, logger)
+ ui, err := buildUI(md, materializer, guard, logger)
if err != nil {
_ = cleanup()
return nil, nil, noop, err
@@ -251,13 +252,13 @@ func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *w
// buildUI constructs the management UI when GITMOTE_COOKIE_KEY is set; otherwise
// it returns nil (UI disabled) so a misconfigured key never yields an insecurely
// signed session.
-func buildUI(md *meta.Metadata, guard *auth.Guard, logger *slog.Logger) (*webui.Handler, error) {
+func buildUI(md *meta.Metadata, mz *repo.Materializer, guard *auth.Guard, logger *slog.Logger) (*webui.Handler, error) {
key := os.Getenv("GITMOTE_COOKIE_KEY")
if key == "" {
logger.Warn("GITMOTE_COOKIE_KEY unset; management UI disabled")
return nil, nil
}
- return webui.New(md, guard, []byte(key), logger)
+ return webui.New(md, mz, guard, []byte(key), logger)
}
// hookBinaryPath resolves the pre-receive hook executable: GITMOTE_HOOK if set,
diff --git a/internal/repo/browse.go b/internal/repo/browse.go
new file mode 100644
index 0000000..6d44123
--- /dev/null
+++ b/internal/repo/browse.go
@@ -0,0 +1,311 @@
+package repo
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os/exec"
+ "path"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// ErrNotFound reports that a browse lookup — a ref, a tree, or a blob — did not
+// resolve in the materialized repo. Handlers map it to 404. A git subcommand
+// that exits non-zero on these read-only plumbing calls means "no such
+// object/path", so it is reported as ErrNotFound; a failure to launch git at
+// all surfaces as a distinct error.
+var ErrNotFound = errors.New("repo: not found")
+
+// TreeEntry is one row of a directory listing (git ls-tree --long).
+type TreeEntry struct {
+ Mode string // e.g. "100644"
+ Type string // "blob", "tree", or "commit" (submodule)
+ SHA string
+ Size int64 // 0 for non-blob entries (git prints "-")
+ Path string // full path from the repo root, for links
+ Name string // base name, for display
+}
+
+// Commit is a single entry of history: the fields the browse UI shows.
+type Commit struct {
+ SHA string
+ Author string
+ Email string
+ When time.Time
+ Subject string
+ Body string
+}
+
+// commitFormat is a git pretty-format whose fields are unit-separated (\x1f) and
+// whose records are terminated by \x1e, so a multi-line body never confuses
+// parsing. Fields, in order: hash, author name, author email, author date
+// (strict ISO 8601), subject, body.
+const commitFormat = "%H%x1f%an%x1f%ae%x1f%aI%x1f%s%x1f%b%x1e"
+
+// ResolveRef resolves ref (a branch, tag, or hash) to a commit SHA in the
+// materialized repo at dir. It rejects a ref beginning with "-" (flag
+// injection) and returns ErrNotFound for an unknown ref.
+func ResolveRef(ctx context.Context, dir, ref string) (string, error) {
+ if err := safeRev(ref); err != nil {
+ return "", err
+ }
+ out, err := runGitOut(ctx, dir, "rev-parse", "--verify", "--end-of-options", ref+"^{commit}")
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(string(out)), nil
+}
+
+// Tree lists the immediate children of path within the commit sha. An empty
+// path lists the repo root. Entries carry mode, type, sha, size, and name.
+func Tree(ctx context.Context, dir, sha, treePath string) ([]TreeEntry, error) {
+ if err := safeRev(sha); err != nil {
+ return nil, err
+ }
+ if err := safePath(treePath); err != nil {
+ return nil, err
+ }
+ args := []string{"ls-tree", "--long", "--end-of-options", sha}
+ if treePath != "" {
+ // A trailing slash makes ls-tree list the directory's contents rather
+ // than the directory entry itself. A lone "--" with no pathspec makes
+ // ls-tree match nothing, so only add the separator when scoping.
+ args = append(args, "--", strings.TrimSuffix(treePath, "/")+"/")
+ }
+ out, err := runGitOut(ctx, dir, args...)
+ if err != nil {
+ return nil, err
+ }
+ return parseTree(out), nil
+}
+
+// parseTree parses `git ls-tree --long` output: "<mode> <type> <sha> <size>\t<name>".
+func parseTree(out []byte) []TreeEntry {
+ var entries []TreeEntry
+ for _, line := range strings.Split(string(out), "\n") {
+ if line == "" {
+ continue
+ }
+ meta, name, ok := strings.Cut(line, "\t")
+ if !ok {
+ continue
+ }
+ fields := strings.Fields(meta)
+ if len(fields) < 4 {
+ continue
+ }
+ var size int64
+ if n, err := strconv.ParseInt(fields[3], 10, 64); err == nil {
+ size = n
+ }
+ entries = append(entries, TreeEntry{
+ Mode: fields[0],
+ Type: fields[1],
+ SHA: fields[2],
+ Size: size,
+ Path: name,
+ Name: path.Base(name),
+ })
+ }
+ return entries
+}
+
+// Blob reads the file at path within commit sha, returning its content, size,
+// and whether it looks binary (a NUL byte in the leading bytes). It returns
+// ErrNotFound when the path names no blob.
+func Blob(ctx context.Context, dir, sha, blobPath string) (content []byte, size int64, binary bool, err error) {
+ if err := safeRev(sha); err != nil {
+ return nil, 0, false, err
+ }
+ if err := safePath(blobPath); err != nil {
+ return nil, 0, false, err
+ }
+ out, err := runGitOut(ctx, dir, "cat-file", "blob", "--end-of-options", sha+":"+blobPath)
+ if err != nil {
+ return nil, 0, false, err
+ }
+ return out, int64(len(out)), isBinary(out), nil
+}
+
+// BlobSize returns the byte size of the blob at path within commit sha without
+// reading its content, and ErrNotFound when the path names no blob. The raw
+// endpoint uses it to confirm existence before streaming.
+func BlobSize(ctx context.Context, dir, sha, blobPath string) (int64, error) {
+ if err := safeRev(sha); err != nil {
+ return 0, err
+ }
+ if err := safePath(blobPath); err != nil {
+ return 0, err
+ }
+ out, err := runGitOut(ctx, dir, "cat-file", "-s", "--end-of-options", sha+":"+blobPath)
+ if err != nil {
+ return 0, err
+ }
+ return strconv.ParseInt(strings.TrimSpace(string(out)), 10, 64)
+}
+
+// BlobReader streams the blob at path within commit sha. The caller must Close
+// the reader, which also reaps the git process. Existence should be confirmed
+// with BlobSize first, so a missing path is a 404 before any bytes are sent.
+func BlobReader(ctx context.Context, dir, sha, blobPath string) (io.ReadCloser, error) {
+ if err := safeRev(sha); err != nil {
+ return nil, err
+ }
+ if err := safePath(blobPath); err != nil {
+ return nil, err
+ }
+ cmd := exec.CommandContext(ctx, "git", "cat-file", "blob", "--end-of-options", sha+":"+blobPath)
+ cmd.Dir = dir
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return nil, err
+ }
+ if err := cmd.Start(); err != nil {
+ return nil, err
+ }
+ return &gitReadCloser{stdout: stdout, cmd: cmd}, nil
+}
+
+// gitReadCloser wraps a running git process's stdout so Close both closes the
+// pipe and waits for the process to exit.
+type gitReadCloser struct {
+ stdout io.ReadCloser
+ cmd *exec.Cmd
+}
+
+func (g *gitReadCloser) Read(p []byte) (int, error) { return g.stdout.Read(p) }
+
+func (g *gitReadCloser) Close() error {
+ _ = g.stdout.Close()
+ return g.cmd.Wait()
+}
+
+// Log returns the commit history reachable from sha, optionally scoped to a
+// path, newest first. It fetches limit+1 commits: if more than limit come back
+// the extra is dropped and more is true, so the caller can show a truncation
+// marker instead of silently capping.
+func Log(ctx context.Context, dir, sha, logPath string, limit int) (commits []Commit, more bool, err error) {
+ if err := safeRev(sha); err != nil {
+ return nil, false, err
+ }
+ if err := safePath(logPath); err != nil {
+ return nil, false, err
+ }
+ args := []string{"log", "--format=" + commitFormat, "-n", strconv.Itoa(limit + 1), "--end-of-options", sha}
+ if logPath != "" {
+ args = append(args, "--", logPath)
+ }
+ out, err := runGitOut(ctx, dir, args...)
+ if err != nil {
+ return nil, false, err
+ }
+ commits = parseCommits(out)
+ if len(commits) > limit {
+ return commits[:limit], true, nil
+ }
+ return commits, false, nil
+}
+
+// Show returns a single commit's metadata and its unified diff. --root makes the
+// initial commit produce a diff against the empty tree rather than nothing.
+func Show(ctx context.Context, dir, sha string) (Commit, string, error) {
+ if err := safeRev(sha); err != nil {
+ return Commit{}, "", err
+ }
+ out, err := runGitOut(ctx, dir, "show", "--format="+commitFormat, "--patch", "--root", "--end-of-options", sha)
+ if err != nil {
+ return Commit{}, "", err
+ }
+ // The format ends with \x1e; the patch follows after it.
+ head, diff, _ := bytes.Cut(out, []byte{0x1e})
+ commits := parseCommits(append(head, 0x1e))
+ if len(commits) == 0 {
+ return Commit{}, "", ErrNotFound
+ }
+ return commits[0], strings.TrimLeft(string(diff), "\n"), nil
+}
+
+// parseCommits splits git output framed by commitFormat into commits.
+func parseCommits(out []byte) []Commit {
+ var commits []Commit
+ for _, rec := range strings.Split(string(out), "\x1e") {
+ rec = strings.Trim(rec, "\n")
+ if rec == "" {
+ continue
+ }
+ fields := strings.Split(rec, "\x1f")
+ if len(fields) < 6 {
+ continue
+ }
+ when, _ := time.Parse(time.RFC3339, fields[3])
+ commits = append(commits, Commit{
+ SHA: fields[0],
+ Author: fields[1],
+ Email: fields[2],
+ When: when,
+ Subject: fields[4],
+ Body: strings.TrimRight(fields[5], "\n"),
+ })
+ }
+ return commits
+}
+
+// isBinary flags content as binary if a NUL byte appears in the leading bytes,
+// git's own heuristic for a non-text blob.
+func isBinary(content []byte) bool {
+ const sniff = 8000
+ if len(content) > sniff {
+ content = content[:sniff]
+ }
+ return bytes.IndexByte(content, 0) >= 0
+}
+
+// safeRev rejects a rev (ref or sha) that could be read as a git flag.
+func safeRev(rev string) error {
+ if rev == "" || strings.HasPrefix(rev, "-") {
+ return fmt.Errorf("repo: unsafe rev %q: %w", rev, ErrNotFound)
+ }
+ return nil
+}
+
+// safePath rejects a pathspec that is absolute or escapes the repo root, so a
+// browse request can never reach outside the materialized tree.
+func safePath(p string) error {
+ if p == "" {
+ return nil
+ }
+ if strings.HasPrefix(p, "/") {
+ return fmt.Errorf("repo: unsafe path %q: %w", p, ErrNotFound)
+ }
+ for _, seg := range strings.Split(p, "/") {
+ if seg == ".." {
+ return fmt.Errorf("repo: unsafe path %q: %w", p, ErrNotFound)
+ }
+ }
+ return nil
+}
+
+// runGitOut runs a git subcommand in dir and returns its stdout. A non-zero
+// exit — the "no such object/path/ref" case for these read-only plumbing calls
+// — is reported as ErrNotFound with git's stderr attached; a failure to launch
+// git surfaces as a plain error.
+func runGitOut(ctx context.Context, dir string, args ...string) ([]byte, error) {
+ cmd := exec.CommandContext(ctx, "git", args...)
+ cmd.Dir = dir
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+ if err := cmd.Run(); err != nil {
+ var ee *exec.ExitError
+ if errors.As(err, &ee) {
+ return nil, fmt.Errorf("git %s: %s: %w",
+ strings.Join(args, " "), strings.TrimSpace(stderr.String()), ErrNotFound)
+ }
+ return nil, fmt.Errorf("git %s: %w", strings.Join(args, " "), err)
+ }
+ return stdout.Bytes(), nil
+}
diff --git a/internal/repo/browse_test.go b/internal/repo/browse_test.go
new file mode 100644
index 0000000..3524087
--- /dev/null
+++ b/internal/repo/browse_test.go
@@ -0,0 +1,209 @@
+package repo
+
+import (
+ "context"
+ "errors"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// browseFixture builds a real repo with two commits, a subdirectory, a binary
+// blob, and a tag, and returns its dir plus the two commit SHAs (first, head).
+func browseFixture(t *testing.T) (dir, first, head string) {
+ t.Helper()
+ dir = t.TempDir()
+ git(t, dir, "init", "-b", "main", ".")
+
+ if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hello\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "sub", "file.txt"), []byte("nested\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "bin.dat"), []byte{0x00, 0x01, 0x02, 0x00}, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ git(t, dir, "add", "-A")
+ git(t, dir, "commit", "-m", "first")
+ first = git(t, dir, "rev-parse", "HEAD")
+
+ if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hello\nworld\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ git(t, dir, "commit", "-am", "second")
+ head = git(t, dir, "rev-parse", "HEAD")
+
+ git(t, dir, "tag", "v1")
+ return dir, first, head
+}
+
+func TestResolveRef(t *testing.T) {
+ ctx := context.Background()
+ dir, _, head := browseFixture(t)
+
+ for _, ref := range []string{"main", "v1", head} {
+ got, err := ResolveRef(ctx, dir, ref)
+ if err != nil {
+ t.Fatalf("ResolveRef(%q): %v", ref, err)
+ }
+ if got != head {
+ t.Fatalf("ResolveRef(%q) = %q, want %q", ref, got, head)
+ }
+ }
+
+ if _, err := ResolveRef(ctx, dir, "--all"); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("ResolveRef(flag) err = %v, want ErrNotFound", err)
+ }
+ if _, err := ResolveRef(ctx, dir, "nope"); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("ResolveRef(unknown) err = %v, want ErrNotFound", err)
+ }
+}
+
+func TestTree(t *testing.T) {
+ ctx := context.Background()
+ dir, _, head := browseFixture(t)
+
+ root, err := Tree(ctx, dir, head, "")
+ if err != nil {
+ t.Fatalf("Tree(root): %v", err)
+ }
+ types := map[string]string{}
+ for _, e := range root {
+ types[e.Name] = e.Type
+ }
+ if types["README.md"] != "blob" || types["sub"] != "tree" || types["bin.dat"] != "blob" {
+ t.Fatalf("root entries = %+v", root)
+ }
+
+ sub, err := Tree(ctx, dir, head, "sub")
+ if err != nil {
+ t.Fatalf("Tree(sub): %v", err)
+ }
+ if len(sub) != 1 || sub[0].Path != "sub/file.txt" || sub[0].Name != "file.txt" {
+ t.Fatalf("sub entries = %+v", sub)
+ }
+
+ if _, err := Tree(ctx, dir, head, "../etc"); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("Tree(traversal) err = %v, want ErrNotFound", err)
+ }
+}
+
+func TestBlob(t *testing.T) {
+ ctx := context.Background()
+ dir, _, head := browseFixture(t)
+
+ content, size, binary, err := Blob(ctx, dir, head, "README.md")
+ if err != nil {
+ t.Fatalf("Blob(README): %v", err)
+ }
+ if binary || size != int64(len(content)) || string(content) != "hello\nworld\n" {
+ t.Fatalf("README blob: content=%q size=%d binary=%v", content, size, binary)
+ }
+
+ _, _, binary, err = Blob(ctx, dir, head, "bin.dat")
+ if err != nil {
+ t.Fatalf("Blob(bin): %v", err)
+ }
+ if !binary {
+ t.Fatal("bin.dat not flagged binary")
+ }
+
+ if _, _, _, err := Blob(ctx, dir, head, "missing"); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("Blob(missing) err = %v, want ErrNotFound", err)
+ }
+}
+
+func TestBlobReaderAndSize(t *testing.T) {
+ ctx := context.Background()
+ dir, _, head := browseFixture(t)
+
+ size, err := BlobSize(ctx, dir, head, "README.md")
+ if err != nil {
+ t.Fatalf("BlobSize: %v", err)
+ }
+ if size != int64(len("hello\nworld\n")) {
+ t.Fatalf("BlobSize = %d", size)
+ }
+
+ rc, err := BlobReader(ctx, dir, head, "README.md")
+ if err != nil {
+ t.Fatalf("BlobReader: %v", err)
+ }
+ got, err := io.ReadAll(rc)
+ if err != nil {
+ t.Fatalf("read blob: %v", err)
+ }
+ if err := rc.Close(); err != nil {
+ t.Fatalf("close blob: %v", err)
+ }
+ if string(got) != "hello\nworld\n" {
+ t.Fatalf("streamed blob = %q", got)
+ }
+
+ if _, err := BlobSize(ctx, dir, head, "missing"); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("BlobSize(missing) err = %v, want ErrNotFound", err)
+ }
+}
+
+func TestLogOverflowMarker(t *testing.T) {
+ ctx := context.Background()
+ dir, _, head := browseFixture(t)
+
+ commits, more, err := Log(ctx, dir, head, "", 1)
+ if err != nil {
+ t.Fatalf("Log(limit 1): %v", err)
+ }
+ if len(commits) != 1 || !more {
+ t.Fatalf("Log(limit 1) = %d commits, more=%v; want 1, true", len(commits), more)
+ }
+ if commits[0].Subject != "second" || commits[0].Author == "" || commits[0].When.IsZero() {
+ t.Fatalf("head commit = %+v", commits[0])
+ }
+
+ all, more, err := Log(ctx, dir, head, "", 10)
+ if err != nil {
+ t.Fatalf("Log(limit 10): %v", err)
+ }
+ if len(all) != 2 || more {
+ t.Fatalf("Log(limit 10) = %d commits, more=%v; want 2, false", len(all), more)
+ }
+
+ scoped, _, err := Log(ctx, dir, head, "sub/file.txt", 10)
+ if err != nil {
+ t.Fatalf("Log(path): %v", err)
+ }
+ if len(scoped) != 1 || scoped[0].Subject != "first" {
+ t.Fatalf("Log(path) = %+v", scoped)
+ }
+}
+
+func TestShow(t *testing.T) {
+ ctx := context.Background()
+ dir, first, head := browseFixture(t)
+
+ c, diff, err := Show(ctx, dir, head)
+ if err != nil {
+ t.Fatalf("Show(head): %v", err)
+ }
+ if c.SHA != head || c.Subject != "second" {
+ t.Fatalf("Show(head) commit = %+v", c)
+ }
+ if !strings.Contains(diff, "+world") {
+ t.Fatalf("Show(head) diff missing added line:\n%s", diff)
+ }
+
+ // --root makes the initial commit diff against the empty tree.
+ _, diff, err = Show(ctx, dir, first)
+ if err != nil {
+ t.Fatalf("Show(first): %v", err)
+ }
+ if !strings.Contains(diff, "+hello") {
+ t.Fatalf("Show(first) diff missing initial content:\n%s", diff)
+ }
+}
diff --git a/internal/webui/browse.go b/internal/webui/browse.go
new file mode 100644
index 0000000..1a74df2
--- /dev/null
+++ b/internal/webui/browse.go
@@ -0,0 +1,272 @@
+package webui
+
+import (
+ "errors"
+ "io"
+ "mime"
+ "net/http"
+ "path"
+ "strings"
+
+ "github.com/atmin/gitmote/internal/meta"
+ "github.com/atmin/gitmote/internal/repo"
+)
+
+// logLimit caps how many commits a single history page shows; a 101st commit,
+// 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 == "" {
+ http.NotFound(w, r)
+ 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)
+ default:
+ http.NotFound(w, r)
+ }
+}
+
+// browseCtx is the resolved starting point every browse handler needs: the repo
+// record, its materialized on-disk dir, the selected ref, and the commit SHA
+// that ref points at.
+type browseCtx struct {
+ repo *meta.Repo
+ dir string
+ ref string
+ 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) {
+ ctx := r.Context()
+ rp, err := h.md.GetRepo(ctx, repoName)
+ if errors.Is(err, meta.ErrNotFound) {
+ http.NotFound(w, r)
+ return browseCtx{}, false
+ }
+ if err != nil {
+ h.serverError(w, "get repo", err)
+ return browseCtx{}, false
+ }
+ ref := strings.TrimSpace(r.URL.Query().Get("ref"))
+ if ref == "" {
+ ref = rp.DefaultBranch
+ }
+ dir, err := h.mz.Materialize(ctx, repoName)
+ if err != nil {
+ h.serverError(w, "materialize repo", err)
+ return browseCtx{}, false
+ }
+ sha, err := repo.ResolveRef(ctx, dir, ref)
+ if errors.Is(err, repo.ErrNotFound) {
+ http.NotFound(w, r)
+ return browseCtx{}, false
+ }
+ if err != nil {
+ h.serverError(w, "resolve ref", err)
+ return browseCtx{}, false
+ }
+ return browseCtx{repo: rp, dir: dir, ref: ref, sha: sha}, true
+}
+
+// base fills the shared browse header (nav identity, repo, selected ref, and the
+// ref switcher's options) for c.
+func (h *Handler) browseHeader(r *http.Request, c browseCtx) browseBase {
+ return browseBase{
+ base: h.base(r, "", ""),
+ Repo: c.repo.Name,
+ Ref: c.ref,
+ Refs: h.refChoices(r, c.repo.ID),
+ }
+}
+
+// refChoices lists the repo's branches and tags (short names) for the switcher.
+// A ref-listing failure is non-fatal: the page still renders, just without a
+// switcher.
+func (h *Handler) refChoices(r *http.Request, repoID int64) []refChoice {
+ refs, err := h.md.ListRefs(r.Context(), repoID)
+ if err != nil {
+ h.log.Error("list refs for switcher", "error", err)
+ return nil
+ }
+ choices := make([]refChoice, 0, len(refs))
+ for _, ref := range refs {
+ name := ref.Name
+ switch {
+ case strings.HasPrefix(name, "refs/heads/"):
+ name = strings.TrimPrefix(name, "refs/heads/")
+ case strings.HasPrefix(name, "refs/tags/"):
+ name = strings.TrimPrefix(name, "refs/tags/")
+ }
+ choices = append(choices, refChoice{Name: name, Ref: name})
+ }
+ 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
+ }
+ entries, err := repo.Tree(r.Context(), c.dir, c.sha, treePath)
+ if errors.Is(err, repo.ErrNotFound) {
+ http.NotFound(w, r)
+ return
+ }
+ if err != nil {
+ h.serverError(w, "list tree", err)
+ return
+ }
+ h.render(w, "browse_tree.html", treeData{
+ browseBase: h.browseHeader(r, c),
+ Path: treePath,
+ Crumbs: crumbs(treePath),
+ Entries: entries,
+ })
+}
+
+func (h *Handler) browseBlob(w http.ResponseWriter, r *http.Request, repoName, blobPath string) {
+ c, ok := h.resolve(w, r, repoName)
+ if !ok {
+ return
+ }
+ content, size, binary, err := repo.Blob(r.Context(), c.dir, c.sha, blobPath)
+ if errors.Is(err, repo.ErrNotFound) {
+ http.NotFound(w, r)
+ return
+ }
+ if err != nil {
+ h.serverError(w, "read blob", err)
+ return
+ }
+ data := blobData{
+ browseBase: h.browseHeader(r, c),
+ Path: blobPath,
+ Crumbs: crumbs(blobPath),
+ Binary: binary,
+ Size: size,
+ }
+ if !binary {
+ data.Text = string(content)
+ }
+ 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
+ }
+ // 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) {
+ http.NotFound(w, r)
+ return
+ } else if err != nil {
+ h.serverError(w, "stat blob", err)
+ return
+ }
+ rc, err := repo.BlobReader(r.Context(), c.dir, c.sha, blobPath)
+ if err != nil {
+ h.serverError(w, "open blob", err)
+ return
+ }
+ defer func() { _ = rc.Close() }()
+
+ if ct := mime.TypeByExtension(path.Ext(blobPath)); ct != "" {
+ w.Header().Set("Content-Type", ct)
+ } else {
+ w.Header().Set("Content-Type", "application/octet-stream")
+ }
+ 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)
+ }
+}
+
+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"))
+ commits, more, err := repo.Log(r.Context(), c.dir, c.sha, scope, logLimit)
+ if errors.Is(err, repo.ErrNotFound) {
+ http.NotFound(w, r)
+ return
+ }
+ if err != nil {
+ h.serverError(w, "commit log", err)
+ return
+ }
+ h.render(w, "browse_commits.html", commitsData{
+ browseBase: h.browseHeader(r, c),
+ Path: scope,
+ Crumbs: crumbs(scope),
+ Commits: commits,
+ More: more,
+ })
+}
+
+func (h *Handler) browseCommit(w http.ResponseWriter, r *http.Request, repoName, sha string) {
+ c, ok := h.resolve(w, r, repoName)
+ if !ok {
+ return
+ }
+ commit, diff, err := repo.Show(r.Context(), c.dir, sha)
+ if errors.Is(err, repo.ErrNotFound) {
+ http.NotFound(w, r)
+ return
+ }
+ if err != nil {
+ h.serverError(w, "show commit", err)
+ return
+ }
+ h.render(w, "browse_commit.html", commitData{
+ browseBase: h.browseHeader(r, c),
+ Commit: commit,
+ Diff: diff,
+ })
+}
+
+// crumbs turns a path into its breadcrumb trail, each segment linking to the
+// tree at that depth. The empty path yields no crumbs (the repo root link in
+// the template stands alone).
+func crumbs(p string) []crumb {
+ p = strings.Trim(p, "/")
+ if p == "" {
+ return nil
+ }
+ var out []crumb
+ var acc string
+ for _, seg := range strings.Split(p, "/") {
+ if acc == "" {
+ acc = seg
+ } else {
+ acc += "/" + seg
+ }
+ out = append(out, crumb{Name: seg, Path: acc})
+ }
+ return out
+}
diff --git a/internal/webui/browse_test.go b/internal/webui/browse_test.go
new file mode 100644
index 0000000..ae3ac4e
--- /dev/null
+++ b/internal/webui/browse_test.go
@@ -0,0 +1,227 @@
+package webui
+
+import (
+ "bytes"
+ "context"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/atmin/gitmote/internal/meta"
+)
+
+// gitTest runs a git command hermetically, failing the test on a nonzero exit.
+func gitTest(t *testing.T, dir string, args ...string) string {
+ t.Helper()
+ cmd := exec.Command("git", args...)
+ cmd.Dir = dir
+ cmd.Env = append(os.Environ(),
+ "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com",
+ "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com",
+ "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
+ )
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
+ }
+ return strings.TrimSpace(string(out))
+}
+
+// seedBrowseRepo builds a real repo (a README.go, a subdir, a binary blob, a
+// tag), loads its objects into the harness store under name, and records the
+// repo + branch ref in meta. It returns (headSHA, firstSHA).
+func (x *harness) seedBrowseRepo(name, branch string) (head, first string) {
+ x.t.Helper()
+ ctx := context.Background()
+
+ src := x.t.TempDir()
+ gitTest(x.t, src, "init", "-b", branch, ".")
+ write(x.t, src, "hello.go", "package main\n\nfunc main() {}\n")
+ write(x.t, src, "sub/note.txt", "nested\n")
+ write(x.t, src, "bin.dat", "a\x00b\x00c")
+ gitTest(x.t, src, "add", "-A")
+ gitTest(x.t, src, "commit", "-m", "first")
+ first = gitTest(x.t, src, "rev-parse", "HEAD")
+
+ write(x.t, src, "hello.go", "package main\n\nfunc main() { println(1) }\n")
+ gitTest(x.t, src, "commit", "-am", "second")
+ head = gitTest(x.t, src, "rev-parse", "HEAD")
+ gitTest(x.t, src, "tag", "v1")
+
+ // Mirror .git/objects/… into the store under {name}/objects/….
+ objRoot := filepath.Join(src, ".git", "objects")
+ err := filepath.WalkDir(objRoot, func(p string, d os.DirEntry, err error) error {
+ if err != nil || d.IsDir() {
+ return err
+ }
+ rel, err := filepath.Rel(objRoot, p)
+ if err != nil {
+ return err
+ }
+ data, err := os.ReadFile(p)
+ if err != nil {
+ return err
+ }
+ return x.store.Put(ctx, name+"/objects/"+filepath.ToSlash(rel), bytes.NewReader(data))
+ })
+ if err != nil {
+ x.t.Fatalf("seed objects: %v", err)
+ }
+
+ r, err := x.md.CreateRepo(ctx, name, branch)
+ if err != nil {
+ x.t.Fatalf("CreateRepo: %v", err)
+ }
+ if err := x.md.CASRef(ctx, r.ID, "refs/heads/"+branch, meta.ZeroSHA, head); err != nil {
+ x.t.Fatalf("CASRef branch: %v", err)
+ }
+ if err := x.md.CASRef(ctx, r.ID, "refs/tags/v1", meta.ZeroSHA, head); err != nil {
+ x.t.Fatalf("CASRef tag: %v", err)
+ }
+ return head, first
+}
+
+func write(t *testing.T, dir, rel, content string) {
+ t.Helper()
+ full := filepath.Join(dir, rel)
+ if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestBrowseRequiresAdmin(t *testing.T) {
+ x := newHarness(t)
+ x.seedBrowseRepo("alice/app", "main")
+
+ // No session cookie: a GET redirects to login rather than serving content.
+ rec := x.do(http.MethodGet, "/browse/alice/app/-/tree/", nil, nil)
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("unauth browse = %d, want 303", rec.Code)
+ }
+}
+
+func TestBrowseTreeAndBlob(t *testing.T) {
+ x := newHarness(t)
+ x.seedBrowseRepo("alice/app", "main")
+ session := x.login(x.mintTokenFor(x.admin.ID))
+
+ // Root tree lists the top-level entries.
+ rec := x.do(http.MethodGet, "/browse/alice/app/-/tree/", nil, session)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("tree = %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)
+ }
+ }
+
+ // Subdirectory tree.
+ rec = x.do(http.MethodGet, "/browse/alice/app/-/tree/sub?ref=main", 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 inside a <pre>.
+ rec = x.do(http.MethodGet, "/browse/alice/app/-/blob/hello.go?ref=main", nil, session)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("blob = %d (%s)", rec.Code, rec.Body)
+ }
+ if !strings.Contains(rec.Body.String(), "package main") {
+ t.Fatalf("blob body missing content:\n%s", rec.Body)
+ }
+
+ // Binary blob shows the download affordance, not garbage.
+ rec = x.do(http.MethodGet, "/browse/alice/app/-/blob/bin.dat?ref=main", nil, session)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("binary blob = %d", rec.Code)
+ }
+ if !strings.Contains(rec.Body.String(), "Binary file") {
+ t.Fatalf("binary blob missing download affordance:\n%s", rec.Body)
+ }
+}
+
+func TestBrowseRawDownload(t *testing.T) {
+ x := newHarness(t)
+ x.seedBrowseRepo("alice/app", "main")
+ session := x.login(x.mintTokenFor(x.admin.ID))
+
+ rec := x.do(http.MethodGet, "/browse/alice/app/-/raw/sub/note.txt?ref=main", nil, session)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("raw = %d (%s)", rec.Code, rec.Body)
+ }
+ if cd := rec.Header().Get("Content-Disposition"); !strings.Contains(cd, "attachment") || !strings.Contains(cd, "note.txt") {
+ t.Fatalf("raw Content-Disposition = %q", cd)
+ }
+ if rec.Body.String() != "nested\n" {
+ t.Fatalf("raw body = %q", rec.Body.String())
+ }
+}
+
+func TestBrowseRefSwitcher(t *testing.T) {
+ x := newHarness(t)
+ x.seedBrowseRepo("alice/app", "main")
+ session := x.login(x.mintTokenFor(x.admin.ID))
+
+ rec := x.do(http.MethodGet, "/browse/alice/app/-/tree/", 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"`) {
+ t.Fatalf("ref switcher missing branch/tag:\n%s", body)
+ }
+}
+
+func TestBrowseCommitsAndCommit(t *testing.T) {
+ x := newHarness(t)
+ head, _ := x.seedBrowseRepo("alice/app", "main")
+ session := x.login(x.mintTokenFor(x.admin.ID))
+
+ rec := x.do(http.MethodGet, "/browse/alice/app/-/commits?ref=main", nil, session)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("commits = %d (%s)", rec.Code, rec.Body)
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, "first") || !strings.Contains(body, "second") {
+ t.Fatalf("commits body missing subjects:\n%s", body)
+ }
+
+ rec = x.do(http.MethodGet, "/browse/alice/app/-/commit/"+head, nil, session)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("commit = %d (%s)", rec.Code, rec.Body)
+ }
+ if !strings.Contains(rec.Body.String(), head) {
+ t.Fatalf("commit body missing sha:\n%s", rec.Body)
+ }
+}
+
+func TestBrowseNotFoundAndTraversal(t *testing.T) {
+ x := newHarness(t)
+ x.seedBrowseRepo("alice/app", "main")
+ session := x.login(x.mintTokenFor(x.admin.ID))
+
+ cases := []struct {
+ name, target string
+ }{
+ {"unknown repo", "/browse/bob/ghost/-/tree/?ref=main"},
+ {"unknown ref", "/browse/alice/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/alice/app/-/commits?ref=main&path=../etc/passwd"},
+ {"missing blob", "/browse/alice/app/-/blob/nope.txt?ref=main"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ rec := x.do(http.MethodGet, tc.target, nil, session)
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("%s = %d, want 404 (%s)", tc.name, rec.Code, rec.Body)
+ }
+ })
+ }
+}
diff --git a/internal/webui/data.go b/internal/webui/data.go
index 5cfa7e0..56fe8a0 100644
--- a/internal/webui/data.go
+++ b/internal/webui/data.go
@@ -1,6 +1,9 @@
package webui
-import "github.com/atmin/gitmote/internal/meta"
+import (
+ "github.com/atmin/gitmote/internal/meta"
+ "github.com/atmin/gitmote/internal/repo"
+)
// base is embedded in every authenticated page's data; its fields are promoted
// so templates reference them as .Me / .Flash / .Err regardless of page.
@@ -39,3 +42,59 @@ type aclsData struct {
Selected string // currently viewed repo name
ACLs []meta.ACL // the selected repo's grants
}
+
+// --- browse ---
+
+// refChoice is one entry in the ref switcher: a display name (short, e.g.
+// "main") and the value to pass as ?ref=.
+type refChoice struct {
+ Name string
+ Ref string
+}
+
+// crumb is one segment of a path breadcrumb, linking to the tree at that depth.
+type crumb struct {
+ Name string
+ Path string
+}
+
+// browseBase is embedded in every browse page: repo identity, the selected ref,
+// 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
+ Refs []refChoice // branches and tags for the switcher
+}
+
+type treeData struct {
+ browseBase
+ Path string
+ Crumbs []crumb
+ Entries []repo.TreeEntry
+}
+
+type blobData struct {
+ browseBase
+ Path string
+ Crumbs []crumb
+ Text string // decoded text content (text branch); empty for binary
+ Binary bool
+ Size int64
+}
+
+type commitsData struct {
+ browseBase
+ Path string // path scope, if any
+ Crumbs []crumb // breadcrumb for the scoped path (nil when unscoped)
+ Commits []repo.Commit
+ More bool // history was capped; more commits exist beyond the shown page
+}
+
+type commitData struct {
+ browseBase
+ Path string // always empty; present so the shared browse_head renders
+ Crumbs []crumb // always nil; present so the shared browse_head renders
+ Commit repo.Commit
+ Diff string
+}
diff --git a/internal/webui/templates/browse_blob.html b/internal/webui/templates/browse_blob.html
new file mode 100644
index 0000000..9d60f81
--- /dev/null
+++ b/internal/webui/templates/browse_blob.html
@@ -0,0 +1,9 @@
+{{define "browse_blob.html"}}{{template "top" .}}
+{{template "browse_head" .}}
+<p>{{.Size}} bytes · <a href="/browse/{{.Repo}}/-/raw/{{.Path}}?ref={{.Ref}}">raw</a></p>
+{{if .Binary}}
+<p>Binary file — <a href="/browse/{{.Repo}}/-/raw/{{.Path}}?ref={{.Ref}}">download</a>.</p>
+{{else}}
+<pre>{{.Text}}</pre>
+{{end}}
+{{template "bottom" .}}{{end}}
diff --git a/internal/webui/templates/browse_commit.html b/internal/webui/templates/browse_commit.html
new file mode 100644
index 0000000..0a14978
--- /dev/null
+++ b/internal/webui/templates/browse_commit.html
@@ -0,0 +1,10 @@
+{{define "browse_commit.html"}}{{template "top" .}}
+{{template "browse_head" .}}
+<h1>{{.Commit.Subject}}</h1>
+<p>
+ <code>{{.Commit.SHA}}</code><br>
+ {{.Commit.Author}} <{{.Commit.Email}}> · {{.Commit.When.Format "2006-01-02 15:04"}}
+</p>
+{{if .Commit.Body}}<pre>{{.Commit.Body}}</pre>{{end}}
+<pre>{{.Diff}}</pre>
+{{template "bottom" .}}{{end}}
diff --git a/internal/webui/templates/browse_commits.html b/internal/webui/templates/browse_commits.html
new file mode 100644
index 0000000..ea903f3
--- /dev/null
+++ b/internal/webui/templates/browse_commits.html
@@ -0,0 +1,20 @@
+{{define "browse_commits.html"}}{{template "top" .}}
+{{template "browse_head" .}}
+<h1>History{{if .Path}} · {{.Path}}{{end}}</h1>
+<table>
+ <thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>When</th></tr></thead>
+ <tbody>
+ {{range .Commits}}
+ <tr>
+ <td><a href="/browse/{{$.Repo}}/-/commit/{{.SHA}}?ref={{$.Ref}}"><code>{{slice .SHA 0 10}}</code></a></td>
+ <td>{{.Subject}}</td>
+ <td>{{.Author}}</td>
+ <td>{{.When.Format "2006-01-02 15:04"}}</td>
+ </tr>
+ {{else}}
+ <tr><td colspan="4">no commits</td></tr>
+ {{end}}
+ </tbody>
+</table>
+{{if .More}}<p>Showing the most recent commits — more history exists beyond this page.</p>{{end}}
+{{template "bottom" .}}{{end}}
diff --git a/internal/webui/templates/browse_head.html b/internal/webui/templates/browse_head.html
new file mode 100644
index 0000000..15c7f61
--- /dev/null
+++ b/internal/webui/templates/browse_head.html
@@ -0,0 +1,19 @@
+{{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="/browse/{{.Repo}}/-/commits?ref={{.Ref}}{{if .Path}}&path={{.Path}}{{end}}">history</a>
+</p>
+{{if .Refs}}
+<form class="inline" method="get" action="/browse/{{.Repo}}/-/tree/{{.Path}}">
+ <label>Ref
+ <select name="ref" onchange="this.form.submit()">
+ {{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_tree.html b/internal/webui/templates/browse_tree.html
new file mode 100644
index 0000000..2596615
--- /dev/null
+++ b/internal/webui/templates/browse_tree.html
@@ -0,0 +1,26 @@
+{{define "browse_tree.html"}}{{template "top" .}}
+{{template "browse_head" .}}
+<table>
+ <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>
+ {{end}}
+ {{range .Entries}}
+ <tr>
+ {{if eq .Type "tree"}}
+ <td>📁 <a href="/browse/{{$.Repo}}/-/tree/{{.Path}}?ref={{$.Ref}}">{{.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>{{.Mode}}</td><td>{{.Size}}</td>
+ {{else}}
+ <td>🔗 {{.Name}}</td><td>{{.Mode}}</td><td></td>
+ {{end}}
+ </tr>
+ {{else}}
+ <tr><td colspan="3">empty tree</td></tr>
+ {{end}}
+ </tbody>
+</table>
+{{template "bottom" .}}{{end}}
diff --git a/internal/webui/templates/repos.html b/internal/webui/templates/repos.html
index 248db60..ab32a44 100644
--- a/internal/webui/templates/repos.html
+++ b/internal/webui/templates/repos.html
@@ -7,7 +7,7 @@
<tbody>
{{range .Repos}}
<tr>
- <td>{{.Name}}</td>
+ <td><a href="/browse/{{.Name}}/-/tree/">{{.Name}}</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 5b8a11b..1bbdc84 100644
--- a/internal/webui/webui.go
+++ b/internal/webui/webui.go
@@ -1,8 +1,10 @@
// Package webui serves the authenticated management UI — the operations that
// can't be done over git: create/list repos, mint/revoke tokens, and manage
-// per-repo ACLs (docs/architecture/storage.md, the "web UI" component). It is a
-// thin server-rendered layer over the same s3lite tables the git path uses; it
-// introduces no new source of truth.
+// per-repo ACLs (docs/architecture/storage.md, the "web UI" component). It also
+// serves read-only repo browsing (tree/blob/raw/commits/commit) over the
+// materialized bare repo. It is a thin server-rendered layer over the same
+// s3lite tables and object store the git path uses; it introduces no new source
+// of truth.
//
// Access is gated on the global-admin role (users.is_admin). A browser logs in
// by pasting a personal access token once (verified through the same path git
@@ -23,6 +25,7 @@ import (
"github.com/atmin/gitmote/internal/auth"
"github.com/atmin/gitmote/internal/meta"
+ "github.com/atmin/gitmote/internal/repo"
)
//go:embed templates/*.html
@@ -46,6 +49,7 @@ var nameSegment = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)
// Handler serves the management UI.
type Handler struct {
md *meta.Metadata
+ mz *repo.Materializer
auth Authenticator
sess *sessions
tmpl *template.Template
@@ -54,12 +58,23 @@ type Handler struct {
}
// New builds the UI handler. cookieKey signs session cookies and must be
-// non-empty; the auth verifier backs the login form.
-func New(md *meta.Metadata, a Authenticator, cookieKey []byte, logger *slog.Logger) (*Handler, error) {
+// non-empty; the auth verifier backs the login form; mz materializes repos for
+// the read-only browse pages.
+func New(md *meta.Metadata, mz *repo.Materializer, a Authenticator, cookieKey []byte, logger *slog.Logger) (*Handler, error) {
if len(cookieKey) == 0 {
return nil, errors.New("webui: empty cookie key")
}
- tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
+ tmpl, err := template.New("webui").Funcs(template.FuncMap{
+ // parentPath is the enclosing directory of a browse path, "" at the root
+ // — used only to build the ".." link in the tree view.
+ "parentPath": func(p string) string {
+ p = strings.Trim(p, "/")
+ if i := strings.LastIndex(p, "/"); i >= 0 {
+ return p[:i]
+ }
+ return ""
+ },
+ }).ParseFS(templatesFS, "templates/*.html")
if err != nil {
return nil, err
}
@@ -68,6 +83,7 @@ func New(md *meta.Metadata, a Authenticator, cookieKey []byte, logger *slog.Logg
}
return &Handler{
md: md,
+ mz: mz,
auth: a,
sess: &sessions{key: cookieKey},
tmpl: tmpl,
@@ -95,6 +111,11 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("GET /ui/acls", h.requireAdmin(h.getACLs))
mux.HandleFunc("POST /ui/acls", h.requireAdmin(h.postACL))
mux.HandleFunc("POST /ui/acls/revoke", h.requireAdmin(h.postRevokeACL))
+
+ // Read-only browsing. The repo name contains slashes and 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.requireAdmin(h.browse))
}
// ctxUser carries the authenticated admin from the middleware to the handler.
diff --git a/internal/webui/webui_test.go b/internal/webui/webui_test.go
index 05e5568..054f27a 100644
--- a/internal/webui/webui_test.go
+++ b/internal/webui/webui_test.go
@@ -13,6 +13,8 @@ import (
"github.com/atmin/gitmote/internal/auth"
"github.com/atmin/gitmote/internal/meta"
+ "github.com/atmin/gitmote/internal/repo"
+ "github.com/atmin/gitmote/internal/store"
)
// harness wires a real meta DB + auth guard behind the UI handler, seeds one
@@ -21,6 +23,7 @@ type harness struct {
t *testing.T
h *Handler
md *meta.Metadata
+ store store.Store
mux *http.ServeMux
admin *meta.User
}
@@ -34,7 +37,9 @@ func newHarness(t *testing.T) *harness {
}
t.Cleanup(func() { _ = md.Close() })
- h, err := New(md, auth.NewGuard(md), []byte("test-cookie-key"), nil)
+ objs := store.NewMem()
+ mz := repo.New(md, objs, t.TempDir())
+ h, err := New(md, mz, auth.NewGuard(md), []byte("test-cookie-key"), nil)
if err != nil {
t.Fatalf("New: %v", err)
}
@@ -45,7 +50,7 @@ func newHarness(t *testing.T) *harness {
if err != nil {
t.Fatalf("CreateAdmin: %v", err)
}
- return &harness{t: t, h: h, md: md, mux: mux, admin: admin}
+ return &harness{t: t, h: h, md: md, store: objs, mux: mux, admin: admin}
}
// mintTokenFor mints a token for an existing user and returns the raw string.