package git import ( "bytes" "fmt" "html/template" "net/url" "os" "os/exec" "path/filepath" "repobrowser/internal/config" "repobrowser/internal/models" "strings" chromalib "github.com/alecthomas/chroma/v2" chromahtml "github.com/alecthomas/chroma/v2/formatters/html" "github.com/alecthomas/chroma/v2/lexers" "github.com/alecthomas/chroma/v2/styles" ) func gitAt(path string, args ...string) (string, error) { cmd := exec.Command("git", append([]string{"-C", path}, args...)...) out, err := cmd.Output() return strings.TrimSpace(string(out)), err } func gitCmdStr(args ...string) string { return strings.Join(args, " ") } func ListRepos(cfg config.RepositoryBrowserConfiguration) []models.RepoInfo { entries, err := os.ReadDir(cfg.Root) if err != nil { return nil } var repos []models.RepoInfo for _, e := range entries { if !e.IsDir() { continue } p := filepath.Join(cfg.Root, e.Name()) // Must be a git repo if _, err := os.Stat(filepath.Join(p, ".git")); err != nil { // Also accept bare repos if _, err2 := os.Stat(filepath.Join(p, "HEAD")); err2 != nil { continue } } branch, _ := gitAt(p, "rev-parse", "--abbrev-ref", "HEAD") if branch == "" { branch = "main" } last, _ := gitAt(p, "log", "-1", "--pretty=format:%ar", branch) desc := "" if d, err := os.ReadFile(filepath.Join(p, "description")); err == nil { s := strings.TrimSpace(string(d)) if !strings.HasPrefix(s, "Unnamed repository") { desc = s } } repos = append(repos, models.RepoInfo{ Name: e.Name(), Description: desc, LastCommit: last, Branch: branch, }) } return repos } func ListBranches(rp string) ([]string, string) { out, err := gitAt(rp, "branch", "--format=%(refname:short)") if err != nil { return nil, "HEAD" } cur, _ := gitAt(rp, "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, repoName, tab string) []models.BranchLink { var links []models.BranchLink for _, b := range all { var u string switch tab { case "files": u = fmt.Sprintf("/%s/?branch=%s", repoName, url.QueryEscape(b)) default: u = fmt.Sprintf("/%s/%s?branch=%s", repoName, tab, url.QueryEscape(b)) } links = append(links, models.BranchLink{Name: b, URL: u, Active: b == activeBr}) } return links } func ListFiles(rp, branch string) ([]models.FileEntry, string, error) { args := []string{"ls-tree", "-r", "--name-only", branch} out, err := gitAt(rp, args...) cmd := gitCmdStr(args...) if err != nil { return nil, cmd, err } var files []models.FileEntry for _, l := range strings.Split(out, "\n") { l = strings.TrimSpace(l) if l == "" { continue } countOut, _ := gitAt(rp, "log", "--oneline", "--follow", branch, "--", l) count := 0 if countOut != "" { count = len(strings.Split(countOut, "\n")) } files = append(files, models.FileEntry{Path: l, CommitCount: count}) } return files, cmd, nil } func ListLog(rp, branch string, limit int) ([]models.CommitEntry, string, error) { args := []string{"log", "--pretty=format:%H\x1f%s\x1f%an\x1f%ad", "--date=short", fmt.Sprintf("-%d", limit), branch} out, err := gitAt(rp, args...) cmd := gitCmdStr("log", fmt.Sprintf("-%d", limit), "--oneline", branch) if err != nil { return nil, cmd, err } return ParseCommits(out), cmd, nil } func ListRefs(rp string) ([]models.RefEntry, string, error) { args := []string{"for-each-ref", "--format=%(refname:short)\x1f%(objecttype)\x1f%(objectname:short)\x1f%(objectname)", "refs/heads", "refs/tags"} out, err := gitAt(rp, args...) cmd := gitCmdStr("for-each-ref", "refs/heads", "refs/tags") if err != nil { return nil, cmd, err } var refs []models.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, models.RefEntry{Kind: kind, Name: parts[0], Short: parts[2], Hash: parts[3]}) } return refs, cmd, nil } func FileHistory(rp, path, branch string) ([]models.CommitEntry, string, error) { args := []string{"log", "--all", "--follow", "--pretty=format:%H\x1f%s\x1f%an\x1f%ad", "--date=short", branch, "--", path} out, err := gitAt(rp, args...) cmd := gitCmdStr("log", "--all", "--follow", "--oneline", branch, "--", path) if err != nil { return nil, cmd, err } return ParseCommits(out), cmd, nil } func FileAtCommit(rp, hash, path string) (string, string, error) { ref := fmt.Sprintf("%s:%s", hash, path) args := []string{"show", ref} out, err := gitAt(rp, args...) return out, gitCmdStr(args...), err } func CommitMeta(rp, hash string) (string, string) { meta, _ := gitAt(rp, "show", "--no-patch", "--pretty=format:%s\x1f%an\x1f%ae\x1f%ad", "--date=format:%Y-%m-%d %H:%M", hash) return meta, gitCmdStr("show", "--stat", hash) } func CommitFiles(rp, hash string) string { out, _ := gitAt(rp, "diff-tree", "--no-commit-id", "-r", "--name-status", hash) return out } func ParseCommits(out string) []models.CommitEntry { var commits []models.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, models.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 "📄" } func HighlightCode(content, filename string) (string, string) { lexer := lexers.Match(filename) if lexer == nil { lexer = lexers.Analyse(content) } if lexer == nil { lexer = lexers.Fallback } lexer = chromalib.Coalesce(lexer) style := styles.Get("onedark") if style == nil { style = styles.Fallback } formatter := chromahtml.New( chromahtml.WithClasses(true), chromahtml.TabWidth(4), chromahtml.WithLineNumbers(true), chromahtml.LineNumbersInTable(true), ) var cssBuf bytes.Buffer if err := formatter.WriteCSS(&cssBuf, style); err != nil { return plainFallback(content), "" } chromaCSS := cssBuf.String() iterator, err := lexer.Tokenise(nil, content) if err != nil { return plainFallback(content), chromaCSS } var hlBuf bytes.Buffer if err := formatter.Format(&hlBuf, style, iterator); err != nil { return plainFallback(content), chromaCSS } return hlBuf.String(), chromaCSS } func plainFallback(content string) string { return `
` +
        template.HTMLEscapeString(content) + `
` }