loop/agent: add git commit hooks when running inside container

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s8d03af4abcafd1dbk
diff --git a/loop/agent.go b/loop/agent.go
index a8721a7..8992bb3 100644
--- a/loop/agent.go
+++ b/loop/agent.go
@@ -785,6 +785,9 @@
 	}
 	ctx := a.config.Context
 	if ini.InDocker {
+		if err := setupGitHooks(ini.WorkingDir); err != nil {
+			slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
+		}
 		cmd := exec.CommandContext(ctx, "git", "stash")
 		cmd.Dir = ini.WorkingDir
 		if out, err := cmd.CombinedOutput(); err != nil {
@@ -2014,3 +2017,138 @@
 		unsubscribe: unsubscribe,
 	}
 }
+
+// setupGitHooks creates or updates git hooks in the specified working directory.
+func setupGitHooks(workingDir string) error {
+	hooksDir := filepath.Join(workingDir, ".git", "hooks")
+
+	_, err := os.Stat(hooksDir)
+	if os.IsNotExist(err) {
+		return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
+	}
+	if err != nil {
+		return fmt.Errorf("error checking git hooks directory: %w", err)
+	}
+
+	// Define the post-commit hook content
+	postCommitHook := `#!/bin/bash
+echo "<post_commit_hook>"
+echo "Please review this commit message and fix it if it is incorrect."
+echo "This hook only echos the commit message; it does not modify it."
+echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
+echo "<last_commit_message>"
+git log -1 --pretty=%B
+echo "</last_commit_message>"
+echo "</post_commit_hook>"
+`
+
+	// Define the prepare-commit-msg hook content
+	prepareCommitMsgHook := `#!/bin/bash
+# Add Co-Authored-By and Change-ID trailers to commit messages
+# Check if these trailers already exist before adding them
+
+commit_file="$1"
+COMMIT_SOURCE="$2"
+
+# Skip for merges, squashes, or when using a commit template
+if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
+   [ "$COMMIT_SOURCE" = "squash" ]; then
+  exit 0
+fi
+
+commit_msg=$(cat "$commit_file")
+
+needs_co_author=true
+needs_change_id=true
+
+# Check if commit message already has Co-Authored-By trailer
+if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
+  needs_co_author=false
+fi
+
+# Check if commit message already has Change-ID trailer
+if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
+  needs_change_id=false
+fi
+
+# Only modify if at least one trailer needs to be added
+if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
+  # Ensure there's a blank line before trailers
+  if [ -s "$commit_file" ] && [ "$(tail -1 "$commit_file" | tr -d '\n')" != "" ]; then
+    echo "" >> "$commit_file"
+  fi
+
+  # Add trailers if needed
+  if [ "$needs_co_author" = true ]; then
+    echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
+  fi
+
+  if [ "$needs_change_id" = true ]; then
+    change_id=$(openssl rand -hex 8)
+    echo "Change-ID: s${change_id}k" >> "$commit_file"
+  fi
+fi
+`
+
+	// Update or create the post-commit hook
+	err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
+	if err != nil {
+		return fmt.Errorf("failed to set up post-commit hook: %w", err)
+	}
+
+	// Update or create the prepare-commit-msg hook
+	err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
+	if err != nil {
+		return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
+	}
+
+	return nil
+}
+
+// updateOrCreateHook creates a new hook file or updates an existing one
+// by appending the new content if it doesn't already contain it.
+func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
+	// Check if the hook already exists
+	buf, err := os.ReadFile(hookPath)
+	if os.IsNotExist(err) {
+		// Hook doesn't exist, create it
+		err = os.WriteFile(hookPath, []byte(content), 0o755)
+		if err != nil {
+			return fmt.Errorf("failed to create hook: %w", err)
+		}
+		return nil
+	}
+	if err != nil {
+		return fmt.Errorf("error reading existing hook: %w", err)
+	}
+
+	// Hook exists, check if our content is already in it by looking for a distinctive line
+	code := string(buf)
+	if strings.Contains(code, distinctiveLine) {
+		// Already contains our content, nothing to do
+		return nil
+	}
+
+	// Append our content to the existing hook
+	f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
+	if err != nil {
+		return fmt.Errorf("failed to open hook for appending: %w", err)
+	}
+	defer f.Close()
+
+	// Ensure there's a newline at the end of the existing content if needed
+	if len(code) > 0 && !strings.HasSuffix(code, "\n") {
+		_, err = f.WriteString("\n")
+		if err != nil {
+			return fmt.Errorf("failed to add newline to hook: %w", err)
+		}
+	}
+
+	// Add a separator before our content
+	_, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
+	if err != nil {
+		return fmt.Errorf("failed to append to hook: %w", err)
+	}
+
+	return nil
+}
diff --git a/loop/donetool.go b/loop/donetool.go
index a5d6d72..0bf8fbb 100644
--- a/loop/donetool.go
+++ b/loop/donetool.go
@@ -72,7 +72,7 @@
         },
         "git_commit": {
           "$ref": "#/definitions/checklistItem",
-          "description": "Create git commits for any code changes you made, adding --trailer 'Co-Authored-By: sketch <hello@sketch.dev>' and --trailer 'Change-ID: s$(openssl rand -hex 8)k'. The git user is already configured correctly."
+          "description": "Create git commits for any code changes you made. A git hook will add Co-Authored-By and Change-ID trailers. The git user is already configured correctly."
         }
       },
       "additionalProperties": {
diff --git a/loop/testdata/agent_loop.httprr b/loop/testdata/agent_loop.httprr
index 673de69..a961a6a 100644
--- a/loop/testdata/agent_loop.httprr
+++ b/loop/testdata/agent_loop.httprr
@@ -1,9 +1,9 @@
 httprr trace v1
-17130 2368
+17065 1994
 POST https://api.anthropic.com/v1/messages HTTP/1.1

 Host: api.anthropic.com

 User-Agent: Go-http-client/1.1

-Content-Length: 16932

+Content-Length: 16867

 Anthropic-Version: 2023-06-01

 Content-Type: application/json

 

@@ -155,7 +155,7 @@
        },
        "git_commit": {
         "$ref": "#/definitions/checklistItem",
-        "description": "Create git commits for any code changes you made, adding --trailer 'Co-Authored-By: sketch \u003chello@sketch.dev\u003e' and --trailer 'Change-ID: s$(openssl rand -hex 8)k'. The git user is already configured correctly."
+        "description": "Create git commits for any code changes you made. A git hook will add Co-Authored-By and Change-ID trailers. The git user is already configured correctly."
        }
       },
       "additionalProperties": {
@@ -545,25 +545,25 @@
 }HTTP/2.0 200 OK

 Anthropic-Organization-Id: 3c473a21-7208-450a-a9f8-80aebda45c1b

 Anthropic-Ratelimit-Input-Tokens-Limit: 200000

