package main import ( "fmt" "html/template" "net/http" "net/url" "os/exec" "strings" "github.com/gin-gonic/gin" ) // Hardcoded repo root — change this to your repo path const repoRoot = "/home/repo" // ───────────────────────────────────────────────────────────────────────────── // HTML shell // ───────────────────────────────────────────────────────────────────────────── const templateBase = ` {{.Title}}
{{.Body}}
` // ───────────────────────────────────────────────────────────────────────────── // Data types // ───────────────────────────────────────────────────────────────────────────── type BranchLink struct { Name string URL string Active bool } type FileEntry struct { Path string CommitCount int } type CommitEntry struct { Hash string Short string Message string Author string Date string } type RefEntry struct { Kind string Name string Short string Hash string } type PageData struct { Title string Branch string Branches []BranchLink ActiveTab string Breadcrumb template.HTML Body template.HTML GitCmd string // the git command that produced this page's data } // ───────────────────────────────────────────────────────────────────────────── // Git helpers — each returns (result, cmdString, error) // ───────────────────────────────────────────────────────────────────────────── func gitRun(args ...string) (string, error) { cmd := exec.Command("git", append([]string{"-C", repoRoot}, args...)...) out, err := cmd.Output() return strings.TrimSpace(string(out)), err } // gitCmd returns the human-readable form of a git command (without -C flag). func gitCmd(args ...string) string { return strings.Join(args, " ") } func repoName() string { name, err := gitRun("rev-parse", "--show-toplevel") if err != nil { return repoRoot } parts := strings.Split(name, "/") return parts[len(parts)-1] } func listBranches() ([]string, string) { out, err := gitRun("branch", "--format=%(refname:short)") if err != nil { return nil, "HEAD" } cur, _ := gitRun("rev-parse", "--abbrev-ref", "HEAD") var branches []string for _, b := range strings.Split(out, "\n") { b = strings.TrimSpace(b) if b != "" { branches = append(branches, b) } } if cur == "" { cur = "HEAD" } return branches, cur } func buildBranchLinks(all []string, _, activeBr, page string) []BranchLink { var links []BranchLink for _, b := range all { u := fmt.Sprintf("/%s?branch=%s", page, url.QueryEscape(b)) if page == "index" { u = fmt.Sprintf("/?branch=%s", url.QueryEscape(b)) } links = append(links, BranchLink{Name: b, URL: u, Active: b == activeBr}) } return links } func listFiles(branch string) ([]FileEntry, string, error) { args := []string{"ls-tree", "-r", "--name-only", branch} out, err := gitRun(args...) cmd := gitCmd(args...) if err != nil { return nil, cmd, err } var files []FileEntry for _, l := range strings.Split(out, "\n") { l = strings.TrimSpace(l) if l == "" { continue } countOut, _ := gitRun("log", "--oneline", "--follow", branch, "--", l) count := 0 if countOut != "" { count = len(strings.Split(countOut, "\n")) } files = append(files, FileEntry{Path: l, CommitCount: count}) } return files, cmd, nil } func listLog(branch string, limit int) ([]CommitEntry, string, error) { args := []string{"log", "--pretty=format:%H\x1f%s\x1f%an\x1f%ad", "--date=short", fmt.Sprintf("-%d", limit), branch} out, err := gitRun(args...) cmd := gitCmd("log", fmt.Sprintf("-%d", limit), "--oneline", branch) if err != nil { return nil, cmd, err } return parseCommits(out), cmd, nil } func listRefs() ([]RefEntry, string, error) { args := []string{"for-each-ref", "--format=%(refname:short)\x1f%(objecttype)\x1f%(objectname:short)\x1f%(objectname)", "refs/heads", "refs/tags"} out, err := gitRun(args...) cmd := gitCmd("for-each-ref", "refs/heads", "refs/tags") if err != nil { return nil, cmd, err } var refs []RefEntry for _, line := range strings.Split(out, "\n") { line = strings.TrimSpace(line) if line == "" { continue } parts := strings.SplitN(line, "\x1f", 4) if len(parts) < 4 { continue } kind := "branch" if parts[1] == "tag" { kind = "tag" } refs = append(refs, RefEntry{Kind: kind, Name: parts[0], Short: parts[2], Hash: parts[3]}) } return refs, cmd, nil } func fileHistory(path, branch string) ([]CommitEntry, string, error) { args := []string{"log", "--all", "--follow", "--pretty=format:%H\x1f%s\x1f%an\x1f%ad", "--date=short", branch, "--", path} out, err := gitRun(args...) cmd := gitCmd("log", "--all", "--follow", "--oneline", branch, "--", path) if err != nil { return nil, cmd, err } return parseCommits(out), cmd, nil } func fileAtCommit(hash, path string) (string, string, error) { ref := fmt.Sprintf("%s:%s", hash, path) args := []string{"show", ref} out, err := gitRun(args...) return out, gitCmd(args...), err } func commitMeta(hash string) (string, string) { meta, _ := gitRun("show", "--no-patch", "--pretty=format:%s\x1f%an\x1f%ae\x1f%ad", "--date=format:%Y-%m-%d %H:%M", hash) return meta, gitCmd("show", "--stat", hash) } func commitFiles(hash string) string { out, _ := gitRun("diff-tree", "--no-commit-id", "-r", "--name-status", hash) return out } func parseCommits(out string) []CommitEntry { var commits []CommitEntry for _, line := range strings.Split(out, "\n") { line = strings.TrimSpace(line) if line == "" { continue } parts := strings.SplitN(line, "\x1f", 4) if len(parts) < 4 { continue } short := parts[0] if len(short) > 8 { short = short[:8] } commits = append(commits, CommitEntry{ Hash: parts[0], Short: short, Message: parts[1], Author: parts[2], Date: parts[3], }) } return commits } func fileIcon(name string) string { ext := "" if i := strings.LastIndex(name, "."); i >= 0 { ext = name[i+1:] } switch ext { case "go": return "🔵" case "js", "ts", "jsx", "tsx": return "🟡" case "py": return "🟢" case "md": return "📝" case "sh", "bash": return "⚙️" case "json", "yaml", "yml", "toml": return "🔧" case "html", "css": return "🌐" case "rs": return "🦀" case "c", "cpp", "h": return "🔩" } return "📄" } // ───────────────────────────────────────────────────────────────────────────── // Template renderer // ───────────────────────────────────────────────────────────────────────────── func renderPage(c *gin.Context, data PageData) { tmpl, err := template.New("base").Parse(templateBase) if err != nil { c.String(http.StatusInternalServerError, err.Error()) return } c.Status(http.StatusOK) c.Header("Content-Type", "text/html; charset=utf-8") tmpl.Execute(c.Writer, data) } func activeBranch(c *gin.Context, defaultBranch string) string { if b := c.Query("branch"); b != "" { return b } return defaultBranch } func basePageData(c *gin.Context, tab string, crumb template.HTML) PageData { all, cur := listBranches() branch := activeBranch(c, cur) page := tab if page == "files" { page = "index" } return PageData{ Branch: branch, Branches: buildBranchLinks(all, cur, branch, page), ActiveTab: tab, Breadcrumb: crumb, } } // ───────────────────────────────────────────────────────────────────────────── // Handlers // ───────────────────────────────────────────────────────────────────────────── // GET / — file list func handleIndex(c *gin.Context) { repo := repoName() pd := basePageData(c, "files", template.HTML(fmt.Sprintf(`%s`, url.QueryEscape(activeBranch(c, "")), template.HTMLEscapeString(repo)))) files, cmd, err := listFiles(pd.Branch) var body strings.Builder body.WriteString(fmt.Sprintf( `

