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}}
gitweb
{{.Breadcrumb}}
🏷 Refs
📋 Log
📁 Files
{{.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(`File Commits `)
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(
`%s `+
`%s `+
`%s `+
`%d `,
fileIcon(name), template.HTMLEscapeString(dir),
url.QueryEscape(f.Path), url.QueryEscape(pd.Branch),
template.HTMLEscapeString(name), f.CommitCount,
))
}
body.WriteString("
")
}
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(`Name Type Hash `)
for _, r := range refs {
badgeClass, badgeLabel, icon := "ref-branch", "branch", "⎇"
if r.Kind == "tag" {
badgeClass, badgeLabel, icon = "ref-tag", "tag", "🏷"
}
body.WriteString(fmt.Sprintf(
`%s %s `+
`%s `+
`%s `,
icon, template.HTMLEscapeString(r.Name),
badgeClass, badgeLabel, template.HTMLEscapeString(r.Short),
))
}
body.WriteString("
")
}
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(
``+
``,
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(
"%d %s \n",
i+1, template.HTMLEscapeString(line),
))
}
body.WriteString("
")
}
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(`Status File `)
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(
`%s `+
`%s `,
color, label,
url.QueryEscape(fp), url.QueryEscape(pd.Branch),
template.HTMLEscapeString(fp),
))
}
body.WriteString("
")
}
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")
}