bidet

commit e2c0ba88ca54836cd58fce3f4052860786b92c33

tree

parent:
966bf149ae7ecdf11b644f19c529f04b0168c580

nmyk <nick@nmyk.io>

2026-02-17T15:52:32-05:00

dance this mess around

diff --git a/.gitignore b/.gitignore
index 5c741111b4a4fe370275ef61f2ca479173da4f45..d40673f79b55b1fd2b391aecc3f03b52a2b0b9c6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
 *.git
 vendor/
+.DS_Store
\ No newline at end of file
diff --git a/cmd/bidet/main.go b/cmd/bidet/main.go
new file mode 100644
index 0000000000000000000000000000000000000000..a3da711b81909251c33d6d225ad55e41a0c92679
--- /dev/null
+++ b/cmd/bidet/main.go
@@ -0,0 +1,15 @@
+package main
+
+import (
+	"log"
+	"net/http"
+
+	"nmyk.io/bidet/internal/handlers"
+)
+
+const addr = ":8080"
+
+func main() {
+	log.Printf("Serving on %s\n", addr)
+	log.Fatal(http.ListenAndServe(addr, handlers.Routes()))
+}
diff --git a/go.mod b/go.mod
index 0cc96d7da5ecf3428aee037c0a9d5a232c639998..45053c62ff6d84549a834f84ba40827a56d0be76 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
-module bidet
+module nmyk.io/bidet
 
-go 1.24.0
+go 1.25.0
 
 require github.com/go-git/go-git/v5 v5.16.5
 
