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/loop/server/loophttp.go b/loop/server/loophttp.go
index 20e1629..f7e6502 100644
--- a/loop/server/loophttp.go
+++ b/loop/server/loophttp.go
@@ -23,6 +23,7 @@
 	"syscall"
 	"time"
 
+	"sketch.dev/git_tools"
 	"sketch.dev/loop/server/gzhandler"
 
 	"github.com/creack/pty"
@@ -130,6 +131,12 @@
 	}
 
 	s.mux.HandleFunc("/stream", s.handleSSEStream)
+
+	// Git tool endpoints
+	s.mux.HandleFunc("/git/rawdiff", s.handleGitRawDiff)
+	s.mux.HandleFunc("/git/show", s.handleGitShow)
+	s.mux.HandleFunc("/git/recentlog", s.handleGitRecentLog)
+
 	s.mux.HandleFunc("/diff", func(w http.ResponseWriter, r *http.Request) {
 		// Check if a specific commit hash was requested
 		commit := r.URL.Query().Get("commit")
@@ -1200,3 +1207,107 @@
 		AgentState:           s.agent.CurrentStateName(),
 	}
 }
+
+func (s *Server) handleGitRawDiff(w http.ResponseWriter, r *http.Request) {
+	if r.Method != "GET" {
+		w.WriteHeader(http.StatusMethodNotAllowed)
+		return
+	}
+
+	// Get the git working directory from agent
+	repoDir := s.agent.WorkingDir()
+
+	// Parse query parameters
+	query := r.URL.Query()
+	commit := query.Get("commit")
+	from := query.Get("from")
+	to := query.Get("to")
+
+	// If commit is specified, use commit^ and commit as from and to
+	if commit != "" {
+		from = commit + "^"
+		to = commit
+	}
+
+	// Check if we have enough parameters
+	if from == "" || to == "" {
+		http.Error(w, "Missing required parameters: either 'commit' or both 'from' and 'to'", http.StatusBadRequest)
+		return
+	}
+
+	// Call the git_tools function
+	diff, err := git_tools.GitRawDiff(repoDir, from, to)
+	if err != nil {
+		http.Error(w, fmt.Sprintf("Error getting git diff: %v", err), http.StatusInternalServerError)
+		return
+	}
+
+	// Return the result as JSON
+	w.Header().Set("Content-Type", "application/json")
+	if err := json.NewEncoder(w).Encode(diff); err != nil {
+		http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
+		return
+	}
+}
+
+func (s *Server) handleGitShow(w http.ResponseWriter, r *http.Request) {
+	if r.Method != "GET" {
+		w.WriteHeader(http.StatusMethodNotAllowed)
+		return
+	}
+
+	// Get the git working directory from agent
+	repoDir := s.agent.WorkingDir()
+
+	// Parse query parameters
+	hash := r.URL.Query().Get("hash")
+	if hash == "" {
+		http.Error(w, "Missing required parameter: 'hash'", http.StatusBadRequest)
+		return
+	}
+
+	// Call the git_tools function
+	show, err := git_tools.GitShow(repoDir, hash)
+	if err != nil {
+		http.Error(w, fmt.Sprintf("Error running git show: %v", err), http.StatusInternalServerError)
+		return
+	}
+
+	// Create a JSON response
+	response := map[string]string{
+		"hash":   hash,
+		"output": show,
+	}
+
+	// Return the result as JSON
+	w.Header().Set("Content-Type", "application/json")
+	if err := json.NewEncoder(w).Encode(response); err != nil {
+		http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
+		return
+	}
+}
+
+func (s *Server) handleGitRecentLog(w http.ResponseWriter, r *http.Request) {
+	if r.Method != "GET" {
+		w.WriteHeader(http.StatusMethodNotAllowed)
+		return
+	}
+
+	// Get the git working directory and initial commit from agent
+	repoDir := s.agent.WorkingDir()
+	initialCommit := s.agent.SketchGitBaseRef()
+
+	// Call the git_tools function
+	log, err := git_tools.GitRecentLog(repoDir, initialCommit)
+	if err != nil {
+		http.Error(w, fmt.Sprintf("Error getting git log: %v", err), http.StatusInternalServerError)
+		return
+	}
+
+	// Return the result as JSON
+	w.Header().Set("Content-Type", "application/json")
+	if err := json.NewEncoder(w).Encode(log); err != nil {
+		http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
+		return
+	}
+}
diff --git a/loop/server/loophttp_test.go b/loop/server/loophttp_test.go
index d68928e..fc52532 100644
--- a/loop/server/loophttp_test.go
+++ b/loop/server/loophttp_test.go
@@ -27,6 +27,7 @@
 	initialCommit            string
 	title                    string
 	branchName               string