%s

`+ `

%s  ·  branch: %s  ·  %d files

`, template.HTMLEscapeString(repo), template.HTMLEscapeString(repoRoot), template.HTMLEscapeString(pd.Branch), len(files), )) if err != nil || len(files) == 0 { body.WriteString(`
No tracked files found on this branch.
`) } else { body.WriteString(``) for _, f := range files { parts := strings.Split(f.Path, "/") name := parts[len(parts)-1] dir := "" if len(parts) > 1 { dir = strings.Join(parts[:len(parts)-1], "/") + "/" } body.WriteString(fmt.Sprintf( ``+ ``, fileIcon(name), template.HTMLEscapeString(dir), url.QueryEscape(f.Path), url.QueryEscape(pd.Branch), template.HTMLEscapeString(name), f.CommitCount, )) } body.WriteString("
FileCommits
%s`+ `%s`+ `%s%d
") } pd.Title = repo + " — files" pd.GitCmd = cmd pd.Body = template.HTML(body.String()) renderPage(c, pd) } // GET /refs func handleRefs(c *gin.Context) { repo := repoName() pd := basePageData(c, "refs", template.HTML(fmt.Sprintf(`%s / refs`, url.QueryEscape(activeBranch(c, "")), template.HTMLEscapeString(repo)))) refs, cmd, err := listRefs() var body strings.Builder body.WriteString(`

Refs

Branches and tags in this repository

