sketch: "git push" button

Ultimately, we want to allow users to push their changes to github, and
thereby do a good chunk of work without resorting to the terminal (and
figuring out how to move the git references around, which requires a
bunch of esotiric and annoying expertise).

This commit introduces:

1. For outtie's HTTP server (which is now comically Go HTTP ->
   CGI-effing-bin -> git -> shell script -> git in this case), there's a
   custom git hook that forwards changes to refs/remotes/origin/foo to
   origin/foo. This is a git proxy of sorts. By forwarding the
   SSH_AUTH_SOCK, we can use outtie's auth options without giving innie
   the actual credentials. This works by creating a temporary directory
   for git hooks (for outtie).

2. Innie sets up a new remote, "upstream" when a "passthrough-upstream"
   flag is pasksed. This remote kind of looks like the real upstream (so
   upstream/foo) is fetched. This will let the agent handle rebases
   better.

3. Innie exposes a /pushinfo handler that returns the list of remotes
   and the current commit and such. These have nice display names for
   the outtie's machine and github if useful.

   There's also a /push handler. This is the thing that knows about the
   refs/remotes/origin/foo thing. There's no magic git push refspec that
   makes this all work without that, I think. (Maybe there is? I don't
   think there is.)

   Note that there's been some changes about what the remotes look like,
   and when we use the remotes and when we use agent.GitOrigin().
   We may be able to simplify this by using git's insteadof
   configurations, but I think it's fine.

4. The web UI exposes a button to push, choose the remote and branch,
   and such. If it can't do the push, you'll get a button to try to get
   the agent to rebase.

   We don't allow force pushes in the UI. We're treating those
   as an advanced feature, and, if you need to do that, you can
   figure it out.

This was collaboration with a gazillion sketch sessions.
diff --git a/loop/server/loophttp.go b/loop/server/loophttp.go
index 79e9e54..296317b 100644
--- a/loop/server/loophttp.go
+++ b/loop/server/loophttp.go
@@ -19,6 +19,7 @@
 	"os"
 	"os/exec"
 	"path/filepath"
+	"regexp"
 	"runtime/debug"
 	"strconv"
 	"strings"
@@ -34,6 +35,60 @@
 	"sketch.dev/loop/server/gzhandler"
 )
 
+// Remote represents a git remote with display information.
+type Remote struct {
+	Name        string `json:"name"`
+	URL         string `json:"url"`
+	DisplayName string `json:"display_name"`
+	IsGitHub    bool   `json:"is_github"`
+}
+
+// GitPushInfoResponse represents the response from /git/pushinfo
+type GitPushInfoResponse struct {
+	Hash    string   `json:"hash"`
+	Subject string   `json:"subject"`
+	Remotes []Remote `json:"remotes"`
+}
+
+// GitPushRequest represents the request body for /git/push
+type GitPushRequest struct {
+	Remote string `json:"remote"`
+	Branch string `json:"branch"`
+	Commit string `json:"commit"`
+	DryRun bool   `json:"dry_run"`
+	Force  bool   `json:"force"`
+}
+
+// GitPushResponse represents the response from /git/push
+type GitPushResponse struct {
+	Success bool   `json:"success"`
+	Output  string `json:"output"`
+	DryRun  bool   `json:"dry_run"`
+	Error   string `json:"error,omitempty"`
+}
+
+// isGitHubURL checks if a URL is a GitHub URL
+func isGitHubURL(url string) bool {
+	return strings.Contains(url, "github.com")
+}
+
+// simplifyGitHubURL simplifies GitHub URLs to "owner/repo" format
+// and also returns whether it's a github url
+func simplifyGitHubURL(url string) (string, bool) {
+	// Handle GitHub URLs in various formats
+	if strings.Contains(url, "github.com") {
+		// Extract owner/repo from URLs like:
+		// https://github.com/owner/repo.git
+		// git@github.com:owner/repo.git
+		// https://github.com/owner/repo
+		re := regexp.MustCompile(`github\.com[:/]([^/]+/[^/]+?)(?:\.git)?/?$`)
+		if matches := re.FindStringSubmatch(url); len(matches) > 1 {
+			return matches[1], true
+		}
+	}
+	return url, false
+}
+
 // terminalSession represents a terminal session with its PTY and the event channel
 type terminalSession struct {
 	pty                *os.File
@@ -691,6 +746,12 @@
 		json.NewEncoder(w).Encode(map[string]string{"path": filename})
 	})
 
