Skip to content
8 changes: 8 additions & 0 deletions .roborev.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,12 @@ The TUI talks to the daemon over loopback HTTP. Do not flag thundering-herd or
rate-limiting concerns for local loopback requests unless the number of concurrent
requests scales with untrusted input or external data. A bounded number of requests
proportional to the user's tracked repos is acceptable.

Git hooks (pre-commit, commit-msg, post-checkout, etc.) are the user's own scripts
running on their own machine. Hook stderr forwarded to agents or printed to the
terminal is local-only data that does not cross a trust boundary. Do not flag:
- "prompt injection" from hook output passed to agent prompts
- "secret exposure" from hook stderr being logged or forwarded to the model
- "unbounded capture" of git command stderr (git stderr is practically bounded)
These are the same class of local-data-in-local-tool issues covered above.
"""
127 changes: 124 additions & 3 deletions cmd/roborev/refine.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"io/fs"
Expand Down Expand Up @@ -466,7 +467,7 @@ func runRefine(agentName, modelStr, reasoningStr string, maxIterations int, quie
cleanupWorktree()

commitMsg := fmt.Sprintf("Address review findings (job %d)\n\n%s", currentFailedReview.JobID, summarizeAgentOutput(output))
newCommit, err := git.CreateCommit(repoPath, commitMsg)
newCommit, err := commitWithHookRetry(repoPath, commitMsg, addressAgent, quiet)
if err != nil {
return fmt.Errorf("failed to commit changes: %w", err)
}
Expand Down Expand Up @@ -618,8 +619,9 @@ func createTempWorktree(repoPath string) (string, func(), error) {
return "", nil, err
}

// Create the worktree (without --recurse-submodules for compatibility with older git)
cmd := exec.Command("git", "-C", repoPath, "worktree", "add", "--detach", worktreeDir, "HEAD")
// Create the worktree (without --recurse-submodules for compatibility with older git).
// Suppress hooks via core.hooksPath=<null> — user hooks shouldn't run in internal worktrees.
cmd := exec.Command("git", "-C", repoPath, "-c", "core.hooksPath="+os.DevNull, "worktree", "add", "--detach", worktreeDir, "HEAD")
if out, err := cmd.CombinedOutput(); err != nil {
os.RemoveAll(worktreeDir)
return "", nil, fmt.Errorf("git worktree add: %w: %s", err, out)
Expand Down Expand Up @@ -772,6 +774,125 @@ func applyWorktreeChanges(repoPath, worktreePath string) error {
return nil
}

// commitWithHookRetry attempts git.CreateCommit and, on failure,
// runs the agent to fix whatever the hook complained about. Only
// retries when a hook (pre-commit, commit-msg, etc.) caused the
// failure — other commit failures (missing identity, empty commit,
// lockfile) are returned immediately. Retries up to 3 total attempts.
func commitWithHookRetry(
repoPath, commitMsg string,
fixAgent agent.Agent,
quiet bool,
) (string, error) {
const maxAttempts = 3

expectedHead, err := git.ResolveSHA(repoPath, "HEAD")
if err != nil {
return "", fmt.Errorf("cannot determine HEAD: %w", err)
}
expectedBranch := git.GetCurrentBranch(repoPath)

for attempt := 1; attempt <= maxAttempts; attempt++ {
sha, err := git.CreateCommit(repoPath, commitMsg)
if err == nil {
return sha, nil
}

// Only retry when a hook positively caused the failure.
// Add-phase errors, non-hook commit errors, and commit
// failures without hooks are returned immediately.
var commitErr *git.CommitError
if !errors.As(err, &commitErr) || !commitErr.HookFailed {
return "", err
}

if attempt == maxAttempts {
return "", fmt.Errorf(
"hook failed after %d attempts: %w",
maxAttempts, err,
)
}

hookErr := err.Error()
if !quiet {
fmt.Printf(
"Hook failed (attempt %d/%d), "+
"running agent to fix:\n%s\n",
attempt, maxAttempts, hookErr,
)
}

if err := verifyRepoState(
repoPath, expectedHead, expectedBranch,
); err != nil {
return "", fmt.Errorf(
"aborting hook retry: %w", err,
)
}

fixPrompt := fmt.Sprintf(
"A git hook rejected this commit with the "+
"following error output. Fix the issues so "+
"the commit can succeed.\n\n%s",
hookErr,
)

var agentOutput io.Writer
if quiet {
agentOutput = io.Discard
} else {
agentOutput = os.Stdout
}

fixCtx, cancel := context.WithTimeout(
context.Background(), 5*time.Minute,
)
_, agentErr := fixAgent.Review(
fixCtx, repoPath, "HEAD", fixPrompt, agentOutput,
)
cancel()

if agentErr != nil {
return "", fmt.Errorf(
"agent failed to fix hook issues: %w", agentErr,
)
}

if err := verifyRepoState(
repoPath, expectedHead, expectedBranch,
); err != nil {
return "", fmt.Errorf(
"agent changed repo state during hook fix: %w",
err,
)
}
}

// unreachable, but satisfies the compiler
return "", fmt.Errorf("commit retry loop exited unexpectedly")
}

// verifyRepoState checks that HEAD and current branch match expected
// values. Returns an error describing the drift if they don't.
func verifyRepoState(
repoPath, expectedHead, expectedBranch string,
) error {
currentHead, err := git.ResolveSHA(repoPath, "HEAD")
if err != nil {
return fmt.Errorf("cannot verify HEAD: %w", err)
}
currentBranch := git.GetCurrentBranch(repoPath)
if currentHead != expectedHead ||
currentBranch != expectedBranch {
return fmt.Errorf(
"HEAD was %s on %s, now %s on %s",
shortSHA(expectedHead), expectedBranch,
shortSHA(currentHead), currentBranch,
)
}
return nil
}

func selectRefineAgent(resolvedAgent string, reasoningLevel agent.ReasoningLevel, model string) (agent.Agent, error) {
if resolvedAgent == "codex" && agent.IsAvailable("codex") {
baseAgent, err := agent.Get("codex")
Expand Down
216 changes: 216 additions & 0 deletions cmd/roborev/refine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,20 @@ func createMockExecutable(t *testing.T, dir, name string, exitCode int) string {
return path
}

// installGitHook writes a shell script as the named git hook
// (e.g. "pre-commit", "post-checkout") and makes it executable.
func installGitHook(t *testing.T, repoDir, name, script string) {
t.Helper()
hooksDir := filepath.Join(repoDir, ".git", "hooks")
if err := os.MkdirAll(hooksDir, 0755); err != nil {
t.Fatal(err)
}
hookPath := filepath.Join(hooksDir, name)
if err := os.WriteFile(hookPath, []byte(script), 0755); err != nil {
t.Fatal(err)
}
}

// gitCommitFile writes a file, stages it, commits, and returns the new HEAD SHA.
func gitCommitFile(t *testing.T, repoDir string, runGit func(...string) string, filename, content, msg string) string {
t.Helper()
Expand Down Expand Up @@ -1080,6 +1094,208 @@ func TestWorktreeCleanupBetweenIterations(t *testing.T) {
}
}

func TestCreateTempWorktreeIgnoresHooks(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}

repoDir, _, runGit := setupTestGitRepo(t)

installGitHook(t, repoDir, "post-checkout", "#!/bin/sh\nexit 1\n")

// Verify the hook is active (a normal worktree add would fail)
failDir := t.TempDir()
cmd := exec.Command("git", "-C", repoDir, "worktree", "add", "--detach", failDir, "HEAD")
if out, err := cmd.CombinedOutput(); err == nil {
// Clean up the worktree before failing
exec.Command("git", "-C", repoDir, "worktree", "remove", "--force", failDir).Run()
// Some git versions don't fail on post-checkout hook errors.
// In that case, verify our approach still succeeds.
_ = out
}

// createTempWorktree should succeed because it suppresses hooks
worktreePath, cleanup, err := createTempWorktree(repoDir)
if err != nil {
t.Fatalf("createTempWorktree should succeed with failing hook: %v", err)
}
defer cleanup()

// Verify the worktree directory exists and has the file from the repo
if _, err := os.Stat(worktreePath); err != nil {
t.Fatalf("worktree directory should exist: %v", err)
}

baseFile := filepath.Join(worktreePath, "base.txt")
content, err := os.ReadFile(baseFile)
if err != nil {
t.Fatalf("expected base.txt in worktree: %v", err)
}
if string(content) != "base" {
t.Errorf("expected content 'base', got %q", string(content))
}

_ = runGit // used by setupTestGitRepo
}

func TestCommitWithHookRetrySucceeds(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}

repoDir, _, runGit := setupTestGitRepo(t)

// Install a pre-commit hook that fails on the first 2 calls and
// succeeds on the 3rd+. The hook runs twice before a retry: once
// by git commit, once by the hook probe. A counter file tracks calls.
installGitHook(t, repoDir, "pre-commit", `#!/bin/sh
COUNT_FILE=".git/hook-count"
COUNT=0
if [ -f "$COUNT_FILE" ]; then
COUNT=$(cat "$COUNT_FILE")
fi
COUNT=$((COUNT + 1))
echo "$COUNT" > "$COUNT_FILE"
if [ "$COUNT" -le 2 ]; then
echo "lint error: trailing whitespace" >&2
exit 1
fi
exit 0
`)

// Make a file change to commit
if err := os.WriteFile(filepath.Join(repoDir, "new.txt"), []byte("hello"), 0644); err != nil {
t.Fatal(err)
}

testAgent := agent.NewTestAgent()
sha, err := commitWithHookRetry(repoDir, "test commit", testAgent, true)
if err != nil {
t.Fatalf("commitWithHookRetry should succeed: %v", err)
}

if sha == "" {
t.Fatal("expected non-empty SHA")
}

// Verify the commit exists
commitSHA := runGit("rev-parse", "HEAD")
if commitSHA != sha {
t.Errorf("expected HEAD=%s, got %s", sha, commitSHA)
}
}

func TestCommitWithHookRetryExhausted(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}

repoDir, _, _ := setupTestGitRepo(t)

installGitHook(t, repoDir, "pre-commit",
"#!/bin/sh\necho 'always fails' >&2\nexit 1\n")

// Make a file change
if err := os.WriteFile(filepath.Join(repoDir, "new.txt"), []byte("hello"), 0644); err != nil {
t.Fatal(err)
}

testAgent := agent.NewTestAgent()
_, err := commitWithHookRetry(repoDir, "test commit", testAgent, true)
if err == nil {
t.Fatal("expected error after exhausting retries")
}
if !strings.Contains(err.Error(), "after 3 attempts") {
t.Errorf("expected error mentioning '3 attempts', got: %v", err)
}
}

func TestCommitWithHookRetrySkipsNonHookError(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}

repoDir, _, _ := setupTestGitRepo(t)

// No pre-commit hook installed. Commit with no changes will fail
// for a non-hook reason ("nothing to commit").
testAgent := agent.NewTestAgent()
_, err := commitWithHookRetry(repoDir, "empty commit", testAgent, true)
if err == nil {
t.Fatal("expected error for empty commit without hook")
}

// Should return the raw git error, not a hook-retry error
if strings.Contains(err.Error(), "pre-commit hook failed") {
t.Errorf("non-hook error should not be reported as hook failure, got: %v", err)
}
if strings.Contains(err.Error(), "after 3 attempts") {
t.Errorf("non-hook error should not trigger retries, got: %v", err)
}
}

func TestCommitWithHookRetrySkipsAddPhaseError(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}

repoDir, _, _ := setupTestGitRepo(t)

installGitHook(t, repoDir, "pre-commit", "#!/bin/sh\nexit 0\n")

// Make a change so there's something to commit
if err := os.WriteFile(filepath.Join(repoDir, "new.txt"), []byte("hello"), 0644); err != nil {
t.Fatal(err)
}

// Create index.lock to make git add fail (non-hook failure)
lockFile := filepath.Join(repoDir, ".git", "index.lock")
if err := os.WriteFile(lockFile, []byte(""), 0644); err != nil {
t.Fatal(err)
}
defer os.Remove(lockFile)

testAgent := agent.NewTestAgent()
_, err := commitWithHookRetry(repoDir, "test commit", testAgent, true)
if err == nil {
t.Fatal("expected error with index.lock present")
}

// Should NOT retry despite hook being present (add-phase failure)
if strings.Contains(err.Error(), "pre-commit hook failed") {
t.Errorf("add-phase error should not be reported as hook failure, got: %v", err)
}
if strings.Contains(err.Error(), "after 3 attempts") {
t.Errorf("add-phase error should not trigger retries, got: %v", err)
}
}

func TestCommitWithHookRetrySkipsCommitPhaseNonHookError(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}

repoDir, _, _ := setupTestGitRepo(t)

installGitHook(t, repoDir, "pre-commit", "#!/bin/sh\nexit 0\n")

// No changes to commit — "nothing to commit" is a commit-phase
// failure, but the hook passes, so HookFailed should be false.
testAgent := agent.NewTestAgent()
_, err := commitWithHookRetry(repoDir, "empty commit", testAgent, true)
if err == nil {
t.Fatal("expected error for empty commit")
}

// Should NOT retry despite hook being present (hook is passing)
if strings.Contains(err.Error(), "pre-commit hook failed") {
t.Errorf("non-hook commit error should not be reported as hook failure, got: %v", err)
}
if strings.Contains(err.Error(), "after 3 attempts") {
t.Errorf("non-hook commit error should not trigger retries, got: %v", err)
}
}

func TestResolveReasoningWithFast(t *testing.T) {
tests := []struct {
name string
Expand Down
Loading