diff --git a/internal/core/git.go b/internal/core/git.go
new file mode 100644
index 0000000000000000000000000000000000000000..46aa7a5b796c20158e13b1ac96220343c850e1b1
--- /dev/null
+++ b/internal/core/git.go
@@ -0,0 +1,136 @@
+package core
+
+import (
+	"errors"
+	"os"
+	"path"
+	"strings"
+
+	"github.com/go-git/go-git/v5"
+	"github.com/go-git/go-git/v5/plumbing"
+	"github.com/go-git/go-git/v5/plumbing/object"
+)
+
+const DOT_GIT = ".git"
+
+func isBareRepo(e os.DirEntry) bool {
+	return (e.IsDir() && strings.HasSuffix(e.Name(), DOT_GIT) && e.Name() != DOT_GIT)
+}
+
+func List(dir string) ([]string, error) {
+	entries, err := os.ReadDir(dir)
+	if err != nil {
+		return nil, err
+	}
+	var repos []string
+	for _, e := range entries {
+		if isBareRepo(e) {
+			repos = append(repos, strings.TrimSuffix(e.Name(), DOT_GIT))
+		}
+	}
+	if len(repos) == 0 {
+		return nil, errors.New("No repos found in " + dir)
+	}
+	return repos, nil
+}
+
+type Repo struct {
+	*git.Repository
+}
+
+func Open(repoName string) (*Repo, error) {
+	if _, err := os.Stat(repoName + DOT_GIT); os.IsNotExist(err) {
+		return nil, err
+	}
+	repo, err := git.PlainOpen(repoName + DOT_GIT)
+	if err != nil {
+		return nil, err
+	}
+	return &Repo{repo}, nil
+}
+
+func (repo *Repo) Load(entry *object.TreeEntry) (string, error) {
+	file, err := repo.BlobObject(entry.Hash)
+	if err != nil {
+		return "", err
+	}
+	reader, err := file.Reader()
+	if err != nil {
+		return "", err
+	}
+	defer reader.Close()
+
+	content := make([]byte, file.Size)
+	reader.Read(content)
+	return string(content), nil
+}
+
+type Commit struct {
+	*object.Commit
+	Hash    plumbing.Hash
+	RefName string
+}
+
+func (repo *Repo) ParseRoute(route string) (*Commit, string) {
+	if route == "" {
+		return nil, ""
+	}
+	candidate := strings.TrimPrefix(route, "/")
+	for {
+		ref, err := repo.resolveRef(candidate)
+		var relPath string
+		if err == nil { // candidate is a tag or a branch name
+			if len(candidate) < len(route) {
+				relPath = strings.TrimPrefix(route[len(candidate):], "/")
+			}
+			refName := ref.Name().Short()
+			commit, _ := repo.resolveCommit(refName)
+			return &Commit{commit, commit.Hash, refName}, relPath
+		}
+		commit, err := repo.resolveCommit(candidate)
+		if err == nil { // candidate is a commit hash
+			if len(candidate) < len(route) {
+				relPath = strings.TrimPrefix(route[len(candidate):], "/")
+			}
+			return &Commit{commit, commit.Hash, ""}, relPath
+		}
+		parent := path.Dir(candidate)
+		if parent == "." || parent == candidate {
+			break
+		}
+		candidate = parent
+	}
+	return nil, ""
+}
+
+func (repo *Repo) resolveCommit(name string) (*object.Commit, error) {
+	hash, err := repo.ResolveRevision(plumbing.Revision(name))
+	if err != nil {
+		return nil, err
+	}
+	return repo.CommitObject(*hash)
+}
+
+func (repo *Repo) resolveRef(name string) (*plumbing.Reference, error) {
+	// first check for branches with the given name
+	branchRef := plumbing.ReferenceName(path.Join("refs", "heads", name))
+	ref, err := repo.Reference(branchRef, true)
+	if err == nil {
+		return ref, nil
+	}
+	// then tags
+	tagRef := plumbing.ReferenceName(path.Join("refs", "tags", name))
+	ref, err = repo.Reference(tagRef, true)
+	if err != nil {
+		return nil, err
+	}
+	obj, err := repo.Object(plumbing.AnyObject, ref.Hash())
+	if err != nil {
+		return nil, err
+	}
+	// if it's an annotated tag, extract the target
+	if tagObj, ok := obj.(*object.Tag); ok {
+		ref = plumbing.NewHashReference(ref.Name(), tagObj.Target)
+	}
+	return ref, nil
+}
diff --git a/internal/handlers/handler.go b/internal/handlers/handler.go
new file mode 100644
index 0000000000000000000000000000000000000000..30d441a267daf1c079f95ecef36cc6b954f48a71
--- /dev/null
+++ b/internal/handlers/handler.go
@@ -0,0 +1,468 @@
+package handlers
+
+import (
+	"bytes"
+	"embed"
+	"html/template"
+	"net/http"
+	"path"
+	"slices"
+	"strings"
+	"time"
+
+	"github.com/go-git/go-git/v5"
+	"github.com/go-git/go-git/v5/plumbing"
+	"github.com/go-git/go-git/v5/plumbing/filemode"
+	"github.com/go-git/go-git/v5/plumbing/object"
+	"nmyk.io/bidet/internal/core"
+)
+
+func Routes() *http.ServeMux {
+	mux := http.NewServeMux()
+	mux.HandleFunc("GET /", ListRepos)
+	mux.HandleFunc("GET /{name}", Repo)
+	mux.HandleFunc("GET /{name}/refs", Refs)
+	mux.HandleFunc("GET /{name}/refs/{type}", Refs)
+	mux.HandleFunc("GET /{name}/tree/{path...}", Tree)
+	mux.HandleFunc("GET /{name}/blob/{path...}", Blob)
+	mux.HandleFunc("GET /{name}/commits/{path...}", Commits)
+	mux.HandleFunc("GET /{name}/commit/{hash}", Commit)
+	return mux
+}
+
+//go:embed templates/*.tmpl
+var Templates embed.FS
+
+//go:embed static/style.css
+var styleCSS []byte
+
+func Serve(w http.ResponseWriter, tmplName string, data any) {
+	tmpl := template.Must(template.New("").
+		Funcs(template.FuncMap{
+			"css": func() template.CSS {
+				return template.CSS(styleCSS)
+			},
+		}).
+		ParseFS(
+			Templates,
+			"templates/base.tmpl",
+			"templates/"+tmplName+".tmpl",
+		),
+	)
+
+	var buf bytes.Buffer
+	if err := tmpl.ExecuteTemplate(&buf, tmplName, data); err != nil {
+		http.Error(w, err.Error(), 500)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	buf.WriteTo(w)
+}
+
+func ListRepos(w http.ResponseWriter, _ *http.Request) {
+	repos, err := core.List(".")
+	if err != nil {
+		http.Error(w, err.Error(), 500)
+		return
+	}
+	Serve(w, "repos", repos)
+}
+
+type BranchMeta struct {
+	Name string
+}
+
+type TagMeta struct {
+	Name string
+}
+
+type RefsData struct {
+	Repo     string
+	Type     string
+	Branches []BranchMeta
+	Tags     []TagMeta
+}
+
+func Refs(w http.ResponseWriter, r *http.Request) {
+	repoName := r.PathValue("name")
+	repo, err := core.Open(repoName)
+	if err != nil {
+		http.Error(w, err.Error(), 500)
+		return
+	}
+	branchIter, err := repo.Branches()
+	if err != nil {
+		http.Error(w, err.Error(), 500)
+	}
+	var branches []BranchMeta
+	err = branchIter.ForEach(func(ref *plumbing.Reference) error {
+		branches = append(branches, BranchMeta{ref.Name().Short()})
+		return nil
+	})
+	if err != nil {
+		http.Error(w, err.Error(), 500)
+	}
+	tagIter, err := repo.Tags()
+	if err != nil {
+		http.Error(w, err.Error(), 500)
+	}
+	var tags []TagMeta
+	err = tagIter.ForEach(func(ref *plumbing.Reference) error {
+		tags = append(tags, TagMeta{ref.Name().Short()})
+		return nil
+	})
+	if err != nil {
+		http.Error(w, err.Error(), 500)
+	}
+	refType := r.PathValue("type")
+	if !slices.Contains([]string{"branches", "tags"}, refType) {
+		refType = "branches"
+	}
+	data := RefsData{
+		Repo:     repoName,
+		Type:     refType,
+		Branches: branches,
+		Tags:     tags,
+	}
+	Serve(w, "refs", data)
+}
+
+type CommitMeta struct {
+	Hash    string
+	Author  string
+	When    string
+	Message string
+}
+
+type CommitsData struct {
+	Repo    string
+	Ref     string
+	Commits []CommitMeta
+}
+
+func fmtAuthor(name string, email string) string {
+	return name + " <" + email + ">"
+}
+
+func Commits(w http.ResponseWriter, r *http.Request) {
+	repoName := r.PathValue("name")
+	repo, err := core.Open(repoName)
+	if err != nil {
+		http.Error(w, err.Error(), 500)
+		return
+	}
+	commit, _ := repo.ParseRoute(r.PathValue("path"))
+	iter, err := repo.Log(&git.LogOptions{
+		From: commit.Hash,
+	})
+	if err != nil {
+		http.Error(w, err.Error(), 500)
+	}
+	commits := []CommitMeta{}
+	err = iter.ForEach(func(c *object.Commit) error {
+		commits = append(commits, CommitMeta{
+			Hash:    c.Hash.String(),
+			Author:  fmtAuthor(c.Author.Name, c.Author.Email),
+			When:    c.Author.When.Format(time.RFC3339),
+			Message: c.Message,
+		})
+		return nil
+	})
+	if err != nil {
+		http.Error(w, err.Error(), 500)
+	}
+	data := CommitsData{
+		Repo:    repoName,
+		Ref:     commit.RefName,
+		Commits: commits,
+	}
+	Serve(w, "commits", data)
+}
+
+type CommitData struct {
+	CommitMeta
+	Repo    string
+	Parents []string
+	Patch   string
+}
+
+func Commit(w http.ResponseWriter, r *http.Request) {
+	repoName := r.PathValue("name")
+	repo, err := core.Open(repoName)
+	if err != nil {
+		http.Error(w, err.Error(), 500)
+		return
+	}
+	hashStr := r.PathValue("hash")
+	hash := plumbing.NewHash(hashStr)
+	commit, err := repo.CommitObject(hash)
+	if err != nil {
+		http.Error(w, err.Error(), 500)
+		return
+	}
+	var (
+		parents []string
+		patch   string
+		i       int
+	)
+	iter := commit.Parents()
+	err = iter.ForEach(func(c *object.Commit) error {
+		if i == 0 {
+			p, err := c.Patch(commit)
+			if err != nil {
+				return err
+			}
+			patch = p.String()
+		}
+		parents = append(parents, string(c.Hash.String()))
+		return nil
+	})
+	if err != nil {
+		http.Error(w, err.Error(), 500)
+	}
+	data := CommitData{
+		CommitMeta: CommitMeta{
+			Hash:    commit.Hash.String(),
+			Author:  fmtAuthor(commit.Author.Name, commit.Author.Email),
+			When:    commit.Author.When.Format(time.RFC3339),
+			Message: commit.Message,
+		},
+		Repo:    repoName,
+		Parents: parents,
+		Patch:   patch,
+	}
+	Serve(w, "commit", data)
+}
+
+type BlobData struct {
+	Repo     string
+	RefLabel string
+	Path     string
+	Crumbs   []Crumb
+	Content  string
+}
+
+func Blob(w http.ResponseWriter, r *http.Request) {
+	repoName := r.PathValue("name")
+	repo, err := core.Open(repoName)
+	if err != nil {
+		http.Error(w, err.Error(), 500)
+		return
+	}
+	commit, filePath := repo.ParseRoute(r.PathValue("path"))
+	tree, err := commit.Tree()
+	if err != nil {
+		http.Error(w, err.Error(), 500)
+		return
+	}
+	entry, err := tree.FindEntry(filePath)
+	if err != nil {
+		http.NotFound(w, nil)
+		return
+	}
+	content, err := repo.Load(entry)
+	if err != nil {
+		http.Error(w, err.Error(), 500)
+	}
+	var refLabel string
+	if commit.RefName != "" {
+		refLabel = commit.RefName
+	} else {
+		refLabel = commit.Hash.String()
+	}
+	data := BlobData{
+		Repo:     repoName,
+		RefLabel: refLabel,
+		Path:     filePath,
+		Crumbs:   breadcrumbs(repoName, refLabel, filePath),
+		Content:  content,
+	}
+	Serve(w, "blob", data)
+}
+
+type TreeData struct {
+	Repo       string
+	Ref        string
+	RefLabel   string
+	Commit     string
+	HeadRef    string
+	Path       string
+	Crumbs     []Crumb
+	Entries    []EntryMeta
+	ReadmeName string
+	Readme     string
+}
+
+type EntryMeta struct {
+	Name  string
+	Path  string
+	IsDir bool
+}
+
+func Tree(w http.ResponseWriter, r *http.Request) {
+	repoName := r.PathValue("name")
+	repo, err := core.Open(repoName)
+	if err != nil {
+		http.Error(w, err.Error(), 500)
+		return
+	}
+	urlPath := r.PathValue("path")
+	commit, treePath := repo.ParseRoute(urlPath)
+	head, err := repo.Head()
+	if err != nil {
+		http.Error(w, "Cannot resolve HEAD state", 500)
+	}
+	headRef := head.Name().Short()
+	if commit.RefName == headRef && treePath == "" {
+		Repo(w, r)
+		return
+	}
+	rootTree, err := commit.Tree()
+	if err != nil {
+		http.Error(w, err.Error(), 500)
+		return
+	}
+	var tree *object.Tree
+	if treePath != "" {
+		treeEntry, err := rootTree.FindEntry(treePath)
+		if err != nil {
+			http.NotFound(w, r)
+			return
+		}
+		tree, err = repo.TreeObject(treeEntry.Hash)
+		if err != nil {
+			http.Error(w, err.Error(), 500)
+		}
+	} else {
+		tree = rootTree
+	}
+	var entries []EntryMeta
+	if treePath != "" {
+		entries = []EntryMeta{
+			{
+				Name:  "..",
+				Path:  path.Dir(treePath),
+				IsDir: true,
+			},
+		}
+	}
+	var readme, readmeName string
+	for _, e := range tree.Entries {
+		fullPath := path.Join(treePath, e.Name)
+		em := EntryMeta{
+			Name:  e.Name,
+			Path:  fullPath,
+			IsDir: e.Mode == filemode.Dir,
+		}
+		if strings.HasPrefix(strings.ToLower(e.Name), "readme") {
+			readmeName = e.Name
+			var err error
+			readme, err = repo.Load(&e)
+			if err != nil {
+				http.Error(w, err.Error(), 500)
+			}
+		}
+		entries = append(entries, em)
+	}
+	var refLabel string
+	if commit.RefName != "" {
+		refLabel = commit.RefName
+	} else {
+		refLabel = commit.Hash.String()
+	}
+	data := TreeData{
+		Repo:       repoName,
+		RefLabel:   refLabel,
+		Ref:        commit.RefName,
+		HeadRef:    headRef,
+		Path:       treePath,
+		Crumbs:     breadcrumbs(repoName, refLabel, treePath),
+		Entries:    entries,
+		ReadmeName: readmeName,
+		Readme:     readme,
+	}
+	Serve(w, "tree", data)
+}
+
+type Crumb struct {
+	Name   string
+	URL    string
+	IsLast bool
+}
+
+func breadcrumbs(repoName string, refLabel string, p string) []Crumb {
+	var crumbs []Crumb
+	last := true
+	cur := p
+	for {
+		base := path.Base(cur)
+		if base == "." || base == "/" {
+			break
+		}
+		crumbs = append(crumbs, Crumb{
+			Name:   base,
+			URL:    path.Join(repoName, "tree", refLabel, cur),
+			IsLast: last,
+		})
+		last = false
+		next := path.Dir(cur)
+		if next == cur {
+			break
+		}
+		cur = next
+	}
+	slices.Reverse(crumbs)
+	return crumbs
+}
+
+func Repo(w http.ResponseWriter, r *http.Request) {
+	repoName := r.PathValue("name")
+	repo, err := core.Open(repoName)
+	if err != nil {
+		http.Error(w, err.Error(), 500)
+		return
+	}
+	ref, err := repo.Head()
+	if err != nil {
+		http.Error(w, "Cannot resolve HEAD", 500)
+		return
+	}
+	commit, err := repo.CommitObject(ref.Hash())
+	if err != nil {
+		http.Error(w, err.Error(), 500)
+		return
+	}
+	tree, err := commit.Tree()
+	if err != nil {
+		http.Error(w, err.Error(), 500)
+		return
+	}
+	refName := ref.Name().Short()
+	var entries []EntryMeta
+	var readme, readmeName string
+	for _, e := range tree.Entries {
+		em := EntryMeta{
+			Name:  e.Name,
+			Path:  e.Name,
+			IsDir: e.Mode == filemode.Dir,
+		}
+		if strings.HasPrefix(strings.ToLower(e.Name), "readme") {
+			readmeName = e.Name
+			var err error
+			readme, err = repo.Load(&e)
+			if err != nil {
+				http.Error(w, err.Error(), 500)
+			}
+		}
+		entries = append(entries, em)
+	}
+	data := TreeData{
+		Repo:       repoName,
+		Ref:        refName,
+		Entries:    entries,
+		ReadmeName: readmeName,
+		Readme:     readme,
+	}
+	Serve(w, "repo", data)
+}
diff --git a/internal/handlers/static/style.css b/internal/handlers/static/style.css
new file mode 100644
index 0000000000000000000000000000000000000000..d444c08d5f2a27c0a080c6563935313a707a9ad0
--- /dev/null
+++ b/internal/handlers/static/style.css
@@ -0,0 +1,74 @@
+@media (prefers-color-scheme: dark) {
+    :root {
+        --main-text-color: gainsboro;
+        --background-color: black;
+        --alt-text-color: lightskyblue;
+        --header-color: plum;
+        --accent-color: palegreen;
+        --unaccent-color: slategrey;
+        --contrast-color: lightcoral;
+        --selection-bg-color: navy;
+    }
+}
+
+@media (prefers-color-scheme: light) {
+    :root {
+        --main-text-color: black;
+        --background-color: gainsboro;
+        --alt-text-color: mediumblue;
+        --header-color: purple;
+        --accent-color: green;
+        --unaccent-color: slategrey;
+        --contrast-color: firebrick;
+        --selection-bg-color: skyblue;
+    }
+}
+
+body {
+    color: var(--main-text-color);
+    background: var(--background-color);
+    margin: auto;
+    max-width: 80ch;
+    font-size: 14px;
+    padding: 1em;
+    font-family: monospace;
+    word-wrap: break-word;
+}
+
+h1,
+h2,
+h3 {
+    color: var(--header-color);
+}
+
+::selection {
+    background: var(--selection-bg-color);
+}
+
+hr {
+    border-color: var(--header-color);
+}
+
+a {
+    color: var(--alt-text-color);
+    text-decoration: underline;
+}
+
+a:hover {
+    color: var(--alt-text-color);
+    text-decoration: underline;
+}
+
+ul {
+    margin: 0px;
+    padding-left: 20px;
+}
+
+ul {
+    list-style-type: "";
+}
+
+pre {
+    overflow-x: auto;
+    padding-bottom: 10px;
+}
\ No newline at end of file
diff --git a/internal/handlers/templates/base.tmpl b/internal/handlers/templates/base.tmpl
new file mode 100644
index 0000000000000000000000000000000000000000..7c11b26b0a6bd1ec08d0e2724bde19e46d1945e6
--- /dev/null
+++ b/internal/handlers/templates/base.tmpl
@@ -0,0 +1,13 @@
+{{define "base"}}
+<!DOCTYPE html>
+<html>
+<head>
+    <meta charset="UTF-8">
+    <title>{{block "title" .}}Default{{end}}</title>
+    <style>{{ css }}</style>
+</head>
+<body>
+    {{block "content" . }}{{end}}
+</body>
+</html>
+{{end}}
\ No newline at end of file
diff --git a/main.go b/main.go
deleted file mode 100644
index 91f51d533db929d4af3dcf0742a47071f54504f4..0000000000000000000000000000000000000000
--- a/main.go
+++ /dev/null
@@ -1,588 +0,0 @@
-package main
-
-import (
-	"bytes"
-	"embed"
-	"html/template"
-	"log"
-	"net/http"
-	"os"
-	"path"
-	"slices"
-	"strings"
-	"time"
-
-	git "github.com/go-git/go-git/v5"
-	"github.com/go-git/go-git/v5/plumbing"
-	"github.com/go-git/go-git/v5/plumbing/filemode"
-	"github.com/go-git/go-git/v5/plumbing/object"
-)
-
-//go:embed templates/*.tmpl
-var templates embed.FS
-
-const addr = ":8080"
-const DOT_GIT = ".git"
-
-func main() {
-	log.Printf("Serving on %s\n", addr)
-	log.Fatal(http.ListenAndServe(addr, router()))
-}
-
-type Repo struct {
-	*git.Repository
-	Name string
-}
-
-func router() *http.ServeMux {
-	mux := http.NewServeMux()
-	mux.HandleFunc("GET /", listRepos)
-	mux.HandleFunc("GET /{name}", repoIndex)
-	mux.HandleFunc("GET /{name}/refs", refs)
-	mux.HandleFunc("GET /{name}/refs/{type}", refs)
-	mux.HandleFunc("GET /{name}/tree/{path...}", repoTree)
-	mux.HandleFunc("GET /{name}/blob/{path...}", blob)
-	mux.HandleFunc("GET /{name}/commits/{path...}", commits)
-	mux.HandleFunc("GET /{name}/commit/{hash}", commit)
-	return mux
-}
-
-func serve(w http.ResponseWriter, name string, data any) {
-	tmpl := template.Must(template.ParseFS(
-		templates,
-		"templates/base.tmpl",
-		"templates/"+name+".tmpl",
-	))
-	var buf bytes.Buffer
-	if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
-		http.Error(w, err.Error(), 500)
-		return
-	}
-	w.WriteHeader(http.StatusOK)
-	buf.WriteTo(w)
-}
-
-func listRepos(w http.ResponseWriter, _ *http.Request) {
-	entries, err := os.ReadDir(".")
-	if err != nil {
-		http.Error(w, err.Error(), 500)
-		return
-	}
-	var repos []string
-	isBareRepo := func(e os.DirEntry) bool {
-		return (e.IsDir() &&
-			strings.HasSuffix(e.Name(), DOT_GIT) &&
-			e.Name() != DOT_GIT)
-	}
-	for _, e := range entries {
-		if isBareRepo(e) {
-			repos = append(repos, strings.TrimSuffix(e.Name(), DOT_GIT))
-		}
-	}
-	serve(w, "repos", repos)
-}
-
-func load(repo *git.Repository, entry *object.TreeEntry) (string, error) {
-	file, err := repo.BlobObject(entry.Hash)
-	if err != nil {
-		return "", err
-	}
-	reader, err := file.Reader()
-	if err != nil {
-		return "", err
-	}
-	defer reader.Close()
-
-	content := make([]byte, file.Size)
-	reader.Read(content)
-	return string(content), nil
-}
-
-type BranchMeta struct {
-	Name string
-}
-
-type TagMeta struct {
-	Name string
-}
-
-type RefsData struct {
-	Repo     string
-	Type     string
-	Branches []BranchMeta
-	Tags     []TagMeta
-}
-
-func refs(w http.ResponseWriter, r *http.Request) {
-	repoName := r.PathValue("name")
-	if _, err := os.Stat(repoName + DOT_GIT); os.IsNotExist(err) {
-		http.NotFound(w, r)
-		return
-	}
-	repo, err := git.PlainOpen(repoName + DOT_GIT)
-	if err != nil {
-		http.Error(w, err.Error(), 500)
-		return
-	}
-	branchIter, err := repo.Branches()
-	if err != nil {
-		http.Error(w, err.Error(), 500)
-	}
-	var branches []BranchMeta
-	err = branchIter.ForEach(func(ref *plumbing.Reference) error {
-		branches = append(branches, BranchMeta{ref.Name().Short()})
-		return nil
-	})
-	if err != nil {
-		http.Error(w, err.Error(), 500)
-	}
-	tagIter, err := repo.Tags()
-	if err != nil {
-		http.Error(w, err.Error(), 500)
-	}
-	var tags []TagMeta
-	err = tagIter.ForEach(func(ref *plumbing.Reference) error {
-		tags = append(tags, TagMeta{ref.Name().Short()})
-		return nil
-	})
-	if err != nil {
-		http.Error(w, err.Error(), 500)
-	}
-	refType := r.PathValue("type")
-	if !slices.Contains([]string{"branches", "tags"}, refType) {
-		refType = "branches"
-	}
-	data := RefsData{
-		Repo:     repoName,
-		Type:     refType,
-		Branches: branches,
-		Tags:     tags,
-	}
-	serve(w, "refs", data)
-}
-
-type CommitMeta struct {
-	Hash    string
-	Author  string
-	When    string
-	Message string
-}
-
-type CommitsData struct {
-	Repo    string
-	Ref     string
-	Commits []CommitMeta
-}
-
-func fmtAuthor(name string, email string) string {
-	return name + " <" + email + ">"
-}
-
-func commits(w http.ResponseWriter, r *http.Request) {
-	repoName := r.PathValue("name")
-	if _, err := os.Stat(repoName + DOT_GIT); os.IsNotExist(err) {
-		http.NotFound(w, r)
-		return
-	}
-	repo, err := git.PlainOpen(repoName + DOT_GIT)
-	if err != nil {
-		http.Error(w, err.Error(), 500)
-		return
-	}
-	commit, _ := parse(repo, r.PathValue("path"))
-	iter, err := repo.Log(&git.LogOptions{
-		From: commit.Hash,
-	})
-	if err != nil {
-		http.Error(w, err.Error(), 500)
-	}
-	commits := []CommitMeta{}
-	err = iter.ForEach(func(c *object.Commit) error {
-		commits = append(commits, CommitMeta{
-			Hash:    c.Hash.String(),
-			Author:  fmtAuthor(c.Author.Name, c.Author.Email),
-			When:    c.Author.When.Format(time.RFC3339),
-			Message: c.Message,
-		})
-		return nil
-	})
-	if err != nil {
-		http.Error(w, err.Error(), 500)
-	}
-	data := CommitsData{
-		Repo:    repoName,
-		Ref:     commit.RefName,
-		Commits: commits,
-	}
-	serve(w, "commits", data)
-}
-
-type CommitData struct {
-	Repo    string
-	Hash    string
-	Author  string
-	When    string
-	Message string
-	Parents []string
-	Patch   string
-}
-
-func commit(w http.ResponseWriter, r *http.Request) {
-	repoName := r.PathValue("name")
-	if _, err := os.Stat(repoName + DOT_GIT); os.IsNotExist(err) {
-		http.NotFound(w, r)
-		return
-	}
-	repo, err := git.PlainOpen(repoName + DOT_GIT)
-	if err != nil {
-		http.Error(w, err.Error(), 500)
-		return
-	}
-	hashStr := r.PathValue("hash")
-	hash := plumbing.NewHash(hashStr)
-	commit, err := repo.CommitObject(hash)
-	if err != nil {
-		http.Error(w, err.Error(), 500)
-		return
-	}
-	var (
-		parents []string
-		patch   string
-		i       int
-	)
-	iter := commit.Parents()
-	err = iter.ForEach(func(c *object.Commit) error {
-		if i == 0 {
-			p, err := c.Patch(commit)
-			if err != nil {
-				return err
-			}
-			patch = p.String()
-		}
-		parents = append(parents, string(c.Hash.String()))
-		return nil
-	})
-	if err != nil {
-		http.Error(w, err.Error(), 500)
-	}
-	data := CommitData{
-		Repo:    repoName,
-		Hash:    commit.Hash.String(),
-		Author:  fmtAuthor(commit.Author.Name, commit.Author.Email),
-		When:    commit.Author.When.Format(time.RFC3339),
-		Message: commit.Message,
-		Parents: parents,
-		Patch:   patch,
-	}
-	serve(w, "commit", data)
-}
-
-type Commit struct {
-	*object.Commit
-	Hash    plumbing.Hash
-	RefName string
-}
-
-func parse(repo *git.Repository, refAndPath string) (*Commit, string) {
-	if refAndPath == "" {
-		return nil, ""
-	}
-	candidate := strings.TrimPrefix(refAndPath, "/")
-	for {
-		ref, err := resolveRef(repo, candidate)
-		var urlPath string
-		if err == nil {
-			if len(candidate) < len(refAndPath) {
-				urlPath = strings.TrimPrefix(refAndPath[len(candidate):], "/")
-			}
-			refName := ref.Name().Short()
-			commit, _ := resolveCommit(repo, refName)
-			return &Commit{commit, commit.Hash, refName}, urlPath
-		}
-		commit, err := resolveCommit(repo, candidate)
-		if err == nil {
-			if len(candidate) < len(refAndPath) {
-				urlPath = strings.TrimPrefix(refAndPath[len(candidate):], "/")
-			}
-			return &Commit{commit, commit.Hash, ""}, urlPath
-		}
-		parent := path.Dir(candidate)
-		if parent == "." || parent == candidate {
-			break
-		}
-		candidate = parent
-	}
-	return nil, ""
-}
-
-func blob(w http.ResponseWriter, r *http.Request) {
-	repoName := r.PathValue("name")
-	if _, err := os.Stat(repoName + DOT_GIT); os.IsNotExist(err) {
-		http.NotFound(w, r)
-		return
-	}
-	repo, err := git.PlainOpen(repoName + DOT_GIT)
-	if err != nil {
-		http.Error(w, err.Error(), 500)
-		return
-	}
-	commit, filePath := parse(repo, r.PathValue("path"))
-	tree, err := commit.Tree()
-	if err != nil {
-		http.Error(w, err.Error(), 500)
-		return
-	}
-	entry, err := tree.FindEntry(filePath)
-	if err != nil {
-		http.NotFound(w, nil)
-		return
-	}
-	content, err := load(repo, entry)
-	if err != nil {
-		http.Error(w, err.Error(), 500)
-	}
-	var refLabel string
-	if commit.RefName != "" {
-		refLabel = commit.RefName
-	} else {
-		refLabel = commit.Hash.String()
-	}
-	data := struct {
-		Repo     string
-		RefLabel string
-		Base     string
-		Crumbs   []Crumb
-		Content  string
-	}{
-		Repo:     repoName,
-		RefLabel: refLabel,
-		Base:     filePath,
-		Crumbs:   breadcrumbs(repoName, refLabel, filePath),
-		Content:  content,
-	}
-	serve(w, "blob", data)
-}
-
-type TreeData struct {
-	Repo       string
-	Ref        string
-	RefLabel   string
-	Commit     string
-	HeadRef    string
-	Base       string
-	Crumbs     []Crumb
-	Entries    []EntryMeta
-	ReadmeName string
-	Readme     string
-}
-
-type EntryMeta struct {
-	Name  string
-	Path  string
-	IsDir bool
-}
-
-func resolveCommit(repo *git.Repository, name string) (*object.Commit, error) {
-	hash, err := repo.ResolveRevision(plumbing.Revision(name))
-	if err != nil {
-		return nil, err
-	}
-	return repo.CommitObject(*hash)
-}
-
-func resolveRef(repo *git.Repository, refName string) (*plumbing.Reference, error) {
-	branchRef := plumbing.ReferenceName(path.Join("refs", "heads", refName))
-	ref, err := repo.Reference(branchRef, true)
-	if err == nil {
-		return ref, nil
-	}
-	tagRef := plumbing.ReferenceName(path.Join("refs", "tags", refName))
-	ref, err = repo.Reference(tagRef, true)
-	if err != nil {
-		return nil, err
-	}
-	// Resolve annotated tag to commit hash
-	obj, err := repo.Object(plumbing.AnyObject, ref.Hash())
-	if err != nil {
-		return nil, err
-	}
-	if tagObj, ok := obj.(*object.Tag); ok {
-		ref = plumbing.NewHashReference(ref.Name(), tagObj.Target)
-	}
-	return ref, nil
-}
-
-func repoTree(w http.ResponseWriter, r *http.Request) {
-	repoName := r.PathValue("name")
-	if _, err := os.Stat(repoName + DOT_GIT); os.IsNotExist(err) {
-		http.NotFound(w, r)
-		return
-	}
-	repo, err := git.PlainOpen(repoName + DOT_GIT)
-	if err != nil {
-		http.Error(w, err.Error(), 500)
-		return
-	}
-	urlPath := r.PathValue("path")
-	commit, treePath := parse(repo, urlPath)
-	head, err := repo.Head()
-	if err != nil {
-		http.Error(w, "Cannot resolve HEAD state", 500)
-	}
-	headRef := head.Name().Short()
-	if commit.RefName == headRef && treePath == "" {
-		repoIndex(w, r)
-		return
-	}
-	rootTree, err := commit.Tree()
-	if err != nil {
-		http.Error(w, err.Error(), 500)
-		return
-	}
-	var tree *object.Tree
-	if treePath != "" {
-		treeEntry, err := rootTree.FindEntry(treePath)
-		if err != nil {
-			http.NotFound(w, r)
-			return
-		}
-		tree, err = repo.TreeObject(treeEntry.Hash)
-		if err != nil {
-			http.Error(w, err.Error(), 500)
-		}
-	} else {
-		tree = rootTree
-	}
-	var entries []EntryMeta
-	if treePath != "" {
-		entries = []EntryMeta{
-			{
-				Name:  "..",
-				Path:  path.Dir(treePath),
-				IsDir: true,
-			},
-		}
-	}
-	var readme, readmeName string
-	for _, e := range tree.Entries {
-		fullPath := path.Join(treePath, e.Name)
-		em := EntryMeta{
-			Name:  e.Name,
-			Path:  fullPath,
-			IsDir: e.Mode == filemode.Dir,
-		}
-		if strings.HasPrefix(strings.ToLower(e.Name), "readme") {
-			readmeName = e.Name
-			var err error
-			readme, err = load(repo, &e)
-			if err != nil {
-				http.Error(w, err.Error(), 500)
-			}
-		}
-		entries = append(entries, em)
-	}
-	var refLabel string
-	if commit.RefName != "" {
-		refLabel = commit.RefName
-	} else {
-		refLabel = commit.Hash.String()
-	}
-	data := TreeData{
-		Repo:       repoName,
-		RefLabel:   refLabel,
-		Ref:        commit.RefName,
-		HeadRef:    headRef,
-		Base:       treePath,
-		Crumbs:     breadcrumbs(repoName, refLabel, treePath),
-		Entries:    entries,
-		ReadmeName: readmeName,
-		Readme:     readme,
-	}
-	serve(w, "tree", data)
-}
-
-type Crumb struct {
-	Name   string
-	URL    string
-	IsLast bool
-}
-
-func breadcrumbs(repoName string, refLabel string, p string) []Crumb {
-	var crumbs []Crumb
-	last := true
-	cur := p
-	for {
-		base := path.Base(cur)
-		if base == "." || base == "/" {
-			break
-		}
-		crumbs = append(crumbs, Crumb{
-			Name:   base,
-			URL:    path.Join(repoName, "tree", refLabel, cur),
-			IsLast: last,
-		})
-		last = false
-		next := path.Dir(cur)
-		if next == cur {
-			break
-		}
-		cur = next
-	}
-	slices.Reverse(crumbs)
-	return crumbs
-}
-
-func repoIndex(w http.ResponseWriter, r *http.Request) {
-	repoName := r.PathValue("name")
-	if _, err := os.Stat(repoName + DOT_GIT); os.IsNotExist(err) {
-		http.NotFound(w, r)
-		return
-	}
-	repo, err := git.PlainOpen(repoName + DOT_GIT)
-	if err != nil {
-		http.Error(w, err.Error(), 500)
-		return
-	}
-	ref, err := repo.Head()
-	if err != nil {
-		http.Error(w, "Cannot resolve HEAD", 500)
-		return
-	}
-	commit, err := repo.CommitObject(ref.Hash())
-	if err != nil {
-		http.Error(w, err.Error(), 500)
-		return
-	}
-	tree, err := commit.Tree()
-	if err != nil {
-		http.Error(w, err.Error(), 500)
-		return
-	}
-	refName := ref.Name().Short()
-	var entries []EntryMeta
-	var readme, readmeName string
-	for _, e := range tree.Entries {
-		em := EntryMeta{
-			Name:  e.Name,
-			Path:  e.Name,
-			IsDir: e.Mode == filemode.Dir,
-		}
-		if strings.HasPrefix(strings.ToLower(e.Name), "readme") {
-			readmeName = e.Name
-			var err error
-			readme, err = load(repo, &e)
-			if err != nil {
-				http.Error(w, err.Error(), 500)
-			}
-		}
-		entries = append(entries, em)
-	}
-	data := TreeData{
-		Repo:       repoName,
-		Ref:        refName,
-		Entries:    entries,
-		ReadmeName: readmeName,
-		Readme:     readme,
-	}
-	serve(w, "repo", data)
-}
diff --git a/templates/base.tmpl b/templates/base.tmpl
deleted file mode 100644
index 3ef06f834cf217b6b14894f0a1b92cfab00f610e..0000000000000000000000000000000000000000
--- a/templates/base.tmpl
+++ /dev/null
@@ -1,14 +0,0 @@
-{{define "base"}}
-<!DOCTYPE html>
-<html>
-<head>
-    <meta charset="UTF-8">
-    <title>{{block "title" .}}Default{{end}}</title>
-    <link rel="stylesheet" href="https://nmyk.io/style.css">
-    <link rel="stylesheet" href="https://nmyk.io/doc.css">
-</head>
-<body>
-    {{block "content" .}}{{end}}
-</body>
-</html>
-{{end}}
\ No newline at end of file
diff --git a/templates/blob.tmpl b/internal/handlers/templates/blob.tmpl
rename from templates/blob.tmpl
rename to internal/handlers/templates/blob.tmpl
index 90f900a4ddb0dede3d5cf6345f6c845aacff0fed..4df727dda838c9627ec21bb3f878c47065f51de5 100644
--- a/templates/blob.tmpl
+++ b/internal/handlers/templates/blob.tmpl
@@ -1,6 +1,6 @@
-{{define "title"}}{{.Repo}}/{{.Base}}{{end}}
+{{define "title"}}{{.Repo}}/{{.Path}}{{end}}
 {{define "content"}}
