Files
ForgeBucket/internal/domain/git/binary.go
T

217 lines
5.3 KiB
Go

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 yet.
// Must use --verify: without it, rev-parse exits 0 in a bare empty repo
// because HEAD is a valid symbolic ref even before the first commit.
func IsEmpty(repoPath string) bool {
_, err := run(repoPath, "rev-parse", "--verify", "HEAD")
return err != 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) {
// 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: <mode> SP <type> SP <hash> TAB <name>
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)
}
// 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
}