+	// Handler for /git/pushinfo - returns HEAD commit and remotes for push dialog
+	s.mux.HandleFunc("/git/pushinfo", s.handleGitPushInfo)
+
+	// Handler for /git/push - handles git push operations
+	s.mux.HandleFunc("/git/push", s.handleGitPush)
+
 	// Handler for /cancel - cancels the current inner loop in progress
 	s.mux.HandleFunc("/cancel", func(w http.ResponseWriter, r *http.Request) {
 		if r.Method != http.MethodPost {
@@ -1560,3 +1621,178 @@
 	}
 	_ = json.NewEncoder(w).Encode(response) // can't do anything useful with errors anyway
 }
+
+// handleGitPushInfo returns the current HEAD commit info and remotes for push dialog
+func (s *Server) handleGitPushInfo(w http.ResponseWriter, r *http.Request) {
+	if r.Method != "GET" {
+		w.WriteHeader(http.StatusMethodNotAllowed)
+		return
+	}
+
+	repoDir := s.agent.RepoRoot()
+
+	// Get the current HEAD commit hash and subject in one command
+	cmd := exec.Command("git", "log", "-n", "1", "--format=%H%x00%s", "HEAD")
+	cmd.Dir = repoDir
+	output, err := cmd.Output()
+	if err != nil {
+		http.Error(w, fmt.Sprintf("Error getting HEAD commit: %v", err), http.StatusInternalServerError)
+		return
+	}
+
+	parts := strings.Split(strings.TrimSpace(string(output)), "\x00")
+	if len(parts) != 2 {
+		http.Error(w, "Unexpected git log output format", http.StatusInternalServerError)
+		return
+	}
+	hash := parts[0]
+	subject := parts[1]
+
+	// Get list of remote names
+	cmd = exec.Command("git", "remote")
+	cmd.Dir = repoDir
+	output, err = cmd.Output()
+	if err != nil {
+		http.Error(w, fmt.Sprintf("Error getting remotes: %v", err), http.StatusInternalServerError)
+		return
+	}
+
+	remoteNames := strings.Fields(strings.TrimSpace(string(output)))
+
+	remotes := make([]Remote, 0, len(remoteNames))
+
+	// Get URL and display name for each remote
+	for _, remoteName := range remoteNames {
+		cmd = exec.Command("git", "remote", "get-url", remoteName)
+		cmd.Dir = repoDir
+		urlOutput, err := cmd.Output()
+		if err != nil {
+			// Skip this remote if we can't get its URL
+			continue
+		}
+		url := strings.TrimSpace(string(urlOutput))
+
+		// Set display name based on passthrough-upstream and remote name
+		var displayName string
+		var isGitHub bool
+		if s.agent.PassthroughUpstream() && remoteName == "origin" {
+			// For passthrough upstream, origin displays as "outside_hostname:outside_working_dir"
+			displayName = fmt.Sprintf("%s:%s", s.agent.OutsideHostname(), s.agent.OutsideWorkingDir())
+			isGitHub = false
+		} else if remoteName == "origin" || remoteName == "upstream" {
+			// Use git_origin value, simplified for GitHub URLs
+			displayName, isGitHub = simplifyGitHubURL(s.agent.GitOrigin())
+		} else {
+			// For other remotes, use the remote URL directly
+			displayName, isGitHub = simplifyGitHubURL(url)
+		}
+
+		remotes = append(remotes, Remote{
+			Name:        remoteName,
+			URL:         url,
+			DisplayName: displayName,
+			IsGitHub:    isGitHub,
+		})
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	response := GitPushInfoResponse{
+		Hash:    hash,
+		Subject: subject,
+		Remotes: remotes,
+	}
+	_ = json.NewEncoder(w).Encode(response)
+}
+
+// handleGitPush handles git push operations
+func (s *Server) handleGitPush(w http.ResponseWriter, r *http.Request) {
+	if r.Method != "POST" {
+		w.WriteHeader(http.StatusMethodNotAllowed)
+		return
+	}
+
+	// Parse request body
+	var requestBody GitPushRequest
+
+	if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
+		http.Error(w, fmt.Sprintf("Error parsing request body: %v", err), http.StatusBadRequest)
+		return
+	}
+	defer r.Body.Close()
+
+	if requestBody.Remote == "" || requestBody.Branch == "" || requestBody.Commit == "" {
+		http.Error(w, "Missing required parameters: remote, branch, and commit", http.StatusBadRequest)
+		return
+	}
+
+	repoDir := s.agent.RepoRoot()
+
+	// Build the git push command
+	args := []string{"push"}
+	if requestBody.DryRun {
+		args = append(args, "--dry-run")
+	}
+	if requestBody.Force {
+		args = append(args, "--force")
+	}
+
+	// Determine the target refspec
+	var targetRef string
+	if s.agent.PassthroughUpstream() && requestBody.Remote == "upstream" {
+		// Special case: upstream with passthrough-upstream pushes to refs/remotes/origin/<branch>
+		targetRef = fmt.Sprintf("refs/remotes/origin/%s", requestBody.Branch)
+	} else {
+		// Normal case: push to refs/heads/<branch>
+		targetRef = fmt.Sprintf("refs/heads/%s", requestBody.Branch)
+	}
+
+	args = append(args, requestBody.Remote, fmt.Sprintf("%s:%s", requestBody.Commit, targetRef))
+
+	// Log the git push command being executed
+	slog.InfoContext(r.Context(), "executing git push command",
+		"command", "git",
+		"args", args,
+		"remote", requestBody.Remote,
+		"branch", requestBody.Branch,
+		"commit", requestBody.Commit,
+		"target_ref", targetRef,
+		"dry_run", requestBody.DryRun,
+		"force", requestBody.Force,
+		"repo_dir", repoDir)
+
+	cmd := exec.Command("git", args...)
+	cmd.Dir = repoDir
+	// Ideally we want to pass an extra HTTP header so that the
+	// server can know that this was likely a user initiated action
+	// and not an agent-initiated action. However, git push weirdly
+	// doesn't take a "-c" option, and the only handy env variable that
+	// because a header is the user agent, so we abuse it...
+	cmd.Env = append(os.Environ(), "GIT_HTTP_USER_AGENT=sketch-intentional-push")
+	output, err := cmd.CombinedOutput()
+
+	// Log the result of the git push command
+	if err != nil {
+		slog.WarnContext(r.Context(), "git push command failed",
+			"error", err,
+			"output", string(output),
+			"args", args)
+	} else {
+		slog.InfoContext(r.Context(), "git push command completed successfully",
+			"output", string(output),
+			"args", args)
+	}
+
+	// Prepare response
+	response := GitPushResponse{
+		Success: err == nil,
+		Output:  string(output),
+		DryRun:  requestBody.DryRun,
+	}
+
+	if err != nil {
+		response.Error = err.Error()
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	_ = json.NewEncoder(w).Encode(response)
+}
diff --git a/loop/server/loophttp_test.go b/loop/server/loophttp_test.go
index c6d4b6b..04a18ba 100644
--- a/loop/server/loophttp_test.go
+++ b/loop/server/loophttp_test.go
@@ -3,6 +3,7 @@
 import (
 	"bufio"
 	"context"
+	"io"
 	"net/http"
 	"net/http/httptest"
 	"slices"
@@ -245,6 +246,7 @@
 func (m *mockAgent) OutsideWorkingDir() string                   { return "/app" }
 func (m *mockAgent) GitOrigin() string                           { return "" }
 func (m *mockAgent) GitUsername() string                         { return m.gitUsername }
+func (m *mockAgent) PassthroughUpstream() bool                   { return false }
 func (m *mockAgent) OpenBrowser(url string)                      {}
 func (m *mockAgent) CompactConversation(ctx context.Context) error {
 	// Mock implementation - just return nil
@@ -678,3 +680,130 @@
 
 	t.Log("State endpoint includes port information correctly")
 }
+
+// TestGitPushHandler tests the git push endpoint
+func TestGitPushHandler(t *testing.T) {
+	mockAgent := &mockAgent{
+		workingDir:   t.TempDir(),
+		branchPrefix: "sketch/",
+	}
+
+	// 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 required parameters
+	tests := []struct {
+		name           string
+		requestBody    string
+		expectedStatus int
+		expectedError  string
+	}{
+		{
+			name:           "missing all parameters",
+			requestBody:    `{}`,
+			expectedStatus: http.StatusBadRequest,
+			expectedError:  "Missing required parameters: remote, branch, and commit",
+		},
+		{
+			name:           "missing commit parameter",
+			requestBody:    `{"remote": "origin", "branch": "main"}`,
+			expectedStatus: http.StatusBadRequest,
+			expectedError:  "Missing required parameters: remote, branch, and commit",
+		},
+		{
+			name:           "missing remote parameter",
+			requestBody:    `{"branch": "main", "commit": "abc123"}`,
+			expectedStatus: http.StatusBadRequest,
+			expectedError:  "Missing required parameters: remote, branch, and commit",
+		},
+		{
+			name:           "missing branch parameter",
+			requestBody:    `{"remote": "origin", "commit": "abc123"}`,
+			expectedStatus: http.StatusBadRequest,
+			expectedError:  "Missing required parameters: remote, branch, and commit",
+		},
+		{
+			name:           "all parameters present",
+			requestBody:    `{"remote": "origin", "branch": "main", "commit": "abc123", "dry_run": true}`,
+			expectedStatus: http.StatusOK, // Parameters are valid, response will be JSON
+			expectedError:  "",            // No parameter validation error
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			resp, err := http.Post(
+				testServer.URL+"/git/push",
+				"application/json",
+				strings.NewReader(tt.requestBody),
+			)
+			if err != nil {
+				t.Fatalf("Failed to make HTTP request: %v", err)
+			}
+			defer resp.Body.Close()
+
+			if resp.StatusCode != tt.expectedStatus {
+				t.Errorf("Expected status %d, got: %d", tt.expectedStatus, resp.StatusCode)
+			}
+
+			if tt.expectedError != "" {
+				body, err := io.ReadAll(resp.Body)
+				if err != nil {
+					t.Fatalf("Failed to read response body: %v", err)
+				}
+				if !strings.Contains(string(body), tt.expectedError) {
+					t.Errorf("Expected error message '%s', got: %s", tt.expectedError, string(body))
+				}
+			}
+		})
+	}
+}
+
+// TestGitPushInfoHandler tests the git push info endpoint
+func TestGitPushInfoHandler(t *testing.T) {
+	mockAgent := &mockAgent{
+		workingDir:   t.TempDir(),
+		branchPrefix: "sketch/",
+	}
+
+	// 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 GET request
+	resp, err := http.Get(testServer.URL + "/git/pushinfo")
+	if err != nil {
+		t.Fatalf("Failed to make HTTP request: %v", err)
+	}
+	defer resp.Body.Close()
+
+	// We expect this to fail with 500 since there's no git repository
+	// but the endpoint should be accessible
+	if resp.StatusCode != http.StatusInternalServerError {
+		t.Errorf("Expected status 500, got: %d", resp.StatusCode)
+	}
+
+	// Test that POST is not allowed
+	resp, err = http.Post(testServer.URL+"/git/pushinfo", "application/json", strings.NewReader(`{}`))
+	if err != nil {
+		t.Fatalf("Failed to make HTTP request: %v", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusMethodNotAllowed {
+		t.Errorf("Expected status 405, got: %d", resp.StatusCode)
+	}
+}