-<h1>{{if eq .Base ""}}{{.Repo}}{{else}}<a href="/{{.Repo}}/tree/{{.RefLabel}}">{{.Repo}}</a> /{{end}}
+<h1>{{if eq .Path ""}}{{.Repo}}{{else}}<a href="/{{.Repo}}/tree/{{.RefLabel}}">{{.Repo}}</a> /{{end}}
 {{range $i, $c := .Crumbs}}
   {{if $i}} / {{end}}
   {{if $c.IsLast}}
diff --git a/templates/commit.tmpl b/internal/handlers/templates/commit.tmpl
rename from templates/commit.tmpl
rename to internal/handlers/templates/commit.tmpl
index 20f010e4b9e89a2a8971baa1897855df4f986fd9..adeeaaad1ee84f4b8f03d142da830cfe20f066cd 100644
--- a/templates/commit.tmpl
+++ b/internal/handlers/templates/commit.tmpl
@@ -7,8 +7,7 @@     <p>parents: {{ range .Parents }}<br><a href="/{{$.Repo}}/commit/{{.}}">{{.}}</a> {{end}}</p>
     <p>{{.Author}}</p>
     <p>{{.When}}</p>
     <pre>{{.Message}}</pre>
-    <hr>
-    {{if ne "" .Patch}}<pre>{{.Patch}}</pre>{{else}}<i>(initial commit)</i>{{end}}
+    {{if ne "" .Patch}}<hr><pre>{{.Patch}}</pre>{{end}}
 {{end}}
 
 {{define "commit"}}
