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/dockerimg/dockerimg.go b/dockerimg/dockerimg.go
index 16865cf..19d90cf 100644
--- a/dockerimg/dockerimg.go
+++ b/dockerimg/dockerimg.go
@@ -136,6 +136,9 @@
 
 	// MCPServers contains MCP server configurations
 	MCPServers []string
+
+	// PassthroughUpstream configures upstream remote for passthrough to innie
+	PassthroughUpstream bool
 }
 
 // LaunchContainer creates a docker container for a project, installs sketch and opens a connection to it.
@@ -176,6 +179,11 @@
 	// Capture the original git origin URL before we set up the temporary git server
 	config.OriginalGitOrigin = getOriginalGitOrigin(ctx, gitRoot)
 
+	// If we've got an upstream, let's configure
+	if config.OriginalGitOrigin != "" {
+		config.PassthroughUpstream = true
+	}
+
 	imgName, err := findOrBuildDockerImage(ctx, gitRoot, config.BaseImage, config.ForceRebuild, config.Verbose)
 	if err != nil {
 		return err
@@ -200,7 +208,7 @@
 	errCh := make(chan error)
 
 	// Start the git server
-	gitSrv, err := newGitServer(gitRoot)
+	gitSrv, err := newGitServer(gitRoot, config.PassthroughUpstream)
 	if err != nil {
 		return fmt.Errorf("failed to start git server: %w", err)
 	}
@@ -480,7 +488,7 @@
 	return gs.srv.Serve(gs.gitLn)
 }
 
-func newGitServer(gitRoot string) (*gitServer, error) {
+func newGitServer(gitRoot string, configureUpstreamPassthrough bool) (*gitServer, error) {
 	ret := &gitServer{
 		pass: rand.Text(),
 	}
@@ -499,7 +507,15 @@
 		}
 	}()
 
-	srv := http.Server{Handler: &gitHTTP{gitRepoRoot: gitRoot, pass: []byte(ret.pass), browserC: browserC}}
+	var hooksDir string
+	if configureUpstreamPassthrough {
+		hooksDir, err = setupHooksDir(gitRoot)
+		if err != nil {
+			return nil, fmt.Errorf("failed to setup hooks directory: %w", err)
+		}
+	}
+
+	srv := http.Server{Handler: &gitHTTP{gitRepoRoot: gitRoot, hooksDir: hooksDir, pass: []byte(ret.pass), browserC: browserC}}
 	ret.srv = &srv
 
 	_, gitPort, err := net.SplitHostPort(gitLn.Addr().String())
@@ -625,6 +641,9 @@
 	for _, mcpServer := range config.MCPServers {
 		cmdArgs = append(cmdArgs, "-mcp", mcpServer)
 	}