`) if err != nil || len(refs) == 0 { body.WriteString(`
No refs found.
`) } else { body.WriteString(``) for _, r := range refs { badgeClass, badgeLabel, icon := "ref-branch", "branch", "⎇" if r.Kind == "tag" { badgeClass, badgeLabel, icon = "ref-tag", "tag", "🏷" } body.WriteString(fmt.Sprintf( ``+ ``+ ``, icon, template.HTMLEscapeString(r.Name), badgeClass, badgeLabel, template.HTMLEscapeString(r.Short), )) } body.WriteString("
NameTypeHash
%s%s%s%s
") } pd.Title = repo + " — refs" pd.GitCmd = cmd pd.Body = template.HTML(body.String()) renderPage(c, pd) } // GET /log func handleLog(c *gin.Context) { repo := repoName() pd := basePageData(c, "log", template.HTML(fmt.Sprintf(`%s / log`, url.QueryEscape(activeBranch(c, "")), template.HTMLEscapeString(repo)))) commits, cmd, err := listLog(pd.Branch, 200) var body strings.Builder body.WriteString(fmt.Sprintf( `

Log

`+ `

branch: %s  ·  %d commits shown

`, template.HTMLEscapeString(pd.Branch), len(commits), )) if err != nil || len(commits) == 0 { body.WriteString(`
No commits found.
`) } else { body.WriteString(`
`) for _, cm := range commits { body.WriteString(fmt.Sprintf( `
`+ `%s`+ `%s`+ `%s`+ `%s`+ `
`, url.QueryEscape(cm.Hash), url.QueryEscape(pd.Branch), template.HTMLEscapeString(cm.Short), template.HTMLEscapeString(cm.Message), template.HTMLEscapeString(cm.Author), template.HTMLEscapeString(cm.Date), )) } body.WriteString("
") } pd.Title = repo + " — log" pd.GitCmd = cmd pd.Body = template.HTML(body.String()) renderPage(c, pd) } // GET /history?path=&branch= func handleHistory(c *gin.Context) { path := c.Query("path") if path == "" { c.Redirect(http.StatusFound, "/") return } repo := repoName() pd := basePageData(c, "files", template.HTML(fmt.Sprintf( `%s / %s`, url.QueryEscape(activeBranch(c, "")), template.HTMLEscapeString(repo), url.QueryEscape(path), url.QueryEscape(activeBranch(c, "")), template.HTMLEscapeString(path), ))) commits, cmd, err := fileHistory(path, pd.Branch) var body strings.Builder body.WriteString(fmt.Sprintf(`← all files`, url.QueryEscape(pd.Branch))) body.WriteString(fmt.Sprintf( `

%s

`+ `

Version history  ·  %d commits