-Anthropic-Ratelimit-Input-Tokens-Remaining: 199000

-Anthropic-Ratelimit-Input-Tokens-Reset: 2025-05-14T18:08:23Z

+Anthropic-Ratelimit-Input-Tokens-Remaining: 200000

+Anthropic-Ratelimit-Input-Tokens-Reset: 2025-05-15T19:39:53Z

 Anthropic-Ratelimit-Output-Tokens-Limit: 80000

 Anthropic-Ratelimit-Output-Tokens-Remaining: 80000

-Anthropic-Ratelimit-Output-Tokens-Reset: 2025-05-14T18:08:27Z

+Anthropic-Ratelimit-Output-Tokens-Reset: 2025-05-15T19:39:57Z

 Anthropic-Ratelimit-Requests-Limit: 4000

 Anthropic-Ratelimit-Requests-Remaining: 3999

-Anthropic-Ratelimit-Requests-Reset: 2025-05-14T18:08:21Z

+Anthropic-Ratelimit-Requests-Reset: 2025-05-15T19:39:51Z

 Anthropic-Ratelimit-Tokens-Limit: 280000

-Anthropic-Ratelimit-Tokens-Remaining: 279000

-Anthropic-Ratelimit-Tokens-Reset: 2025-05-14T18:08:23Z

+Anthropic-Ratelimit-Tokens-Remaining: 280000

+Anthropic-Ratelimit-Tokens-Reset: 2025-05-15T19:39:53Z

 Cf-Cache-Status: DYNAMIC

-Cf-Ray: 93fc5726693267dd-SJC

+Cf-Ray: 94051a8be9baf997-SJC

 Content-Type: application/json

-Date: Wed, 14 May 2025 18:08:27 GMT

-Request-Id: req_011CP7sPNMwRDkkveLJpbgia

+Date: Thu, 15 May 2025 19:39:57 GMT

+Request-Id: req_011CP9tAwcwybG36MzDW9PQ3

 Server: cloudflare

 Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

 Via: 1.1 google

 X-Robots-Tag: none

 

-{"id":"msg_01FzMyZwvMfiSVefMimbLeBM","type":"message","role":"assistant","model":"claude-3-7-sonnet-20250219","content":[{"type":"text","text":"I'll list the tools available to me:\n\n1. bash - Execute shell commands\n2. keyword_search - Search for files based on keywords\n3. think - Record thoughts and plans\n4. title - Set conversation title\n5. precommit - Create git branch for tracking work\n6. done - Indicate task completion with checklist\n7. codereview - Run automated code review\n8. multiplechoice - Present multiple choice options to user\n9. browser_navigate - Navigate to URLs\n10. browser_click - Click elements using CSS selectors\n11. browser_type - Type text into web elements\n12. browser_wait_for - Wait for elements to appear\n13. browser_get_text - Get text from web elements\n14. browser_eval - Execute JavaScript in browser\n15. browser_scroll_into_view - Scroll elements into view\n16. browser_resize - Resize browser window\n17. browser_recent_console_logs - Get browser console logs\n18. browser_clear_console_logs - Clear console logs\n19. browser_take_screenshot - Take screenshots\n20. browser_read_image - Read and encode image files\n21. patch - Make precise text edits to files"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":4,"cache_creation_input_tokens":3932,"cache_read_input_tokens":0,"output_tokens":267}}
\ No newline at end of file
+{"id":"msg_01VWuYGvzq7i6mXQcwpJfmV1","type":"message","role":"assistant","model":"claude-3-7-sonnet-20250219","content":[{"type":"text","text":"Here are the tools available to me:\n\n1. bash - Executes shell commands\n2. keyword_search - Searches for files based on keywords\n3. think - For thinking out loud and planning\n4. title - Sets conversation title\n5. precommit - Creates git branch and provides commit guidance\n6. done - Marks task completion with checklist\n7. codereview - Runs automated code review\n8. multiplechoice - Presents multiple choice options to user\n9. Browser tools (navigate, click, type, wait_for, get_text, eval, etc.)\n10. patch - For precise text edits in files\n\nThese tools allow me to navigate code, execute commands, make code modifications, interact with browsers, and complete various coding tasks."}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":4,"cache_creation_input_tokens":3909,"cache_read_input_tokens":0,"output_tokens":169}}
\ No newline at end of file