+	workingDir               string
 }
 
 func (m *mockAgent) NewIterator(ctx context.Context, nextMessageIdx int) loop.MessageIterator {
@@ -198,6 +199,10 @@
 	return m.initialCommit
 }
 
+func (m *mockAgent) SketchGitBaseRef() string {
+	return "sketch-base-test-session"
+}
+
 func (m *mockAgent) Title() string {
 	m.mu.RLock()
 	defer m.mu.RUnlock()
@@ -220,7 +225,7 @@
 func (m *mockAgent) CancelToolUse(id string, cause error) error  { return nil }
 func (m *mockAgent) TotalUsage() conversation.CumulativeUsage    { return conversation.CumulativeUsage{} }
 func (m *mockAgent) OriginalBudget() conversation.Budget         { return conversation.Budget{} }
-func (m *mockAgent) WorkingDir() string                          { return "/app" }
+func (m *mockAgent) WorkingDir() string                          { return m.workingDir }
 func (m *mockAgent) Diff(commit *string) (string, error)         { return "", nil }
 func (m *mockAgent) OS() string                                  { return "linux" }
 func (m *mockAgent) SessionID() string                           { return "test-session" }
@@ -379,3 +384,87 @@
 		t.Errorf("Did not receive any events")
 	}
 }
+
+func TestGitRawDiffHandler(t *testing.T) {
+	// Create a mock agent
+	mockAgent := &mockAgent{
+		workingDir: t.TempDir(), // Use a temp directory
+	}
+
+	// Create the server with the mock agent
+	server, err := server.New(mockAgent, nil)
+	if err != nil {
+		t.Fatalf("Failed to create server: %v", err)
+	}
+
+	// Create a test HTTP server
+	testServer := httptest.NewServer(server)
+	defer testServer.Close()
+
+	// Test missing parameters
+	resp, err := http.Get(testServer.URL + "/git/rawdiff")
+	if err != nil {
+		t.Fatalf("Failed to make HTTP request: %v", err)
+	}
+	if resp.StatusCode != http.StatusBadRequest {
+		t.Errorf("Expected status bad request, got: %d", resp.StatusCode)
+	}
+
+	// Test with commit parameter (this will fail due to no git repo, but we're testing the API, not git)
+	resp, err = http.Get(testServer.URL + "/git/rawdiff?commit=HEAD")
+	if err != nil {
+		t.Fatalf("Failed to make HTTP request: %v", err)
+	}
+	// We expect an error since there's no git repository, but the request should be processed
+	if resp.StatusCode != http.StatusInternalServerError {
+		t.Errorf("Expected status 500, got: %d", resp.StatusCode)
+	}
+
+	// Test with from/to parameters
+	resp, err = http.Get(testServer.URL + "/git/rawdiff?from=HEAD~1&to=HEAD")
+	if err != nil {
+		t.Fatalf("Failed to make HTTP request: %v", err)
+	}
+	// We expect an error since there's no git repository, but the request should be processed
+	if resp.StatusCode != http.StatusInternalServerError {
+		t.Errorf("Expected status 500, got: %d", resp.StatusCode)
+	}
+}
+
+func TestGitShowHandler(t *testing.T) {
+	// Create a mock agent
+	mockAgent := &mockAgent{
+		workingDir: t.TempDir(), // Use a temp directory
+	}
+
+	// Create the server with the mock agent
+	server, err := server.New(mockAgent, nil)
+	if err != nil {
+		t.Fatalf("Failed to create server: %v", err)
+	}
+
+	// Create a test HTTP server
+	testServer := httptest.NewServer(server)
+	defer testServer.Close()
+
+	// Test missing parameter
+	resp, err := http.Get(testServer.URL + "/git/show")
+	if err != nil {
+		t.Fatalf("Failed to make HTTP request: %v", err)
+	}
+	if resp.StatusCode != http.StatusBadRequest {
+		t.Errorf("Expected status bad request, got: %d", resp.StatusCode)
+	}
+
+	// Test with hash parameter (this will fail due to no git repo, but we're testing the API, not git)
+	resp, err = http.Get(testServer.URL + "/git/show?hash=HEAD")
+	if err != nil {
+		t.Fatalf("Failed to make HTTP request: %v", err)
+	}
+	// We expect an error since there's no git repository, but the request should be processed
+	if resp.StatusCode != http.StatusInternalServerError {
+		t.Errorf("Expected status 500, got: %d", resp.StatusCode)
+	}
+}
+
+// Removing duplicate method definition