package git import ( "errors" "fmt" "io" "os" "os/exec" "path/filepath" "sort" "strconv" "strings" ) var ErrPathTraversal = errors.New("repo path outside of configured root") var repoRoot string func SetRepoRoot(root string) { repoRoot = filepath.Clean(root) } // run executes a git command inside repoPath with strict safety guarantees: // - repoPath is validated to be under the configured repoRoot (path traversal guard) // - args are passed as discrete values — never via shell interpolation // - the process inherits only a minimal, fixed environment func run(repoPath string, args ...string) ([]byte, error) { clean := filepath.Clean(repoPath) if repoRoot != "" { root := repoRoot + string(filepath.Separator) if !strings.HasPrefix(clean+string(filepath.Separator), root) && clean != repoRoot { return nil, ErrPathTraversal } } cmd := exec.Command("git", args...) cmd.Dir = clean cmd.Env = []string{ "GIT_TERMINAL_PROMPT=0", "HOME=/tmp", // prevent .gitconfig side-effects } out, err := cmd.Output() if err != nil { if exitErr, ok := err.(*exec.ExitError); ok { return nil, fmt.Errorf("git %s: %w: %s", args[0], err, exitErr.Stderr) } return nil, fmt.Errorf("git %s: %w", args[0], err) } return out, nil } // IsEmpty returns true when the repo has no commits on any branch. // Uses for-each-ref so it doesn't depend on HEAD pointing to a valid branch // (bare repos created without --initial-branch default to master, but clients // may push to main, leaving HEAD dangling). func IsEmpty(repoPath string) bool { out, err := run(repoPath, "for-each-ref", "--count=1", "refs/heads/") if err != nil { return true } return len(strings.TrimSpace(string(out))) == 0 } func Init(path string) error { cmd := exec.Command("git", "init", "--bare", path) cmd.Env = []string{"GIT_TERMINAL_PROMPT=0", "HOME=/tmp"} if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("git init --bare: %w: %s", err, out) } env := []string{"GIT_TERMINAL_PROMPT=0", "HOME=/tmp"} // Point HEAD at main so clients pushing 'main' don't leave a dangling HEAD. head := exec.Command("git", "-C", path, "symbolic-ref", "HEAD", "refs/heads/main") head.Env = env _ = head.Run() // Allow pushes over HTTP (git http-backend checks this config key). cfg := exec.Command("git", "-C", path, "config", "http.receivepack", "true") cfg.Env = env _ = cfg.Run() return nil } type Commit struct { Hash string `json:"hash"` Author string `json:"author"` Message string `json:"message"` Date string `json:"date"` } func Log(repoPath, branch string, limit int) ([]Commit, error) { out, err := run(repoPath, "log", branch, "--format=%H\x1f%an\x1f%s\x1f%ci", fmt.Sprintf("--max-count=%d", limit), ) if err != nil { return nil, err } raw := strings.TrimSpace(string(out)) if raw == "" { return nil, nil } var commits []Commit for _, line := range strings.Split(raw, "\n") { parts := strings.SplitN(line, "\x1f", 4) if len(parts) != 4 { continue } commits = append(commits, Commit{ Hash: parts[0], Author: parts[1], Message: parts[2], Date: parts[3], }) } return commits, nil } type TreeEntry struct { Mode string `json:"mode"` Type string `json:"type"` Hash string `json:"hash"` Name string `json:"name"` Size int64 `json:"size"` CommitHash string `json:"commitHash"` CommitMsg string `json:"commitMsg"` CommitDate string `json:"commitDate"` } func TreeLS(repoPath, ref, subPath string) ([]TreeEntry, error) { if IsEmpty(repoPath) { return nil, nil } treeRef := ref if subPath != "" { treeRef = ref + ":" + subPath } // -l adds size column: SP SP SP TAB out, err := run(repoPath, "ls-tree", "-l", treeRef) if err != nil { return nil, err } var entries []TreeEntry for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { if line == "" { continue } tabIdx := strings.Index(line, "\t") if tabIdx < 0 { continue } name := line[tabIdx+1:] fields := strings.Fields(line[:tabIdx]) if len(fields) < 4 { continue } e := TreeEntry{ Mode: fields[0], Type: fields[1], Hash: fields[2], Name: name, } if fields[3] != "-" { fmt.Sscanf(fields[3], "%d", &e.Size) } entries = append(entries, e) } // Fetch last commit info for each entry. for i, e := range entries { filePath := e.Name if subPath != "" { filePath = subPath + "/" + e.Name } commitOut, err := run(repoPath, "log", "-1", "--format=%h\x1f%s\x1f%aI", "--", filePath) if err == nil { parts := strings.SplitN(strings.TrimSpace(string(commitOut)), "\x1f", 3) if len(parts) == 3 { entries[i].CommitHash = parts[0] entries[i].CommitMsg = parts[1] entries[i].CommitDate = parts[2] } } } return entries, nil } // WriteFile writes content to filePath on branch inside a temporary worktree, // then commits with the given author and message. Uses git plumbing directly // so it works on bare repositories. func WriteFile(repoPath, branch, filePath, content, authorName, authorEmail, message string) error { clean := filepath.Clean(filepath.FromSlash(filePath)) if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) { return errors.New("invalid file path") } tmpDir, err := os.MkdirTemp("", "fb-edit-*") if err != nil { return fmt.Errorf("mktemp: %w", err) } baseEnv := []string{"GIT_TERMINAL_PROMPT=0", "HOME=/tmp"} authorEnv := append(baseEnv, "GIT_AUTHOR_NAME="+authorName, "GIT_AUTHOR_EMAIL="+authorEmail, "GIT_COMMITTER_NAME="+authorName, "GIT_COMMITTER_EMAIL="+authorEmail, ) addWt := exec.Command("git", "worktree", "add", "--force", tmpDir, branch) addWt.Dir = filepath.Clean(repoPath) addWt.Env = baseEnv if out, err := addWt.CombinedOutput(); err != nil { os.RemoveAll(tmpDir) return fmt.Errorf("worktree add: %w: %s", err, out) } defer func() { rmWt := exec.Command("git", "worktree", "remove", "--force", tmpDir) rmWt.Dir = filepath.Clean(repoPath) rmWt.Env = baseEnv rmWt.Run() os.RemoveAll(tmpDir) }() fullPath := filepath.Join(tmpDir, clean) if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { return fmt.Errorf("mkdirall: %w", err) } if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil { return fmt.Errorf("writefile: %w", err) } addC := exec.Command("git", "add", clean) addC.Dir = tmpDir addC.Env = authorEnv if out, err := addC.CombinedOutput(); err != nil { return fmt.Errorf("git add: %w: %s", err, out) } commitC := exec.Command("git", "commit", "-m", message) commitC.Dir = tmpDir commitC.Env = authorEnv if out, err := commitC.CombinedOutput(); err != nil { return fmt.Errorf("git commit: %w: %s", err, out) } return nil } // FileUpload holds a file path and its content for a batch commit. type FileUpload struct { Path string // repo-relative path, e.g. "src/main.go" Content []byte } // WriteManyFiles commits all files in a single commit to branch. Each file path // must be a clean relative path — no ".." or absolute paths allowed. func WriteManyFiles(repoPath, branch, message, authorName, authorEmail string, files []FileUpload) error { if len(files) == 0 { return errors.New("no files to commit") } // Validate all paths before touching the filesystem. for _, f := range files { clean := filepath.Clean(filepath.FromSlash(f.Path)) if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) { return fmt.Errorf("invalid file path: %s", f.Path) } } tmpDir, err := os.MkdirTemp("", "fb-upload-*") if err != nil { return fmt.Errorf("mktemp: %w", err) } baseEnv := []string{"GIT_TERMINAL_PROMPT=0", "HOME=/tmp"} authorEnv := append(baseEnv, "GIT_AUTHOR_NAME="+authorName, "GIT_AUTHOR_EMAIL="+authorEmail, "GIT_COMMITTER_NAME="+authorName, "GIT_COMMITTER_EMAIL="+authorEmail, ) addWt := exec.Command("git", "worktree", "add", "--force", tmpDir, branch) addWt.Dir = filepath.Clean(repoPath) addWt.Env = baseEnv if out, err := addWt.CombinedOutput(); err != nil { os.RemoveAll(tmpDir) return fmt.Errorf("worktree add: %w: %s", err, out) } defer func() { rmWt := exec.Command("git", "worktree", "remove", "--force", tmpDir) rmWt.Dir = filepath.Clean(repoPath) rmWt.Env = baseEnv rmWt.Run() os.RemoveAll(tmpDir) }() for _, f := range files { clean := filepath.Clean(filepath.FromSlash(f.Path)) fullPath := filepath.Join(tmpDir, clean) if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { return fmt.Errorf("mkdirall %s: %w", clean, err) } if err := os.WriteFile(fullPath, f.Content, 0644); err != nil { return fmt.Errorf("writefile %s: %w", clean, err) } } addC := exec.Command("git", "add", ".") addC.Dir = tmpDir addC.Env = authorEnv if out, err := addC.CombinedOutput(); err != nil { return fmt.Errorf("git add: %w: %s", err, out) } commitC := exec.Command("git", "commit", "-m", message) commitC.Dir = tmpDir commitC.Env = authorEnv if out, err := commitC.CombinedOutput(); err != nil { return fmt.Errorf("git commit: %w: %s", err, out) } return nil } type Branch struct { Name string `json:"name"` } func Branches(repoPath string) ([]Branch, error) { if IsEmpty(repoPath) { return nil, nil } out, err := run(repoPath, "branch", "--format=%(refname:short)") if err != nil { return nil, err } var branches []Branch for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { if line != "" { branches = append(branches, Branch{Name: line}) } } return branches, nil } // RepoSize returns the total byte size of the bare repo directory by walking it. func RepoSize(repoPath string) int64 { var total int64 filepath.Walk(repoPath, func(_ string, info os.FileInfo, err error) error { if err == nil && !info.IsDir() { total += info.Size() } return nil }) return total } // Run executes a git command in repoPath with discrete arguments and returns // the raw stdout. WARNING: args must be constant literals or strictly validated // — no user-controlled values belong here. This is the public equivalent of the // internal run() helper and carries the same safety guarantees. func Run(repoPath string, args ...string) ([]byte, error) { return run(repoPath, args...) } // RevParse resolves a ref (branch name, tag, or SHA) to its full commit SHA. func RevParse(repoPath, ref string) (string, error) { out, err := run(repoPath, "rev-parse", "--verify", ref) if err != nil { return "", err } return strings.TrimSpace(string(out)), nil } // SetDefaultBranch updates HEAD to point at the given branch name. func SetDefaultBranch(repoPath, branch string) error { _, err := run(repoPath, "symbolic-ref", "HEAD", "refs/heads/"+branch) return err } // InitWithFiles creates an initial commit in a bare repo by: // 1. git init in a temp dir → write files → commit → git push file://repoPath. // Use this right after Init() when initReadme or initGitignore is requested. func InitWithFiles(repoPath, branch, repoName, authorName, authorEmail string, initReadme, initGitignore bool) error { tmpDir, err := os.MkdirTemp("", "fb-init-*") if err != nil { return fmt.Errorf("mktemp: %w", err) } defer os.RemoveAll(tmpDir) env := []string{ "GIT_TERMINAL_PROMPT=0", "HOME=/tmp", "GIT_AUTHOR_NAME=" + authorName, "GIT_AUTHOR_EMAIL=" + authorEmail, "GIT_COMMITTER_NAME=" + authorName, "GIT_COMMITTER_EMAIL=" + authorEmail, } initCmd := exec.Command("git", "init", tmpDir) initCmd.Env = env if out, err := initCmd.CombinedOutput(); err != nil { return fmt.Errorf("git init: %w: %s", err, out) } // Point the working-tree HEAD at the desired branch before the first commit. symRef := exec.Command("git", "-C", tmpDir, "symbolic-ref", "HEAD", "refs/heads/"+branch) symRef.Env = env symRef.Run() if initReadme { if err := os.WriteFile(filepath.Join(tmpDir, "README.md"), []byte(readmeContent(repoName)), 0644); err != nil { return fmt.Errorf("write README: %w", err) } } if initGitignore { if err := os.WriteFile(filepath.Join(tmpDir, ".gitignore"), []byte(gitignoreContent()), 0644); err != nil { return fmt.Errorf("write .gitignore: %w", err) } } addCmd := exec.Command("git", "add", ".") addCmd.Dir = tmpDir addCmd.Env = env if out, err := addCmd.CombinedOutput(); err != nil { return fmt.Errorf("git add: %w: %s", err, out) } commitCmd := exec.Command("git", "commit", "-m", "Initial commit") commitCmd.Dir = tmpDir commitCmd.Env = env if out, err := commitCmd.CombinedOutput(); err != nil { return fmt.Errorf("git commit: %w: %s", err, out) } // Push to the bare repo via file:// — works on all git versions. pushCmd := exec.Command("git", "push", "file://"+filepath.ToSlash(filepath.Clean(repoPath)), branch) pushCmd.Dir = tmpDir pushCmd.Env = env if out, err := pushCmd.CombinedOutput(); err != nil { return fmt.Errorf("git push: %w: %s", err, out) } return nil } // CloneRepo mirrors a remote HTTP/HTTPS git repository into destPath (bare). func CloneRepo(srcURL, destPath string) error { if !strings.HasPrefix(srcURL, "http://") && !strings.HasPrefix(srcURL, "https://") { return errors.New("only http and https source URLs are supported") } env := []string{"GIT_TERMINAL_PROMPT=0", "HOME=/tmp"} cmd := exec.Command("git", "clone", "--mirror", srcURL, destPath) cmd.Env = env if out, err := cmd.CombinedOutput(); err != nil { // Scrub the URL (which may contain credentials) from the error. msg := strings.ReplaceAll(string(out), srcURL, "") return fmt.Errorf("git clone: %w: %s", err, msg) } // Enable pushes over HTTP. cfgCmd := exec.Command("git", "-C", destPath, "config", "http.receivepack", "true") cfgCmd.Env = env cfgCmd.Run() return nil } func readmeContent(repoName string) string { return fmt.Sprintf("# %s\n\n## Description\n\nAdd your project description here.\n\n## Getting Started\n\n### Prerequisites\n\nList your prerequisites here.\n\n### Installation\n\n1. Clone the repository\n2. Follow setup instructions\n\n## Usage\n\nDescribe how to use your project.\n\n## Contributing\n\nContributions are welcome! Please open an issue or pull request.\n\n## License\n\nThis project is licensed under the MIT License.\n", repoName) } func gitignoreContent() string { return "# OS\n.DS_Store\nThumbs.db\n\n# Editor\n.vscode/\n.idea/\n*.swp\n*.swo\n\n# Logs\n*.log\nlogs/\n\n# Dependencies\nnode_modules/\nvendor/\n\n# Build artifacts\ndist/\nbuild/\n*.o\n*.a\n*.so\n" } func BlobCat(repoPath, ref, filePath string) ([]byte, error) { return run(repoPath, "show", ref+":"+filePath) } // FileDiff represents a single changed file in a diff. type FileDiff struct { Path string `json:"path"` OldPath string `json:"oldPath,omitempty"` // set on renames Additions int `json:"additions"` Deletions int `json:"deletions"` Patch string `json:"patch"` // unified diff hunk(s) for this file } // Diff returns per-file diffs between two refs. func Diff(repoPath, base, head string) ([]FileDiff, error) { out, err := run(repoPath, "diff", "--unified=5", "--no-color", "--diff-filter=ACDMRT", // exclude untracked base+".."+head, ) if err != nil { return nil, err } return parseUnifiedDiff(string(out)), nil } // parseUnifiedDiff splits a multi-file unified diff into per-file FileDiff entries. func parseUnifiedDiff(raw string) []FileDiff { var files []FileDiff var cur *FileDiff var patchLines []string commit := func() { if cur != nil { cur.Patch = strings.Join(patchLines, "\n") files = append(files, *cur) } } for _, line := range strings.Split(raw, "\n") { if strings.HasPrefix(line, "diff --git ") { commit() cur = &FileDiff{} patchLines = nil continue } if cur == nil { continue } switch { case strings.HasPrefix(line, "--- a/"): cur.OldPath = strings.TrimPrefix(line, "--- a/") case strings.HasPrefix(line, "+++ b/"): cur.Path = strings.TrimPrefix(line, "+++ b/") case strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++"): cur.Additions++ patchLines = append(patchLines, line) case strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---"): cur.Deletions++ patchLines = append(patchLines, line) default: patchLines = append(patchLines, line) } } commit() return files } // ArchiveStream writes a git archive of ref in the requested format to w. // format must be one of "zip", "tar.gz", or "bundle". // Output is streamed directly to w without buffering. func ArchiveStream(repoPath string, ref string, format string, w io.Writer) error { clean := filepath.Clean(repoPath) if repoRoot != "" { root := repoRoot + string(filepath.Separator) if !strings.HasPrefix(clean+string(filepath.Separator), root) && clean != repoRoot { return ErrPathTraversal } } baseEnv := []string{"GIT_TERMINAL_PROMPT=0", "HOME=/tmp"} var cmd *exec.Cmd switch format { case "zip", "tar.gz": cmd = exec.Command("git", "archive", "--format="+format, ref) case "bundle": cmd = exec.Command("git", "bundle", "create", "-", "--all") default: return fmt.Errorf("git archive: unsupported format %q", format) } cmd.Dir = clean cmd.Env = baseEnv cmd.Stdout = w var errBuf strings.Builder cmd.Stderr = &errBuf if err := cmd.Run(); err != nil { if errBuf.Len() > 0 { return fmt.Errorf("git archive: %w: %s", err, errBuf.String()) } return fmt.Errorf("git archive: %w", err) } return nil } // ── Language stats ───────────────────────────────────────────────────────────── // LangStat holds the aggregate file-count and percentage for one language. type LangStat struct { Name string `json:"name"` Color string `json:"color"` Count int `json:"count"` Pct float64 `json:"pct"` } // extLang maps file extensions to (display name, hex color). var extLang = map[string][2]string{ ".go": {"Go", "#00ADD8"}, ".ts": {"TypeScript", "#3178C6"}, ".tsx": {"TypeScript", "#3178C6"}, ".js": {"JavaScript", "#F7DF1E"}, ".jsx": {"JavaScript", "#F7DF1E"}, ".mjs": {"JavaScript", "#F7DF1E"}, ".py": {"Python", "#3572A5"}, ".rb": {"Ruby", "#CC342D"}, ".rs": {"Rust", "#DEA584"}, ".java": {"Java", "#B07219"}, ".cs": {"C#", "#178600"}, ".cpp": {"C++", "#F34B7D"}, ".cc": {"C++", "#F34B7D"}, ".c": {"C", "#555555"}, ".h": {"C", "#555555"}, ".swift": {"Swift", "#F05138"}, ".kt": {"Kotlin", "#A97BFF"}, ".php": {"PHP", "#4F5D95"}, ".html": {"HTML", "#E34C26"}, ".css": {"CSS", "#563D7C"}, ".scss": {"SCSS", "#C6538C"}, ".sql": {"SQL", "#e38c00"}, ".sh": {"Shell", "#89E051"}, ".bash": {"Shell", "#89E051"}, ".yaml": {"YAML", "#CB171E"}, ".yml": {"YAML", "#CB171E"}, ".json": {"JSON", "#292929"}, ".md": {"Markdown", "#083FA1"}, ".tf": {"HCL", "#844FBA"}, ".proto": {"Protobuf", "#5F5CE9"}, ".dart": {"Dart", "#00B4AB"}, ".lua": {"Lua", "#000080"}, ".r": {"R", "#198CE7"}, } // LanguageStats analyses the file tree at ref and returns language breakdown // sorted by file count descending. Languages under 3% are collapsed into "Other". func LanguageStats(repoPath, ref string) ([]LangStat, error) { if ref == "" { ref = "HEAD" } out, err := run(repoPath, "ls-tree", "-r", "--name-only", ref) if err != nil { return nil, err } counts := map[string]int{} total := 0 for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { line = strings.TrimSpace(line) if line == "" { continue } ext := strings.ToLower(filepath.Ext(line)) lang, ok := extLang[ext] if ok { counts[lang[0]]++ } else { counts["Other"]++ } total++ } if total == 0 { return []LangStat{}, nil } // Aggregate by name with color lookup. colorOf := map[string]string{"Other": "#8B8B8B"} for _, v := range extLang { colorOf[v[0]] = v[1] } var stats []LangStat otherCount := 0 for name, count := range counts { pct := float64(count) / float64(total) * 100 if name == "Other" || pct < 3.0 { otherCount += count continue } stats = append(stats, LangStat{Name: name, Color: colorOf[name], Count: count, Pct: pct}) } sort.Slice(stats, func(i, j int) bool { return stats[i].Count > stats[j].Count }) if len(stats) > 10 { for _, s := range stats[10:] { otherCount += s.Count } stats = stats[:10] } if otherCount > 0 { stats = append(stats, LangStat{ Name: "Other", Color: "#8B8B8B", Count: otherCount, Pct: float64(otherCount) / float64(total) * 100, }) } return stats, nil } // ── Contributor stats ───────────────────────────────────────────────────────── // Contributor holds a commit author name and their commit count. type Contributor struct { Name string `json:"name"` Commits int `json:"commits"` } // Contributors returns the top limit commit authors sorted by commit count. // Uses git shortlog which is fast even on large repos. func Contributors(repoPath string, limit int) ([]Contributor, error) { out, err := run(repoPath, "shortlog", "-sn", "--no-merges", "HEAD") if err != nil { return []Contributor{}, nil // empty repo or detached HEAD — not an error } var result []Contributor for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { line = strings.TrimSpace(line) if line == "" { continue } // Format: " 42\tAlice Wang" idx := strings.Index(line, "\t") if idx < 0 { continue } countStr := strings.TrimSpace(line[:idx]) name := strings.TrimSpace(line[idx+1:]) n, err := strconv.Atoi(countStr) if err != nil { continue } result = append(result, Contributor{Name: name, Commits: n}) if len(result) >= limit { break } } return result, nil } // CommitCount returns the total number of commits reachable from HEAD. func CommitCount(repoPath string) (int, error) { out, err := run(repoPath, "rev-list", "--count", "HEAD") if err != nil { return 0, nil // empty repo } n, err := strconv.Atoi(strings.TrimSpace(string(out))) if err != nil { return 0, nil } return n, nil } // SearchFiles returns file paths matching query (case-insensitive substring) // in the repository tree at ref, capped at limit results. func SearchFiles(repoPath, ref, query string, limit int) ([]string, error) { if ref == "" { ref = "HEAD" } out, err := run(repoPath, "ls-tree", "-r", "--name-only", ref) if err != nil { return []string{}, nil // empty repo } lower := strings.ToLower(query) var results []string for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { line = strings.TrimSpace(line) if line == "" { continue } if strings.Contains(strings.ToLower(line), lower) { results = append(results, line) if len(results) >= limit { break } } } return results, nil }