fierj
PublicTiny personal git forge
package main
import (
"fmt"
"html/template"
"log/slog"
"net"
"net/http"
"sort"
"strings"
"time"
)
type BreadcrumbItem struct {
Name string
URL string
}
var funcMap = template.FuncMap{
"pathJoin": func(parts ...string) string {
var nonEmpty []string
for _, p := range parts {
if p != "" {
nonEmpty = append(nonEmpty, p)
}
}
return strings.Join(nonEmpty, "/")
},
"shortHash": func(h string) string {
if len(h) > 8 {
return h[:8]
}
return h
},
"hasSuffix": strings.HasSuffix,
"timeAgo": func(ts string) string {
t, err := time.Parse(time.RFC3339, ts)
if err != nil {
return ts
}
d := time.Since(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
m := int(d.Minutes())
if m == 1 {
return "1 minute ago"
}
return fmt.Sprintf("%d minutes ago", m)
case d < 24*time.Hour:
h := int(d.Hours())
if h == 1 {
return "1 hour ago"
}
return fmt.Sprintf("%d hours ago", h)
case d < 30*24*time.Hour:
days := int(d.Hours() / 24)
if days == 1 {
return "1 day ago"
}
return fmt.Sprintf("%d days ago", days)
case d < 365*24*time.Hour:
months := int(d.Hours() / 24 / 30)
if months == 1 {
return "1 month ago"
}
return fmt.Sprintf("%d months ago", months)
default:
return ts[:10]
}
},
}
func renderError(w http.ResponseWriter, tmpl *template.Template, code int, message string) {
w.WriteHeader(code)
tmpl.ExecuteTemplate(w, "error.html", map[string]any{
"Code": code,
"Message": message,
})
}
func Breadcrumb(path string) []BreadcrumbItem {
if path == "" {
return nil
}
parts := strings.Split(path, "/")
items := make([]BreadcrumbItem, len(parts))
cumulative := ""
for i, p := range parts {
if i > 0 {
cumulative += "/"
}
cumulative += p
items[i] = BreadcrumbItem{Name: p, URL: cumulative}
}
return items
}
func Repos(cfg Config, tmpl *template.Template) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
repos, err := ListRepos(cfg)
if err != nil {
renderError(w, tmpl, http.StatusInternalServerError, "failed to list repositories: "+err.Error())
return
}
tmpl.ExecuteTemplate(w, "repos.html", map[string]any{
"Repos": repos,
})
}
}
func Tree(cfg Config, tmpl *template.Template) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
type FileEntry struct {
TreeEntry
LastCommit *Commit
}
git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
treePath := r.PathValue("path")
ref := r.PathValue("ref")
if ref == "" {
ref = git.DefaultBranch()
}
cloneSSH := ""
cloneHTTPS := ""
if cfg.Host != "" {
cloneHTTPS = fmt.Sprintf("https://%s/%s.git", cfg.Host, git.Name)
// Include port in SSH URL if non-standard
if _, port, _ := net.SplitHostPort(cfg.SSHAddr); port != "" && port != "22" {
cloneSSH = fmt.Sprintf("ssh://git@%s:%s/%s.git", cfg.Host, port, git.Name)
} else {
cloneSSH = fmt.Sprintf("git@%s:%s.git", cfg.Host, git.Name)
}
}
if git.IsEmpty() {
tmpl.ExecuteTemplate(w, "tree.html", map[string]any{
"Repo": git.Repo(),
"Ref": ref,
"Path": "",
"Entries": []FileEntry{},
"CloneSSH": cloneSSH,
"CloneHTTPS": cloneHTTPS,
"Description": git.Description(),
"Branches": []string{},
"Tags": []string{},
"Commits": 0,
"Empty": true,
})
return
}
entries, err := git.List(ref, treePath)
if err != nil {
renderError(w, tmpl, http.StatusInternalServerError, "failed to list tree: "+err.Error())
return
}
// directories first
sort.Slice(entries, func(i, j int) bool {
if entries[i].Type != entries[j].Type {
return entries[i].Type == "tree"
}
return entries[i].Name < entries[j].Name
})
fileEntries := []FileEntry{}
for _, e := range entries {
fp := e.Name
if treePath != "" {
fp = treePath + "/" + e.Name
}
fe := FileEntry{TreeEntry: e, LastCommit: git.LastCommit(ref, fp)}
fileEntries = append(fileEntries, fe)
}
readme := git.Readme(ref, treePath)
tmpl.ExecuteTemplate(w, "tree.html", map[string]any{
"Repo": git.Repo(),
"Ref": ref,
"Path": treePath,
"Breadcrumb": Breadcrumb(treePath),
"Entries": fileEntries,
"CloneSSH": cloneSSH,
"CloneHTTPS": cloneHTTPS,
"Description": git.Description(),
"Branches": git.Branches(),
"Tags": git.Tags(),
"Commits": git.CommitCount(ref),
"Readme": readme,
"ActiveTab": "code",
})
}
}
func Blob(cfg Config, tmpl *template.Template) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
filePath := r.PathValue("path")
ref := r.PathValue("ref")
if ref == "" {
ref = git.DefaultBranch()
}
content, err := git.Blob(ref, filePath)
if err != nil {
renderError(w, tmpl, http.StatusNotFound, err.Error())
return
}
tmpl.ExecuteTemplate(w, "blob.html", map[string]any{
"Repo": git.Repo(),
"Ref": ref,
"Path": filePath,
"Breadcrumb": Breadcrumb(filePath),
"Content": content,
"Description": git.Description(),
"Branches": git.Branches(),
"Tags": git.Tags(),
"Commits": git.CommitCount(ref),
"ActiveTab": "code",
})
}
}
func Log(cfg Config, tmpl *template.Template) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
ref := r.PathValue("ref")
commits, err := git.Log(ref, 50)
if err != nil {
renderError(w, tmpl, http.StatusInternalServerError, err.Error())
return
}
data := map[string]any{
"Repo": git.Repo(),
"Ref": ref,
"CommitList": commits,
"Description": git.Description(),
"Branches": git.Branches(),
"Tags": git.Tags(),
"Commits": git.CommitCount(ref),
"ActiveTab": "commits",
}
tmpl.ExecuteTemplate(w, "log.html", data)
}
}
func Diff(cfg Config, tmpl *template.Template) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
ref := r.PathValue("ref")
commit, err := git.Diff(ref)
if err != nil || len(commit) == 0 {
renderError(w, tmpl, http.StatusNotFound, "commit not found")
return
}
if err := tmpl.ExecuteTemplate(w, "commit.html", map[string]any{
"Repo": git.Repo(),
"Ref": ref,
"Diff": commit,
"Hash": ref,
"Description": git.Description(),
"Branches": git.Branches(),
"Tags": git.Tags(),
"Commits": git.CommitCount(ref),
"ActiveTab": "commits",
}); err != nil {
slog.Error("failed to execute template", "error", err)
}
}
}
func NewRepoGet(tmpl *template.Template) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tmpl.ExecuteTemplate(w, "new_repo.html", nil)
}
}
func NewRepoPost(cfg Config, tmpl *template.Template) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
name := strings.TrimSpace(r.FormValue("name"))
desc := strings.TrimSpace(r.FormValue("description"))
importURL := strings.TrimSpace(r.FormValue("import_url"))
if importURL != "" {
importedName, err := ImportRepo(cfg, importURL, desc)
if err != nil {
renderError(w, tmpl, http.StatusBadRequest, "failed to import: "+err.Error())
return
}
http.Redirect(w, r, "/"+importedName, http.StatusSeeOther)
return
}
if name == "" {
renderError(w, tmpl, http.StatusBadRequest, "repository name is required")
return
}
if err := InitRepo(cfg, name, desc); err != nil {
renderError(w, tmpl, http.StatusBadRequest, "failed to create repository: "+err.Error())
return
}
http.Redirect(w, r, "/"+name, http.StatusSeeOther)
}
}
func Refs(cfg Config, tmpl *template.Template) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
ref := git.DefaultBranch()
tmpl.ExecuteTemplate(w, "refs.html", map[string]any{
"Repo": git.Repo(),
"Ref": ref,
"Branches": git.Branches(),
"Tags": git.Tags(),
"Description": git.Description(),
"Commits": git.CommitCount(ref),
"ActiveTab": "refs",
})
}
}