bidet

commit 966bf149ae7ecdf11b644f19c529f04b0168c580

tree

parent:
105da3591641754954b7b227790d434fad7b3352

nmyk <nick@nmyk.io>

2026-02-16T17:14:58-05:00

add commit history

diff --git a/main.go b/main.go
index d0625a493efbdfe4acbcd61d40408adf8d9c2357..91f51d533db929d4af3dcf0742a47071f54504f4 100644
--- a/main.go
+++ b/main.go
@@ -10,6 +10,7 @@ 	"os"
 	"path"
 	"slices"
 	"strings"
+	"time"
 
 	git "github.com/go-git/go-git/v5"
 	"github.com/go-git/go-git/v5/plumbing"
@@ -41,6 +42,8 @@ 	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
 }
 
@@ -158,19 +161,150 @@ 	}
 	serve(w, "refs", data)
 }
 
-func parse(repo *git.Repository, refAndPath string) (refName string, urlPath string) {
+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 "", ""
+		return nil, ""
 	}
 	candidate := strings.TrimPrefix(refAndPath, "/")
 	for {
-		ref, err := resolve(repo, candidate)
+		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()
-			return
+			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 {
@@ -178,7 +312,7 @@ 			break
 		}
 		candidate = parent
 	}
-	return "", ""
+	return nil, ""
 }
 
 func blob(w http.ResponseWriter, r *http.Request) {
@@ -192,17 +326,7 @@ 	if err != nil {
 		http.Error(w, err.Error(), 500)
 		return
 	}
-	refName, filePath := parse(repo, r.PathValue("path"))
-	ref, err := resolve(repo, refName)
-	if err != nil {
-		http.NotFound(w, r)
-		return
-	}
-	commit, err := repo.CommitObject(ref.Hash())
-	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)
@@ -217,18 +341,24 @@ 	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
-		Ref     string
-		Base    string
-		Crumbs  []Crumb
-		Content string
+		Repo     string
+		RefLabel string
+		Base     string
+		Crumbs   []Crumb
+		Content  string
 	}{
-		Repo:    repoName,
-		Ref:     refName,
-		Base:    filePath,
-		Crumbs:  breadcrumbs(repoName, refName, filePath),
-		Content: content,
+		Repo:     repoName,
+		RefLabel: refLabel,
+		Base:     filePath,
+		Crumbs:   breadcrumbs(repoName, refLabel, filePath),
+		Content:  content,
 	}
 	serve(w, "blob", data)
 }
@@ -236,6 +366,8 @@
 type TreeData struct {
 	Repo       string
 	Ref        string
+	RefLabel   string
+	Commit     string
 	HeadRef    string
 	Base       string
 	Crumbs     []Crumb
@@ -250,7 +382,15 @@ 	Path  string
 	IsDir bool
 }
 
-func resolve(repo *git.Repository, refName string) (*plumbing.Reference, error) {
+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 {
@@ -283,24 +423,15 @@ 	if err != nil {
 		http.Error(w, err.Error(), 500)
 		return
 	}
-	refName, treePath := parse(repo, r.PathValue("path"))
+	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 refName == headRef && treePath == "" {
+	if commit.RefName == headRef && treePath == "" {
 		repoIndex(w, r)
-		return
-	}
-	ref, err := resolve(repo, refName)
-	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()
@@ -350,12 +481,19 @@ 			}
 		}
 		entries = append(entries, em)
 	}
+	var refLabel string
+	if commit.RefName != "" {
+		refLabel = commit.RefName
+	} else {
+		refLabel = commit.Hash.String()
+	}
 	data := TreeData{
 		Repo:       repoName,
-		Ref:        refName,
+		RefLabel:   refLabel,
+		Ref:        commit.RefName,
 		HeadRef:    headRef,
 		Base:       treePath,
-		Crumbs:     breadcrumbs(repoName, refName, treePath),
+		Crumbs:     breadcrumbs(repoName, refLabel, treePath),
 		Entries:    entries,
 		ReadmeName: readmeName,
 		Readme:     readme,
@@ -369,7 +507,7 @@ 	URL    string
 	IsLast bool
 }
 
