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_test.go b/git_tools/git_tools_test.go
new file mode 100644
index 0000000..5be6b0f
--- /dev/null
+++ b/git_tools/git_tools_test.go
@@ -0,0 +1,365 @@
+package git_tools
+
+import (
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func setupTestRepo(t *testing.T) string {
+	// Create a temporary directory for the test repository
+	tempDir, err := os.MkdirTemp("", "git-tools-test")
+	if err != nil {
+		t.Fatalf("Failed to create temp directory: %v", err)
+	}
+
+	// Initialize a git repository
+	cmd := exec.Command("git", "-C", tempDir, "init")
+	if out, err := cmd.CombinedOutput(); err != nil {
+		t.Fatalf("Failed to initialize git repo: %v - %s", err, out)
+	}
+
+	// Configure git user
+	cmd = exec.Command("git", "-C", tempDir, "config", "user.email", "test@example.com")
+	if out, err := cmd.CombinedOutput(); err != nil {
+		t.Fatalf("Failed to configure git user email: %v - %s", err, out)
+	}
+
+	cmd = exec.Command("git", "-C", tempDir, "config", "user.name", "Test User")
+	if out, err := cmd.CombinedOutput(); err != nil {
+		t.Fatalf("Failed to configure git user name: %v - %s", err, out)
+	}
+
+	return tempDir
+}
+
+func createAndCommitFile(t *testing.T, repoDir, filename, content string, stage bool) string {
+	filePath := filepath.Join(repoDir, filename)
+	if err := os.WriteFile(filePath, []byte(content), 0644); err != nil {
+		t.Fatalf("Failed to write file: %v", err)
+	}
+
+	if stage {
+		cmd := exec.Command("git", "-C", repoDir, "add", filename)
+		if out, err := cmd.CombinedOutput(); err != nil {
+			t.Fatalf("Failed to add file: %v - %s", err, out)
+		}
+
+		cmd = exec.Command("git", "-C", repoDir, "commit", "-m", "Add "+filename)
+		if out, err := cmd.CombinedOutput(); err != nil {
+			t.Fatalf("Failed to commit file: %v - %s", err, out)
+		}
+
+		// Get the commit hash
+		cmd = exec.Command("git", "-C", repoDir, "rev-parse", "HEAD")
+		out, err := cmd.Output()
+		if err != nil {
+			t.Fatalf("Failed to get commit hash: %v", err)
+		}
+		return string(out[:len(out)-1]) // Trim newline
+	}
+
+	return ""
+}
+
+func TestGitRawDiff(t *testing.T) {
+	repoDir := setupTestRepo(t)
+	defer os.RemoveAll(repoDir)
+
+	// Create initial file
+	initHash := createAndCommitFile(t, repoDir, "test.txt", "initial content\n", true)
+
+	// Modify the file
+	modHash := createAndCommitFile(t, repoDir, "test.txt", "initial content\nmodified content\n", true)
+
+	// Test the diff between the two commits
+	diff, err := GitRawDiff(repoDir, initHash, modHash)
+	if err != nil {
+		t.Fatalf("GitRawDiff failed: %v", err)
+	}
+
+	if len(diff) != 1 {
+		t.Fatalf("Expected 1 file in diff, got %d", len(diff))
+	}
+
+	if diff[0].Path != "test.txt" {
+		t.Errorf("Expected path to be test.txt, got %s", diff[0].Path)
+	}
+
+	if diff[0].Status != "M" {
+		t.Errorf("Expected status to be M (modified), got %s", diff[0].Status)
+	}
+
+	if diff[0].OldMode == "" || diff[0].NewMode == "" {
+		t.Error("Expected file modes to be present")
+	}
+
+	if diff[0].OldHash == "" || diff[0].NewHash == "" {
+		t.Error("Expected file hashes to be present")
+	}
+
+	// Test with invalid commit hash
+	_, err = GitRawDiff(repoDir, "invalid", modHash)
+	if err == nil {
+		t.Error("Expected error for invalid commit hash, got none")
+	}
+}
+
+func TestGitShow(t *testing.T) {
+	repoDir := setupTestRepo(t)
+	defer os.RemoveAll(repoDir)
+
+	// Create file and commit
+	commitHash := createAndCommitFile(t, repoDir, "test.txt", "test content\n", true)
+
+	// Test GitShow
+	show, err := GitShow(repoDir, commitHash)
+	if err != nil {
+		t.Fatalf("GitShow failed: %v", err)
+	}
+
+	if show == "" {
+		t.Error("Expected non-empty output from GitShow")
+	}
+
+	// Test with invalid commit hash
+	_, err = GitShow(repoDir, "invalid")
+	if err == nil {
+		t.Error("Expected error for invalid commit hash, got none")
+	}
+}
+
+func TestParseGitLog(t *testing.T) {
+	// Test with the format from --pretty="%H%x00%s%x00%d"
+	logOutput := "abc123\x00Initial commit\x00 (HEAD -> main, origin/main)\n" +
+		"def456\x00Add feature X\x00 (tag: v1.0.0)\n" +
+		"ghi789\x00Fix bug Y\x00"
+
+	entries, err := parseGitLog(logOutput)
+	if err != nil {
+		t.Fatalf("parseGitLog returned error: %v", err)
+	}
+
+	if len(entries) != 3 {
+		t.Fatalf("Expected 3 log entries, got %d", len(entries))
+	}
+
+	// Check first entry
+	if entries[0].Hash != "abc123" {
+		t.Errorf("Expected hash abc123, got %s", entries[0].Hash)
+	}
+	if len(entries[0].Refs) != 2 {
+		t.Errorf("Expected 2 refs, got %d", len(entries[0].Refs))
+	}
+	if entries[0].Refs[0] != "main" || entries[0].Refs[1] != "origin/main" {
+		t.Errorf("Incorrect refs parsed: %v", entries[0].Refs)
+	}
+	if entries[0].Subject != "Initial commit" {
+		t.Errorf("Expected subject 'Initial commit', got '%s'", entries[0].Subject)
+	}
+
+	// Check second entry
+	if entries[1].Hash != "def456" {
+		t.Errorf("Expected hash def456, got %s", entries[1].Hash)
+	}
+	if len(entries[1].Refs) != 1 {
+		t.Errorf("Expected 1 ref, got %d", len(entries[1].Refs))
+	}
+	if entries[1].Refs[0] != "v1.0.0" {
+		t.Errorf("Incorrect tag parsed: %v", entries[1].Refs)
+	}
+	if entries[1].Subject != "Add feature X" {
+		t.Errorf("Expected subject 'Add feature X', got '%s'", entries[1].Subject)
+	}
+
+	// Check third entry
+	if entries[2].Hash != "ghi789" {
+		t.Errorf("Expected hash ghi789, got %s", entries[2].Hash)
+	}
+	if len(entries[2].Refs) != 0 {
+		t.Errorf("Expected 0 refs, got %d", len(entries[2].Refs))
+	}
+	if entries[2].Subject != "Fix bug Y" {
+		t.Errorf("Expected subject 'Fix bug Y', got '%s'", entries[2].Subject)
+	}
+}
+
+func TestParseRefs(t *testing.T) {
+	testCases := []struct {
+		decoration string
+		expected   []string
+	}{
+		{"(HEAD -> main, origin/main)", []string{"main", "origin/main"}},
+		{"(tag: v1.0.0)", []string{"v1.0.0"}},
+		{"(HEAD -> feature/branch, origin/feature/branch, tag: v0.9.0)", []string{"feature/branch", "origin/feature/branch", "v0.9.0"}},
+		{" (tag: v2.0.0) ", []string{"v2.0.0"}},
+		{"", nil},
+		{" ", nil},
+		{"()", nil},
+	}
+
+	for i, tc := range testCases {
+		refs := parseRefs(tc.decoration)
+
+		if len(refs) != len(tc.expected) {
+			t.Errorf("Case %d: Expected %d refs, got %d", i, len(tc.expected), len(refs))
+			continue
+		}
+
+		for j, ref := range refs {
+			if j >= len(tc.expected) || ref != tc.expected[j] {
+				t.Errorf("Case %d: Expected ref '%s', got '%s'", i, tc.expected[j], ref)
+			}
+		}
+	}
+}
+
+func TestGitRecentLog(t *testing.T) {
+	// Create a temporary directory for the test repository
+	tmpDir, err := os.MkdirTemp("", "git-test-*")
+	if err != nil {
+		t.Fatalf("Failed to create temp dir: %v", err)
+	}
+	defer os.RemoveAll(tmpDir)
+
+	// Initialize a git repository
+	initCmd := exec.Command("git", "-C", tmpDir, "init")
+	if out, err := initCmd.CombinedOutput(); err != nil {
+		t.Fatalf("Failed to initialize git repository: %v\n%s", err, out)
+	}
+
+	// Configure git user for the test repository
+	exec.Command("git", "-C", tmpDir, "config", "user.name", "Test User").Run()
+	exec.Command("git", "-C", tmpDir, "config", "user.email", "test@example.com").Run()
+
+	// Create initial commit
+	initialFile := filepath.Join(tmpDir, "initial.txt")
+	os.WriteFile(initialFile, []byte("initial content"), 0644)
+	exec.Command("git", "-C", tmpDir, "add", "initial.txt").Run()
+	initialCommitCmd := exec.Command("git", "-C", tmpDir, "commit", "-m", "Initial commit")
+	out, err := initialCommitCmd.CombinedOutput()
+	if err != nil {
+		t.Fatalf("Failed to create initial commit: %v\n%s", err, out)
+	}
+
+	// Get the initial commit hash
+	initialCommitCmd = exec.Command("git", "-C", tmpDir, "rev-parse", "HEAD")
+	initialCommitBytes, err := initialCommitCmd.Output()
+	if err != nil {
+		t.Fatalf("Failed to get initial commit hash: %v", err)
+	}
+	initialCommitHash := strings.TrimSpace(string(initialCommitBytes))
+
+	// Add a second commit
+	secondFile := filepath.Join(tmpDir, "second.txt")
+	os.WriteFile(secondFile, []byte("second content"), 0644)
+	exec.Command("git", "-C", tmpDir, "add", "second.txt").Run()
+	secondCommitCmd := exec.Command("git", "-C", tmpDir, "commit", "-m", "Second commit")
+	out, err = secondCommitCmd.CombinedOutput()
+	if err != nil {
+		t.Fatalf("Failed to create second commit: %v\n%s", err, out)
+	}
+
+	// Create a branch and tag
+	exec.Command("git", "-C", tmpDir, "branch", "test-branch").Run()
+	exec.Command("git", "-C", tmpDir, "tag", "-a", "v1.0.0", "-m", "Version 1.0.0").Run()
+
+	// Add a third commit
+	thirdFile := filepath.Join(tmpDir, "third.txt")
+	os.WriteFile(thirdFile, []byte("third content"), 0644)
+	exec.Command("git", "-C", tmpDir, "add", "third.txt").Run()
+	thirdCommitCmd := exec.Command("git", "-C", tmpDir, "commit", "-m", "Third commit")
+	out, err = thirdCommitCmd.CombinedOutput()
+	if err != nil {
+		t.Fatalf("Failed to create third commit: %v\n%s", err, out)
+	}
+
+	// Test GitRecentLog
+	log, err := GitRecentLog(tmpDir, initialCommitHash)
+	if err != nil {
+		t.Fatalf("GitRecentLog failed: %v", err)
+	}
+
+	// No need to check specific entries in order
+	// Just validate we can find the second and third commits we created
+
+	// Verify that we have the correct behavior with the fromCommit parameter:
+	// 1. We should find the second and third commits
+	// 2. We should NOT find the initial commit (it should be excluded)
+	foundThird := false
+	foundSecond := false
+	foundInitial := false
+	for _, entry := range log {
+		t.Logf("Found entry: %s - %s", entry.Hash, entry.Subject)
+		if entry.Subject == "Third commit" {
+			foundThird = true
+		} else if entry.Subject == "Second commit" {
+			foundSecond = true
+		} else if entry.Subject == "Initial commit" {
+			foundInitial = true
+		}
+	}
+
+	if !foundThird {
+		t.Errorf("Expected to find 'Third commit' in log entries")
+	}
+	if !foundSecond {
+		t.Errorf("Expected to find 'Second commit' in log entries")
+	}
+	if foundInitial {
+		t.Errorf("Should NOT have found 'Initial commit' in log entries (fromCommit parameter should exclude it)")
+	}
+}
+
+func TestParseRefsEdgeCases(t *testing.T) {
+	testCases := []struct {
+		name       string
+		decoration string
+		expected   []string
+	}{
+		{
+			name:       "Multiple tags and branches",
+			decoration: "(HEAD -> main, origin/main, tag: v1.0.0, tag: beta)",
+			expected:   []string{"main", "origin/main", "v1.0.0", "beta"},
+		},
+		{
+			name:       "Leading/trailing whitespace",
+			decoration: "  (HEAD -> main)  ",
+			expected:   []string{"main"},
+		},
+		{
+			name:       "No parentheses",
+			decoration: "HEAD -> main, tag: v1.0.0",
+			expected:   []string{"main", "v1.0.0"},
+		},
+		{
+			name:       "Feature branch with slash",
+			decoration: "(HEAD -> feature/new-ui)",
+			expected:   []string{"feature/new-ui"},
+		},
+		{
+			name:       "Only HEAD with no branch",
+			decoration: "(HEAD)",
+			expected:   []string{"HEAD"},
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			refs := parseRefs(tc.decoration)
+
+			if len(refs) != len(tc.expected) {
+				t.Errorf("%s: Expected %d refs, got %d", tc.name, len(tc.expected), len(refs))
+				return
+			}
+
+			for i, ref := range refs {
+				if ref != tc.expected[i] {
+					t.Errorf("%s: Expected ref[%d] = '%s', got '%s'", tc.name, i, tc.expected[i], ref)
+				}
+			}
+		})
+	}
+}