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