-func breadcrumbs(repoName string, refName string, p string) []Crumb {
+func breadcrumbs(repoName string, refLabel string, p string) []Crumb {
 	var crumbs []Crumb
 	last := true
 	cur := p
@@ -380,7 +518,7 @@ 			break
 		}
 		crumbs = append(crumbs, Crumb{
 			Name:   base,
-			URL:    path.Join(repoName, "tree", refName, cur),
+			URL:    path.Join(repoName, "tree", refLabel, cur),
 			IsLast: last,
 		})
 		last = false
diff --git a/templates/blob.tmpl b/templates/blob.tmpl
index f0be0b16f928f0d16c7948dfee3ff5ed99ea57a9..90f900a4ddb0dede3d5cf6345f6c845aacff0fed 100644
--- a/templates/blob.tmpl
+++ b/templates/blob.tmpl
@@ -1,6 +1,6 @@
 {{define "title"}}{{.Repo}}/{{.Base}}{{end}}
 {{define "content"}}
-<h1>{{if eq .Base ""}}{{.Repo}}{{else}}<a href="/{{.Repo}}/tree/{{.Ref}}">{{.Repo}}</a> /{{end}}
+<h1>{{if eq .Base ""}}{{.Repo}}{{else}}<a href="/{{.Repo}}/tree/{{.RefLabel}}">{{.Repo}}</a> /{{end}}
 {{range $i, $c := .Crumbs}}
   {{if $i}} / {{end}}
   {{if $c.IsLast}}
@@ -9,7 +9,7 @@   {{else}}
     <a href="/{{$c.URL}}">{{$c.Name}}</a>
   {{end}}
 {{end}}</h1>
-<p>@ {{.Ref}}</p>
+<p>@ {{.RefLabel}}</p>
 <hr>
 <pre>{{.Content}}</pre>
 {{end}}
diff --git a/templates/commit.tmpl b/templates/commit.tmpl
new file mode 100644
index 0000000000000000000000000000000000000000..20f010e4b9e89a2a8971baa1897855df4f986fd9
--- /dev/null
+++ b/templates/commit.tmpl
@@ -0,0 +1,16 @@
+{{define "title"}}{{.Repo}} @ {{.Hash}}{{end}}
+{{define "content"}}
+<h1><a href="/{{.Repo}}">{{.Repo}}</a></h1>
+<p><a href="/">index</a> | <a href="/{{.Repo}}/refs">refs</a></p>
+<h4>commit {{.Hash}} | <a href="/{{.Repo}}/tree/{{.Hash}}">tree</a></h4>
+    <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}}
+{{end}}
+
+{{define "commit"}}
+{{template "base" .}}
+{{end}}
\ No newline at end of file
diff --git a/templates/commits.tmpl b/templates/commits.tmpl
new file mode 100644
index 0000000000000000000000000000000000000000..fbe0073965e503944b486492176ffa8cdd0a1b4e
--- /dev/null
+++ b/templates/commits.tmpl
@@ -0,0 +1,17 @@
+{{define "title"}}{{.Repo}} @ {{.Ref}}{{end}}
+{{define "content"}}
+<h1><a href="/{{.Repo}}/tree/{{.Ref}}">{{.Repo}}</a></h1>
+<p>@ {{.Ref}}</p>
+<h2>commits</h2>
+{{range .Commits}}
+    <p><a href="/{{$.Repo}}/commit/{{.Hash}}">{{.Hash}}</a></p>
+    <p>{{.Author}}</p>
+    <p>{{.When}}</p>
+    <pre>{{.Message}}</pre>
+    <br>
+{{end}}
+{{end}}
+
+{{define "commits"}}
+{{template "base" .}}
+{{end}}
\ No newline at end of file
diff --git a/templates/repo.tmpl b/templates/repo.tmpl
index f9c52d3da9d160d0639b71bd4dd71640632816bd..15ab581d855d323b3223801b752d209fb5332cbb 100644
--- a/templates/repo.tmpl
+++ b/templates/repo.tmpl
@@ -2,7 +2,7 @@ {{define "title"}}{{.Repo}}/{{.Base}}{{end}}
 {{define "content"}}
 <h1>{{.Repo}}</h1>
 <p>@ {{.Ref}}</p>
-<p><a href="/">index</a> | <a href="/{{.Repo}}/refs">refs</a></p>
+<p><a href="/">index</a> | <a href="/{{.Repo}}/refs">refs</a> | <a href="/{{.Repo}}/commits/{{.Ref}}">commits</a></p>
 <ul>
 {{range .Entries}}
 <li>
diff --git a/templates/tree.tmpl b/templates/tree.tmpl
index 1e1740a3d634c3dbf30d1b17bd3dbfc309a0cad3..16bad38dd41285f8e2c50864e98ee0e2e67732d4 100644
--- a/templates/tree.tmpl
+++ b/templates/tree.tmpl
@@ -1,6 +1,6 @@
 {{define "title"}}{{.Repo}}/{{.Base}}{{end}}
 {{define "content"}}
-<h1>{{if ne .Base ""}}<a href="/{{.Repo}}/tree/{{.Ref}}">{{.Repo}}</a> /
+<h1>{{if ne .Base ""}}<a href="/{{.Repo}}/tree/{{.RefLabel}}">{{.Repo}}</a> /
 {{else}}
 <a href="/{{.Repo}}/tree/{{.HeadRef}}">{{.Repo}}</a>
 {{end}}
@@ -12,17 +12,17 @@   {{else}}
     <a href="/{{$c.URL}}">{{$c.Name}}</a>
   {{end}}
 {{end}}</h1>
-<p>@ {{.Ref}}</p>
+<p>@ {{.RefLabel}}</p>
 {{if eq .Base ""}}
-<p><a href="/">index</a> | <a href="/{{.Repo}}/refs">refs</a></p>
+<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>
 {{range .Entries}}
 <li>
     {{- if .IsDir -}}
-        📁 <a href="/{{$.Repo}}/tree/{{$.Ref}}/{{.Path}}">{{.Name}}</a>
+        📁 <a href="/{{$.Repo}}/tree/{{$.RefLabel}}/{{.Path}}">{{.Name}}</a>
     {{- else -}}
-        📄 <a href="/{{$.Repo}}/blob/{{$.Ref}}/{{.Path}}">{{.Name}}</a>
+        📄 <a href="/{{$.Repo}}/blob/{{$.RefLabel}}/{{.Path}}">{{.Name}}</a>
     {{- end -}}
 </li>
 {{end}}