diff --git a/templates/commits.tmpl b/internal/handlers/templates/commits.tmpl
rename from templates/commits.tmpl
rename to internal/handlers/templates/commits.tmpl
diff --git a/templates/refs.tmpl b/internal/handlers/templates/refs.tmpl
rename from templates/refs.tmpl
rename to internal/handlers/templates/refs.tmpl
diff --git a/templates/repo.tmpl b/internal/handlers/templates/repo.tmpl
rename from templates/repo.tmpl
rename to internal/handlers/templates/repo.tmpl
index 15ab581d855d323b3223801b752d209fb5332cbb..c741b6262e9cfa1d6a57731c297413a2bec68df6 100644
--- a/templates/repo.tmpl
+++ b/internal/handlers/templates/repo.tmpl
@@ -1,4 +1,4 @@
-{{define "title"}}{{.Repo}}/{{.Base}}{{end}}
+{{define "title"}}{{.Repo}}/{{.Path}}{{end}}
 {{define "content"}}
 <h1>{{.Repo}}</h1>
 <p>@ {{.Ref}}</p>
diff --git a/templates/repos.tmpl b/internal/handlers/templates/repos.tmpl
rename from templates/repos.tmpl
rename to internal/handlers/templates/repos.tmpl
diff --git a/templates/tree.tmpl b/internal/handlers/templates/tree.tmpl
rename from templates/tree.tmpl
rename to internal/handlers/templates/tree.tmpl
index 16bad38dd41285f8e2c50864e98ee0e2e67732d4..8ae54adaba821181839da5f0a682f2caa5f9fc0c 100644
--- a/templates/tree.tmpl
+++ b/internal/handlers/templates/tree.tmpl
@@ -1,6 +1,6 @@
-{{define "title"}}{{.Repo}}/{{.Base}}{{end}}
+{{define "title"}}{{.Repo}}/{{.Path}}{{end}}
 {{define "content"}}
-<h1>{{if ne .Base ""}}<a href="/{{.Repo}}/tree/{{.RefLabel}}">{{.Repo}}</a> /
+<h1>{{if ne .Path ""}}<a href="/{{.Repo}}/tree/{{.RefLabel}}">{{.Repo}}</a> /
 {{else}}
 <a href="/{{.Repo}}/tree/{{.HeadRef}}">{{.Repo}}</a>
 {{end}}
@@ -13,7 +13,7 @@     <a href="/{{$c.URL}}">{{$c.Name}}</a>
   {{end}}
 {{end}}</h1>
 <p>@ {{.RefLabel}}</p>
-{{if eq .Base ""}}
+{{if eq .Path ""}}
 <p><a href="/">index</a> | <a href="/{{.Repo}}/refs">refs</a>{{if ne .Ref ""}} | <a href="/{{.Repo}}/commits/{{.Ref}}">commits</a>{{end}}</p>
 {{end}}
 <ul>