`, template.HTMLEscapeString(path), len(commits), )) if err != nil || len(commits) == 0 { body.WriteString(`
No history found for this file.
`) } else { body.WriteString(`
`) for _, cm := range commits { body.WriteString(fmt.Sprintf( `
`+ `%s`+ `%s`+ `%s`+ `%s`+ `
`, url.QueryEscape(path), url.QueryEscape(cm.Hash), url.QueryEscape(pd.Branch), template.HTMLEscapeString(cm.Short), template.HTMLEscapeString(cm.Message), template.HTMLEscapeString(cm.Author), template.HTMLEscapeString(cm.Date), )) } body.WriteString("
") } pd.Title = path + " — history" pd.GitCmd = cmd pd.Body = template.HTML(body.String()) renderPage(c, pd) } // GET /view?path=&hash=&branch= func handleView(c *gin.Context) { path := c.Query("path") hash := c.Query("hash") if path == "" || hash == "" { c.Redirect(http.StatusFound, "/") return } repo := repoName() short := hash if len(short) > 8 { short = short[:8] } pd := basePageData(c, "files", template.HTML(fmt.Sprintf( `%s / `+ `%s / `+ `%s`, url.QueryEscape(activeBranch(c, "")), template.HTMLEscapeString(repo), url.QueryEscape(path), url.QueryEscape(activeBranch(c, "")), template.HTMLEscapeString(path), template.HTMLEscapeString(short), ))) content, cmd, err := fileAtCommit(hash, path) var body strings.Builder body.WriteString(fmt.Sprintf( `← history for %s`, url.QueryEscape(path), url.QueryEscape(pd.Branch), template.HTMLEscapeString(path), )) if err != nil { body.WriteString(fmt.Sprintf( `
⚠ Could not retrieve file at %s: %s
`, template.HTMLEscapeString(short), template.HTMLEscapeString(err.Error()), )) } else { lines := strings.Split(content, "\n") rawURL := fmt.Sprintf("/raw?path=%s&hash=%s", url.QueryEscape(path), url.QueryEscape(hash)) dlURL := fmt.Sprintf("/raw?path=%s&hash=%s&dl=1", url.QueryEscape(path), url.QueryEscape(hash)) body.WriteString(fmt.Sprintf( `
`+ `%s`+ `%s`+ ``+ `%d lines`+ `⬡ raw`+ `↓ download`+ `
`+ `
`, template.HTMLEscapeString(short), template.HTMLEscapeString(path), len(lines), rawURL, dlURL, template.HTMLEscapeString(path[strings.LastIndex(path, "/")+1:]), )) for i, line := range lines { body.WriteString(fmt.Sprintf( "\n", i+1, template.HTMLEscapeString(line), )) } body.WriteString("
%d%s
") } pd.Title = fmt.Sprintf("%s @ %s", path, short) pd.GitCmd = cmd pd.Body = template.HTML(body.String()) renderPage(c, pd) } // GET /raw?path=&hash=[&dl=1] — verbatim file content func handleRaw(c *gin.Context) { path := c.Query("path") hash := c.Query("hash") if path == "" || hash == "" { c.String(http.StatusBadRequest, "missing path or hash") return } content, _, err := fileAtCommit(hash, path) if err != nil { c.String(http.StatusNotFound, "file not found at that revision: %s", err.Error()) return } // Guess a reasonable content-type ct := "text/plain; charset=utf-8" lower := strings.ToLower(path) switch { case strings.HasSuffix(lower, ".html") || strings.HasSuffix(lower, ".htm"): ct = "text/html; charset=utf-8" case strings.HasSuffix(lower, ".json"): ct = "application/json" case strings.HasSuffix(lower, ".xml"): ct = "application/xml" case strings.HasSuffix(lower, ".css"): ct = "text/css; charset=utf-8" case strings.HasSuffix(lower, ".js") || strings.HasSuffix(lower, ".ts"): ct = "text/javascript; charset=utf-8" } c.Header("Content-Type", ct) if c.Query("dl") == "1" { filename := path[strings.LastIndex(path, "/")+1:] c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) } c.String(http.StatusOK, content) } // GET /commit?hash=&branch= func handleCommit(c *gin.Context) { hash := c.Query("hash") if hash == "" { c.Redirect(http.StatusFound, "/log") return } repo := repoName() short := hash if len(short) > 8 { short = short[:8] } pd := basePageData(c, "log", template.HTML(fmt.Sprintf( `%s / `+ `log / `+ `%s`, url.QueryEscape(activeBranch(c, "")), template.HTMLEscapeString(repo), url.QueryEscape(activeBranch(c, "")), template.HTMLEscapeString(short), ))) meta, cmd := commitMeta(hash) filesOut := commitFiles(hash) var subject, author, email, date string parts := strings.SplitN(meta, "\x1f", 4) if len(parts) == 4 { subject, author, email, date = parts[0], parts[1], parts[2], parts[3] } var body strings.Builder body.WriteString(fmt.Sprintf(`← log`, url.QueryEscape(pd.Branch))) body.WriteString(fmt.Sprintf( `

%s

`+ `

%s  ·  %s <%s>  ·  %s

`, template.HTMLEscapeString(subject), template.HTMLEscapeString(short), template.HTMLEscapeString(author), template.HTMLEscapeString(email), template.HTMLEscapeString(date), )) if filesOut == "" { body.WriteString(`
No file changes recorded.
`) } else { body.WriteString(``) for _, line := range strings.Split(filesOut, "\n") { line = strings.TrimSpace(line) if line == "" { continue } f := strings.Fields(line) if len(f) < 2 { continue } status, fp := f[0], f[1] color, label := "var(--text)", status switch { case strings.HasPrefix(status, "A"): color, label = "var(--green)", "added" case strings.HasPrefix(status, "D"): color, label = "var(--red)", "deleted" case strings.HasPrefix(status, "M"): color, label = "var(--accent)", "modified" case strings.HasPrefix(status, "R"): color, label = "var(--gold)", "renamed" } body.WriteString(fmt.Sprintf( ``+ ``, color, label, url.QueryEscape(fp), url.QueryEscape(pd.Branch), template.HTMLEscapeString(fp), )) } body.WriteString("
StatusFile
%s%s
") } pd.Title = short + " — commit" pd.GitCmd = cmd pd.Body = template.HTML(body.String()) renderPage(c, pd) } // ───────────────────────────────────────────────────────────────────────────── // Main // ───────────────────────────────────────────────────────────────────────────── func main() { r := gin.Default() r.GET("/", handleIndex) r.GET("/refs", handleRefs) r.GET("/log", handleLog) r.GET("/commit", handleCommit) r.GET("/history", handleHistory) r.GET("/view", handleView) r.GET("/raw", handleRaw) fmt.Println("gitweb running → http://localhost:8080") r.Run(":8080") }