package git import ( "errors" "fmt" "os/exec" "path/filepath" "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"` } func TreeLS(repoPath, ref, subPath string) ([]TreeEntry, error) { // Short-circuit for repos with no commits yet. if IsEmpty(repoPath) { return nil, nil } treeRef := ref if subPath != "" { treeRef = ref + ":" + subPath } out, err := run(repoPath, "ls-tree", treeRef) if err != nil { return nil, err } var entries []TreeEntry for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { if line == "" { continue } // format: SP SP TAB tabIdx := strings.Index(line, "\t") if tabIdx < 0 { continue } name := line[tabIdx+1:] fields := strings.Fields(line[:tabIdx]) if len(fields) != 3 { continue } entries = append(entries, TreeEntry{ Mode: fields[0], Type: fields[1], Hash: fields[2], Name: name, }) } return entries, 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 } 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 }