+	if config.PassthroughUpstream {
+		cmdArgs = append(cmdArgs, "-passthrough-upstream")
+	}
 
 	// Add additional docker arguments if provided
 	if config.DockerArgs != "" {
diff --git a/dockerimg/githttp.go b/dockerimg/githttp.go
index ecd46a1..acb099b 100644
--- a/dockerimg/githttp.go
+++ b/dockerimg/githttp.go
@@ -3,6 +3,7 @@
 import (
 	"context"
 	"crypto/subtle"
+	_ "embed"
 	"fmt"
 	"log/slog"
 	"net/http"
@@ -15,12 +16,45 @@
 	"time"
 )
 
+//go:embed pre-receive.sh
+var preReceiveScript string
+
 type gitHTTP struct {
 	gitRepoRoot string
+	hooksDir    string
 	pass        []byte
 	browserC    chan bool // browser launch requests
 }
 
+// setupHooksDir creates a temporary directory with git hooks for this session.
+//
+// This automatically forwards pushes from refs/remotes/origin/Y to origin/Y
+// when users push to remote tracking refs through the git HTTP backend.
+//
+// How it works:
+//  1. User pushes to refs/remotes/origin/feature-branch
+//  2. Pre-receive hook detects the pattern and extracts branch name
+//  3. Hook checks if it's a force push (declined if so)
+//  4. Hook runs "git push origin <commit>:feature-branch"
+//  5. If origin push fails, user's push also fails
+//
+// Note:
+//   - Error propagation from origin push to user push
+//   - Session isolation with temporary hooks directory
+func setupHooksDir(gitRepoRoot string) (string, error) {
+	hooksDir, err := os.MkdirTemp("", "sketch-git-hooks-*")
+	if err != nil {
+		return "", fmt.Errorf("failed to create hooks directory: %w", err)
+	}
+
+	preReceiveHook := filepath.Join(hooksDir, "pre-receive")
+	if err := os.WriteFile(preReceiveHook, []byte(preReceiveScript), 0o755); err != nil {
+		return "", fmt.Errorf("failed to write pre-receive hook: %w", err)
+	}
+
+	return hooksDir, nil
+}
+
 func (g *gitHTTP) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	defer func() {
 		if err := recover(); err != nil {
@@ -116,9 +150,16 @@
 	}
 
 	w.Header().Set("Cache-Control", "no-cache")
+
+	args := []string{"http-backend"}
+	if g.hooksDir != "" {
+		// Use -c flag to set core.hooksPath for this git command only
+		args = []string{"-c", "core.hooksPath=" + g.hooksDir, "http-backend"}
+	}
+
 	h := &cgi.Handler{
 		Path: gitBin,
-		Args: []string{"http-backend"},
+		Args: args,
 		Dir:  g.gitRepoRoot,
 		Env: []string{
 			"GIT_PROJECT_ROOT=" + g.gitRepoRoot,
@@ -129,6 +170,9 @@
 			"GIT_HTTP_ALLOW_REPACK=true",
 			"GIT_HTTP_ALLOW_PUSH=true",
 			"GIT_HTTP_VERBOSE=1",
+			// We need to pass through the SSH auth sock to the CGI script
+			// so that we can use the user's existing SSH key infra to authenticate.
+			"SSH_AUTH_SOCK=" + os.Getenv("SSH_AUTH_SOCK"),
 		},
 	}
 	h.ServeHTTP(w, r)
diff --git a/dockerimg/githttp_test.go b/dockerimg/githttp_test.go
new file mode 100644
index 0000000..95a565a
--- /dev/null
+++ b/dockerimg/githttp_test.go
@@ -0,0 +1,230 @@
+package dockerimg
+
+import (
+	"os"
+	"os/exec"
+	"path/filepath"
+	"testing"
+)
+
+func TestSetupHooksDir(t *testing.T) {
+	// Create a temporary git repository
+	tmpDir, err := os.MkdirTemp("", "test-git-repo-*")
+	if err != nil {
+		t.Fatalf("failed to create temp dir: %v", err)
+	}
+	defer os.RemoveAll(tmpDir)
+
+	// Test setupHooksDir function
+	hooksDir, err := setupHooksDir(tmpDir)
+	if err != nil {
+		t.Fatalf("setupHooksDir failed: %v", err)
+	}
+	defer os.RemoveAll(hooksDir)
+
+	// Verify hooks directory was created
+	if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
+		t.Errorf("hooks directory was not created: %s", hooksDir)
+	}
+
+	// Verify pre-receive hook was created
+	preReceiveHook := filepath.Join(hooksDir, "pre-receive")
+	if _, err := os.Stat(preReceiveHook); os.IsNotExist(err) {
+		t.Errorf("pre-receive hook was not created: %s", preReceiveHook)
+	}
+
+	// Verify pre-receive hook is executable
+	info, err := os.Stat(preReceiveHook)
+	if err != nil {
+		t.Errorf("failed to stat pre-receive hook: %v", err)
+	}
+	if info.Mode()&0o111 == 0 {
+		t.Errorf("pre-receive hook is not executable")
+	}
+
+	// Verify pre-receive hook contains expected content
+	content, err := os.ReadFile(preReceiveHook)
+	if err != nil {
+		t.Errorf("failed to read pre-receive hook: %v", err)
+	}
+
+	// Check for key elements in the script
+	contentStr := string(content)
+	if !containsAll(contentStr, []string{
+		"#!/bin/bash",
+		"refs/remotes/origin/",
+		"git push origin",
+		"Force pushes are not allowed",
+		"git merge-base --is-ancestor",
+	}) {
+		t.Errorf("pre-receive hook missing expected content")
+	}
+}
+
+// Helper function to check if a string contains all substrings
+func containsAll(str string, substrings []string) bool {
+	for _, substr := range substrings {
+		if !contains(str, substr) {
+			return false
+		}
+	}
+	return true
+}
+
+// Helper function to check if a string contains a substring
+func contains(str, substr string) bool {
+	return len(str) >= len(substr) && findIndex(str, substr) >= 0
+}
+
+// Helper function to find index of substring (simple implementation)
+func findIndex(str, substr string) int {
+	for i := 0; i <= len(str)-len(substr); i++ {
+		if str[i:i+len(substr)] == substr {
+			return i
+		}
+	}
+	return -1
+}
+
+func TestPreReceiveHookScript(t *testing.T) {
+	// Create a temporary git repository
+	tmpDir, err := os.MkdirTemp("", "test-git-repo-*")
+	if err != nil {
+		t.Fatalf("failed to create temp dir: %v", err)
+	}
+	defer os.RemoveAll(tmpDir)
+
+	// Set up hooks directory
+	hooksDir, err := setupHooksDir(tmpDir)
+	if err != nil {
+		t.Fatalf("setupHooksDir failed: %v", err)
+	}
+	defer os.RemoveAll(hooksDir)
+
+	// Read the pre-receive hook script
+	preReceiveHook := filepath.Join(hooksDir, "pre-receive")
+	script, err := os.ReadFile(preReceiveHook)
+	if err != nil {
+		t.Fatalf("failed to read pre-receive hook: %v", err)
+	}
+
+	// Verify the script contains the expected bash shebang
+	scriptContent := string(script)
+	if !contains(scriptContent, "#!/bin/bash") {
+		t.Errorf("pre-receive hook missing bash shebang")
+	}
+
+	// Verify the script contains logic to handle refs/remotes/origin/Y
+	if !contains(scriptContent, "refs/remotes/origin/(.+)") {
+		t.Errorf("pre-receive hook missing refs/remotes/origin pattern matching")
+	}
+
+	// Verify the script contains force-push protection
+	if !contains(scriptContent, "git merge-base --is-ancestor") {
+		t.Errorf("pre-receive hook missing force-push protection")
+	}
+
+	// Verify the script contains git push origin command
+	if !contains(scriptContent, "git push origin") {
+		t.Errorf("pre-receive hook missing git push origin command")
+	}
+
+	// Verify the script exits with proper error codes
+	if !contains(scriptContent, "exit 1") {
+		t.Errorf("pre-receive hook missing error exit code")
+	}
+
+	if !contains(scriptContent, "exit 0") {
+		t.Errorf("pre-receive hook missing success exit code")
+	}
+}
+
+func TestGitHTTPHooksConfiguration(t *testing.T) {
+	// Create a temporary git repository
+	tmpDir, err := os.MkdirTemp("", "test-git-repo-*")
+	if err != nil {
+		t.Fatalf("failed to create temp dir: %v", err)
+	}
+	defer os.RemoveAll(tmpDir)
+
+	// Initialize git repository
+	if err := runGitCommand(tmpDir, "init"); err != nil {
+		t.Fatalf("failed to init git repo: %v", err)
+	}
+
+	// Set up hooks directory
+	hooksDir, err := setupHooksDir(tmpDir)
+	if err != nil {
+		t.Fatalf("setupHooksDir failed: %v", err)
+	}
+	defer os.RemoveAll(hooksDir)
+
+	// Create gitHTTP instance
+	gitHTTP := &gitHTTP{
+		gitRepoRoot: tmpDir,
+		hooksDir:    hooksDir,
+		pass:        []byte("test-pass"),
+		browserC:    make(chan bool, 1),
+	}
+
+	// Test that the gitHTTP struct has the hooks directory set
+	if gitHTTP.hooksDir == "" {
+		t.Errorf("gitHTTP hooksDir is empty")
+	}
+
+	if gitHTTP.hooksDir != hooksDir {
+		t.Errorf("gitHTTP hooksDir mismatch: expected %s, got %s", hooksDir, gitHTTP.hooksDir)
+	}
+
+	// Verify that the hooks directory exists and contains the pre-receive hook
+	preReceiveHook := filepath.Join(hooksDir, "pre-receive")
+	if _, err := os.Stat(preReceiveHook); os.IsNotExist(err) {
+		t.Errorf("pre-receive hook missing: %s", preReceiveHook)
+	}
+}
+
+// Helper function to run git commands
+func runGitCommand(dir string, args ...string) error {
+	cmd := exec.Command("git", args...)
+	cmd.Dir = dir
+	return cmd.Run()
+}
+
+func TestPreReceiveHookExecution(t *testing.T) {
+	// Create a temporary git repository
+	tmpDir, err := os.MkdirTemp("", "test-git-repo-*")
+	if err != nil {
+		t.Fatalf("failed to create temp dir: %v", err)
+	}
+	defer os.RemoveAll(tmpDir)
+
+	// Initialize git repository
+	if err := runGitCommand(tmpDir, "init"); err != nil {
+		t.Fatalf("failed to init git repo: %v", err)
+	}
+
+	// Set up hooks directory
+	hooksDir, err := setupHooksDir(tmpDir)
+	if err != nil {
+		t.Fatalf("setupHooksDir failed: %v", err)
+	}
+	defer os.RemoveAll(hooksDir)
+
+	// Test the hook script syntax by running bash -n on it
+	preReceiveHook := filepath.Join(hooksDir, "pre-receive")
+	cmd := exec.Command("bash", "-n", preReceiveHook)
+	if err := cmd.Run(); err != nil {
+		t.Errorf("pre-receive hook has syntax errors: %v", err)
+	}
+
+	// Test that the hook script is executable
+	info, err := os.Stat(preReceiveHook)
+	if err != nil {
+		t.Fatalf("failed to stat pre-receive hook: %v", err)
+	}
+
+	mode := info.Mode()
+	if mode&0o111 == 0 {
+		t.Errorf("pre-receive hook is not executable: mode = %v", mode)
+	}
+}
diff --git a/dockerimg/pre-receive.sh b/dockerimg/pre-receive.sh
new file mode 100644
index 0000000..dfabb64
--- /dev/null
+++ b/dockerimg/pre-receive.sh
@@ -0,0 +1,83 @@
+#!/bin/bash
+# Pre-receive hook for sketch git http server
+# Handles refs/remotes/origin/Y pushes by forwarding them to origin/Y
+
+set -e
+
+# Timeout function for commands (macOS compatible)
+# Usage: run_with_timeout <timeout_seconds> <command> [args...]
+#
+# This is here because OX X doesn't ship /usr/bin/timeout by default!?!?
+run_with_timeout() {
+    local timeout=$1
+    shift
+
+    # Run command in background and capture PID
+    "$@" &
+    local cmd_pid=$!
+
+    # Start timeout killer in background
+    (
+        sleep "$timeout"
+        if kill -0 "$cmd_pid" 2>/dev/null; then
+            echo "Command timed out after ${timeout}s, killing process" >&2
+            kill -TERM "$cmd_pid" 2>/dev/null || true
+            sleep 2
+            kill -KILL "$cmd_pid" 2>/dev/null || true
+        fi
+    ) &
+    local killer_pid=$!
+
+    # Wait for command to complete
+    local exit_code=0
+    if wait "$cmd_pid" 2>/dev/null; then
+        exit_code=$?
+    else
+        exit_code=124  # timeout exit code
+    fi
+
+    # Clean up timeout killer
+    kill "$killer_pid" 2>/dev/null || true
+    wait "$killer_pid" 2>/dev/null || true
+
+    return $exit_code
+}
+
+# Read stdin for ref updates
+while read oldrev newrev refname; do
+    # Check if this is a push to refs/remotes/origin/Y pattern
+    if [[ "$refname" =~ ^refs/remotes/origin/(.+)$ ]]; then
+        branch_name="${BASH_REMATCH[1]}"
+
+        # Check if this is a force push by seeing if oldrev is not ancestor of newrev
+        if [ "$oldrev" != "0000000000000000000000000000000000000000" ]; then
+            # Check if this is a fast-forward (oldrev is ancestor of newrev)
+            if ! git merge-base --is-ancestor "$oldrev" "$newrev" 2>/dev/null; then
+                echo "Error: Force push detected to refs/remotes/origin/$branch_name" >&2
+                echo "Force pushes are not allowed" >&2
+                exit 1
+            fi
+        fi
+
+        echo "Detected push to refs/remotes/origin/$branch_name" >&2
+
+        # Verify HTTP_USER_AGENT is set to sketch-intentional-push for forwarding
+        if [ "$HTTP_USER_AGENT" != "sketch-intentional-push" ]; then
+            echo "Error: Unauthorized push to refs/remotes/origin/$branch_name" >&2
+            exit 1
+        fi
+
+        echo "Authorization verified, forwarding to origin" >&2
+
+        # Push to origin using the new commit with 10 second timeout
+        # There's an innocous "ref updates forbidden inside quarantine environment" warning that we can ignore.
+        if ! run_with_timeout 10 git push origin "$newrev:refs/heads/$branch_name"; then
+            echo "Error: Failed to push $newrev to origin/$branch_name (may have timed out)" >&2
+            exit 1
+        fi
+
+        echo "Successfully pushed to origin/$branch_name" >&2
+    fi
+done
+
+exit 0