parent:
d75a654d61ba5dd1809a0bfb15f0eac2daa7ee4a
nmyk <nick@nmyk.io>
2026-02-16T10:27:29-05:00
refactor with ServeMux, breadcrumb nav
diff --git a/main.go b/main.go
index 98a8c6363ff121e630f2af9815e14c00297e7f93..324555e0c08b19a42c7eb98c65160bc629b863fb 100644
--- a/main.go
+++ b/main.go
@@ -8,9 +8,11 @@ "log"
"net/http"
"os"
"path"
+ "slices"
"strings"
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"
)
@@ -19,12 +21,11 @@ //go:embed templates/*.tmpl
var templates embed.FS
const addr = ":8080"
-const dotGit = ".git"
+const DOT_GIT = ".git"
func main() {
- http.HandleFunc("/", router)
log.Printf("Serving on %s\n", addr)
- log.Fatal(http.ListenAndServe(addr, nil))
+ log.Fatal(http.ListenAndServe(addr, router()))
}
type Repo struct {
@@ -32,56 +33,14 @@ *git.Repository
Name string
}
-func router(w http.ResponseWriter, r *http.Request) {
- path := strings.Trim(r.URL.Path, "/")
-
- if path == "" {
- listRepos(w)
- return
- }
-
- parts := strings.Split(path, "/")
- repoName := parts[0]
- repoPath := repoName + dotGit
-
- if _, err := os.Stat(repoPath); os.IsNotExist(err) {
- http.NotFound(w, r)
- return
- }
-
- gitRepo, err := git.PlainOpen(repoPath)
- if err != nil {
- http.Error(w, err.Error(), 500)
- return
- }
-
- repo := Repo{gitRepo, repoName}
-
- 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
- }
-
- if len(parts) == 1 {
- renderTree(w, &repo, "", tree)
- return
- }
-
- subPath := strings.Join(parts[1:], "/")
- handlePath(w, &repo, subPath, tree)
+func router() *http.ServeMux {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /", listRepos)
+ mux.HandleFunc("GET /{name}", repoIndex)
+ mux.HandleFunc("GET /{name}/tree/{ref}", repoTree)
+ mux.HandleFunc("GET /{name}/tree/{ref}/{rest...}", repoTree)
+ mux.HandleFunc("GET /{name}/blob/{ref}/{rest...}", blob)
+ return mux
}
func serve(w http.ResponseWriter, name string, data any) {
@@ -99,7 +58,7 @@ w.WriteHeader(http.StatusOK)
buf.WriteTo(w)
}
-func listRepos(w http.ResponseWriter) {
+func listRepos(w http.ResponseWriter, _ *http.Request) {
entries, err := os.ReadDir(".")
if err != nil {
http.Error(w, err.Error(), 500)
@@ -108,91 +67,276 @@ }
var repos []string
isBareRepo := func(e os.DirEntry) bool {
return (e.IsDir() &&
- strings.HasSuffix(e.Name(), dotGit) &&
- e.Name() != dotGit)
+ strings.HasSuffix(e.Name(), DOT_GIT) &&
+ e.Name() != DOT_GIT)
}
for _, e := range entries {
if isBareRepo(e) {
- repos = append(repos, strings.TrimSuffix(e.Name(), dotGit))
+ repos = append(repos, strings.TrimSuffix(e.Name(), DOT_GIT))
}
}
serve(w, "repos", repos)
}
-func handlePath(w http.ResponseWriter, repo *Repo, subPath string, rootTree *object.Tree) {
- entry, err := rootTree.FindEntry(subPath)
+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
+}
+
+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.NotFound(w, nil)
+ http.Error(w, err.Error(), 500)
return
}
- if entry.Mode == filemode.Dir {
- tree, err := repo.TreeObject(entry.Hash)
- if err != nil {
- http.Error(w, err.Error(), 500)
- return
- }
- renderTree(w, repo, subPath, tree)
+ refName := r.PathValue("ref")
+ branch := path.Join("refs", "heads", refName)
+ ref, err := repo.Reference(plumbing.ReferenceName(branch), true)
+ if err != nil {
+ http.NotFound(w, r)
return
}
- file, err := repo.BlobObject(entry.Hash)
+ commit, err := repo.CommitObject(ref.Hash())
if err != nil {
http.Error(w, err.Error(), 500)
return
}
- reader, err := file.Reader()
+ tree, err := commit.Tree()
if err != nil {
http.Error(w, err.Error(), 500)
return
}
- defer reader.Close()
-
- content := make([]byte, file.Size)
- reader.Read(content)
-
+ filePath := r.PathValue("rest")
+ 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)
+ }
data := struct {
Repo string
- Dir string
- Path string
+ Ref string
+ Base string
+ Crumbs []Crumb
Content string
}{
- Repo: repo.Name,
- Dir: path.Dir(subPath),
- Path: subPath,
- Content: string(content),
+ Repo: repoName,
+ Ref: refName,
+ Base: filePath,
+ Crumbs: breadcrumbs(repoName, refName, filePath),
+ Content: content,
}
+ serve(w, "blob", data)
+}
- serve(w, "file", data)
+type TreeData struct {
+ Repo string
+ Ref string
+ Base string
+ Crumbs []Crumb
+ Entries []EntryMeta
+ ReadmeName string
+ Readme string
}
-func renderTree(w http.ResponseWriter, repo *Repo, subPath string, tree *object.Tree) {
- type Entry struct {
- Name string
- Path string
- IsDir bool
- }
+type EntryMeta struct {
+ Repo string
+ Name string
+ Ref string
+ Path string
+ IsDir bool
+}
- var entries []Entry
-
+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
+ }
+ refName := r.PathValue("ref")
+ branch := path.Join("refs", "heads", refName)
+ ref, err := repo.Reference(plumbing.ReferenceName(branch), true)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ commit, err := repo.CommitObject(ref.Hash())
+ if err != nil {
+ http.Error(w, err.Error(), 500)
+ return
+ }
+ rootTree, err := commit.Tree()
+ if err != nil {
+ http.Error(w, err.Error(), 500)
+ return
+ }
+ treePath := r.PathValue("rest")
+ 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{
+ {
+ Repo: repoName,
+ Name: "..",
+ Ref: refName,
+ Path: path.Dir(treePath),
+ IsDir: true,
+ },
+ }
+ }
+ var readme, readmeName string
for _, e := range tree.Entries {
- fullPath := path.Join(subPath, e.Name)
- entries = append(entries, Entry{
+ fullPath := path.Join(treePath, e.Name)
+ em := EntryMeta{
+ Repo: repoName,
Name: e.Name,
- Path: path.Join("/", repo.Name, fullPath),
+ Ref: refName,
+ 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)
+ }
+ data := TreeData{
+ Repo: repoName,
+ Ref: refName,
+ Base: treePath,
+ Crumbs: breadcrumbs(repoName, refName, treePath),
+ Entries: entries,
+ ReadmeName: readmeName,
+ Readme: readme,
+ }
+ serve(w, "tree", data)
+}
+
+type Crumb struct {
+ Name string
+ URL string
+ IsLast bool
+}
+
+func breadcrumbs(repoName string, refName 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", refName, cur),
+ IsLast: last,
})
+ last = false
+ next := path.Dir(cur)
+ if next == cur {
+ break
+ }
+ cur = next
}
+ slices.Reverse(crumbs)
+ return crumbs
+}
- data := struct {
- Repo string
- Dir string
- Base string
- Entries []Entry
- }{
- Repo: repo.Name,
- Dir: path.Dir(subPath),
- Base: subPath,
- Entries: entries,
+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{
+ Repo: repoName,
+ Name: e.Name,
+ Ref: refName,
+ 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, "tree", data)
}
diff --git a/templates/blob.tmpl b/templates/blob.tmpl
new file mode 100644
index 0000000000000000000000000000000000000000..806e5090049ed586a8dcbc7432a93a06b400f6ef
--- /dev/null
+++ b/templates/blob.tmpl
@@ -0,0 +1,17 @@
+{{define "title"}}{{.Repo}}/{{.Base}}{{end}}
+{{define "content"}}
+<h1>{{if eq .Base ""}}{{.Repo}}{{else}}<a href="/{{.Repo}}/tree/{{.Ref}}">{{.Repo}}</a> /{{end}}
+{{range $i, $c := .Crumbs}}
+ {{if $i}} / {{end}}
+ {{if $c.IsLast}}
+ {{$c.Name}}
+ {{else}}
+ <a href="/{{$c.URL}}">{{$c.Name}}</a>
+ {{end}}
+{{end}}</h1>
+<pre>{{.Content}}</pre>
+{{end}}
+
+{{define "blob"}}
+{{template "base" .}}
+{{end}}
\ No newline at end of file
diff --git a/templates/file.tmpl b/templates/file.tmpl
deleted file mode 100644
index c5be3376d7429828b41c9c31cdedf5a6721ed7ea..0000000000000000000000000000000000000000
--- a/templates/file.tmpl
+++ /dev/null
@@ -1,10 +0,0 @@
-{{define "title"}}{{.Repo}}/{{.Path}}{{end}}
-{{define "content"}}
-<h1>{{.Repo}}/{{.Path}}</h1>
-<p><a href="/{{.Repo}}/{{.Dir}}"><-- back</a></p>
-<pre>{{.Content}}</pre>
-{{end}}
-
-{{define "file"}}
-{{template "base" .}}
-{{end}}
\ No newline at end of file
diff --git a/templates/tree.tmpl b/templates/tree.tmpl
index 0cedd543b3eced06f51bb34fc8b9e57924a0377b..e492f157d050594093357fdc0a8a1fc32905804c 100644
--- a/templates/tree.tmpl
+++ b/templates/tree.tmpl
@@ -1,15 +1,33 @@
{{define "title"}}{{.Repo}}/{{.Base}}{{end}}
{{define "content"}}
-<h1>{{.Repo}}/{{.Base}}</h1>
-<p><a href="/{{if and (eq .Dir ".") (eq .Base "")}}{{else}}{{.Repo}}/{{.Dir}}{{end}}"><-- back</a></p>
+<h1>{{if eq .Base ""}}{{.Repo}}{{else}}<a href="/{{.Repo}}/tree/{{.Ref}}">{{.Repo}}</a> /{{end}}
+{{range $i, $c := .Crumbs}}
+ {{if $i}} / {{end}}
+ {{if $c.IsLast}}
+ {{$c.Name}}
+ {{else}}
+ <a href="/{{$c.URL}}">{{$c.Name}}</a>
+ {{end}}
+{{end}}</h1>
+{{if eq .Base ""}}<p><a href="/"><-- back</a></p>{{end}}
<ul>
{{range .Entries}}
<li>
-{{if .IsDir}}📁{{else}}📄{{end}}
-<a href="{{.Path}}">{{.Name}}</a>
+ {{- if .IsDir -}}
+ 📁 <a href="/{{.Repo}}/tree/{{.Ref}}/{{.Path}}">{{.Name}}</a>
+ {{- else -}}
+ 📄 <a href="/{{.Repo}}/blob/{{.Ref}}/{{.Path}}">{{.Name}}</a>
+ {{- end -}}
</li>
{{end}}
</ul>
+{{- if ne .Readme "" -}}
+<hr>
+<h2>{{.ReadmeName}}</h2>
+<pre>
+{{.Readme}}
+</pre>
+{{- end -}}
{{end}}
{{define "tree"}}