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 } func Init(path string) error { // git init --bare works even if the directory doesn't exist yet 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) } 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) { 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 } func BlobCat(repoPath, ref, filePath string) ([]byte, error) { return run(repoPath, "show", ref+":"+filePath) }