git_tools: Implement git diff and show API

Added git_tools package providing structured access to git diff and show commands. Exposed these methods via HTTP endpoints in loophttp.

This is a stepping stone to a better diff view.

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: se75f0a1b2c3d4e5k
diff --git a/git_tools/git_tools.go b/git_tools/git_tools.go
new file mode 100644
index 0000000..f8a1807
--- /dev/null
+++ b/git_tools/git_tools.go
@@ -0,0 +1,230 @@
+// Package git_tools provides utilities for interacting with Git repositories.
+package git_tools
+
+import (
+	"bufio"
+	"fmt"
+	"os/exec"
+	"strings"
+)
+
+// DiffFile represents a file in a Git diff
+type DiffFile struct {
+	Path    string `json:"path"`
+	OldMode string `json:"old_mode"`
+	NewMode string `json:"new_mode"`
+	OldHash string `json:"old_hash"`
+	NewHash string `json:"new_hash"`
+	Status  string `json:"status"` // A=added, M=modified, D=deleted, etc.
+} // GitRawDiff returns a structured representation of the Git diff between two commits or references
+func GitRawDiff(repoDir, from, to string) ([]DiffFile, error) {
+	// Git command to generate the diff in raw format with full hashes
+	cmd := exec.Command("git", "-C", repoDir, "diff", "--raw", "--abbrev=40", from, to)
+	out, err := cmd.CombinedOutput()
+	if err != nil {
+		return nil, fmt.Errorf("error executing git diff: %w - %s", err, string(out))
+	}
+
+	// Parse the raw diff output into structured format
+	return parseRawDiff(string(out))
+}
+
+// GitShow returns the result of git show for a specific commit hash
+func GitShow(repoDir, hash string) (string, error) {
+	cmd := exec.Command("git", "-C", repoDir, "show", hash)
+	out, err := cmd.CombinedOutput()
+	if err != nil {
+		return "", fmt.Errorf("error executing git show: %w - %s", err, string(out))
+	}
+	return string(out), nil
+}
+
+// parseRawDiff converts git diff --raw output into structured format
+func parseRawDiff(diffOutput string) ([]DiffFile, error) {
+	var files []DiffFile
+	if diffOutput == "" {
+		return files, nil
+	}
+
+	// Process diff output line by line
+	scanner := bufio.NewScanner(strings.NewReader(strings.TrimSpace(diffOutput)))
+	for scanner.Scan() {
+		line := scanner.Text()
+		// Format: :oldmode newmode oldhash newhash status\tpath
+		// Example: :000000 100644 0000000000000000000000000000000000000000 6b33680ae6de90edd5f627c84147f7a41aa9d9cf A        git_tools/git_tools.go
+		if !strings.HasPrefix(line, ":") {
+			continue
+		}
+
+		parts := strings.Fields(line[1:]) // Skip the leading colon
+		if len(parts) < 5 {
+			continue // Not enough parts, skip this line
+		}
+
+		oldMode := parts[0]
+		newMode := parts[1]
+		oldHash := parts[2]
+		newHash := parts[3]
+		status := parts[4]
+
+		// The path is everything after the status character and tab
+		pathIndex := strings.Index(line, status) + len(status) + 1 // +1 for the tab
+		path := ""
+		if pathIndex < len(line) {
+			path = strings.TrimSpace(line[pathIndex:])
+		}
+
+		files = append(files, DiffFile{
+			Path:    path,
+			OldMode: oldMode,
+			NewMode: newMode,
+			OldHash: oldHash,
+			NewHash: newHash,
+			Status:  status,
+		})
+	}
+
+	return files, nil
+}
+
+// LogEntry represents a single entry in the git log
+type LogEntry struct {
+	Hash    string   `json:"hash"`    // The full commit hash
+	Refs    []string `json:"refs"`    // References (branches, tags) pointing to this commit
+	Subject string   `json:"subject"` // The commit subject/message
+}
+
+// GitRecentLog returns the recent commit log between the initial commit and HEAD
+func GitRecentLog(repoDir string, initialCommitHash string) ([]LogEntry, error) {
+	// Validate input
+	if initialCommitHash == "" {
+		return nil, fmt.Errorf("initial commit hash must be provided")
+	}
+
+	// Find merge-base of HEAD and initial commit
+	cmdMergeBase := exec.Command("git", "-C", repoDir, "merge-base", "HEAD", initialCommitHash)
+	mergeBase, err := cmdMergeBase.CombinedOutput()
+	if err != nil {
+		// If merge-base fails (which can happen in simple repos), use initialCommitHash
+		return getGitLog(repoDir, initialCommitHash)
+	}
+
+	mergeBaseHash := strings.TrimSpace(string(mergeBase))
+	if mergeBaseHash == "" {
+		// If merge-base doesn't return a valid hash, use initialCommitHash
+		return getGitLog(repoDir, initialCommitHash)
+	}
+
+	// Use the merge-base as the 'from' point
+	return getGitLog(repoDir, mergeBaseHash)
+}
+
+// getGitLog gets the git log with the specified format using the provided fromCommit
+func getGitLog(repoDir string, fromCommit string) ([]LogEntry, error) {
+	// Check if fromCommit~10 exists (10 commits before fromCommit)
+	checkCmd := exec.Command("git", "-C", repoDir, "rev-parse", "--verify", fromCommit+"~10")
+	if err := checkCmd.Run(); err != nil {
+		// If fromCommit~10 doesn't exist, use just fromCommit..HEAD as the range
+		cmd := exec.Command("git", "-C", repoDir, "log", "-n", "1000", "--oneline", "--decorate", "--pretty=%H%x00%s%x00%d", fromCommit+"..HEAD")
+		out, err := cmd.CombinedOutput()
+		if err != nil {
+			return nil, fmt.Errorf("error executing git log: %w - %s", err, string(out))
+		}
+		return parseGitLog(string(out))
+	}
+
+	// Use fromCommit~10..HEAD range with the specified format for easy parsing
+	cmd := exec.Command("git", "-C", repoDir, "log", "-n", "1000", "--oneline", "--decorate", "--pretty=%H%x00%s%x00%d", fromCommit+"~10..HEAD")
+	out, err := cmd.CombinedOutput()
+	if err != nil {
+		return nil, fmt.Errorf("error executing git log: %w - %s", err, string(out))
+	}
+
+	return parseGitLog(string(out))
+}
+
+// parseGitLog parses the output of git log with null-separated fields
+func parseGitLog(logOutput string) ([]LogEntry, error) {
+	var entries []LogEntry
+	if logOutput == "" {
+		return entries, nil
+	}
+
+	scanner := bufio.NewScanner(strings.NewReader(strings.TrimSpace(logOutput)))
+	for scanner.Scan() {
+		line := scanner.Text()
+		parts := strings.Split(line, "\x00")
+		if len(parts) != 3 {
+			continue // Skip malformed lines
+		}
+
+		hash := parts[0]
+		subject := parts[1]
+		decoration := parts[2]
+
+		// Parse the refs from the decoration
+		refs := parseRefs(decoration)
+
+		entries = append(entries, LogEntry{
+			Hash:    hash,
+			Refs:    refs,
+			Subject: subject,
+		})
+	}
+
+	return entries, nil
+}
+
+// parseRefs extracts references from git decoration format
+func parseRefs(decoration string) []string {
+	// The decoration format from %d is: (HEAD -> main, origin/main, tag: v1.0.0)
+	if decoration == "" {
+		return nil
+	}
+
+	// Remove surrounding parentheses and whitespace
+	decoration = strings.TrimSpace(decoration)
+	decoration = strings.TrimPrefix(decoration, " (")
+	decoration = strings.TrimPrefix(decoration, "(")
+	decoration = strings.TrimSuffix(decoration, ")")
+	decoration = strings.TrimSuffix(decoration, ") ")
+
+	if decoration == "" {
+		return nil
+	}
+
+	// Split by comma
+	parts := strings.Split(decoration, ", ")
+
+	// Process each part
+	var refs []string
+	for _, part := range parts {
+		part = strings.TrimSpace(part)
+		if part == "" {
+			continue
+		}
+
+		// Handle HEAD -> branch format
+		if strings.HasPrefix(part, "HEAD -> ") {
+			refs = append(refs, strings.TrimPrefix(part, "HEAD -> "))
+			continue
+		}
+
+		// Handle tag: format
+		if strings.HasPrefix(part, "tag: ") {
+			refs = append(refs, strings.TrimPrefix(part, "tag: "))
+			continue
+		}
+
+		// Handle just HEAD (no branch)
+		if part == "HEAD" {
+			refs = append(refs, part)
+			continue
+		}
+
+		// Regular branch name
+		refs = append(refs, part)
+	}
+
+	return refs
+}