Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .pre-commit-hooks.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Pre-commit hook for code-reviewer.
# See: https://pre-commit.com
# Usage in .pre-commit-config.yaml:
#
# - repo: https://github.com/OpticDiff/code-reviewer
# rev: v0.6.0
# hooks:
# - id: code-review
# stages: [pre-push]
# args: [--min-severity, high]
#
- id: code-review
name: code-review
description: AI-powered code review before push
entry: code-reviewer --diff
language: golang
pass_filenames: false
stages: [pre-push]
always_run: true
25 changes: 25 additions & 0 deletions cmd/code-reviewer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/OpticDiff/code-reviewer/internal/config"
ctxpkg "github.com/OpticDiff/code-reviewer/internal/context"
"github.com/OpticDiff/code-reviewer/internal/gitlab"
"github.com/OpticDiff/code-reviewer/internal/hook"
"github.com/OpticDiff/code-reviewer/internal/model"
"github.com/OpticDiff/code-reviewer/internal/reviewer"
)
Expand All @@ -28,6 +29,15 @@ func main() {
}
}

// Handle "hook" subcommand before config.Load() since it doesn't need model config.
if len(os.Args) >= 2 && os.Args[1] == "hook" {
if err := runHook(os.Args[2:]); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
os.Exit(0)
}

slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelInfo,
})))
Expand Down Expand Up @@ -152,3 +162,18 @@ func wrapProviderError(err error) error {
}
return fmt.Errorf("initializing model provider: %w", err)
}

// runHook dispatches hook subcommands.
func runHook(args []string) error {
if len(args) == 0 {
return fmt.Errorf("usage: code-reviewer hook <install|uninstall>")
}
switch args[0] {
case "install":
return hook.Install()
case "uninstall":
return hook.Uninstall()
default:
return fmt.Errorf("unknown hook command: %q (valid: install, uninstall)", args[0])
}
}
127 changes: 127 additions & 0 deletions internal/hook/hook.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Package hook provides git hook installation for code-reviewer.
package hook

import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)

// managedSentinel is the exact marker that identifies hooks managed by code-reviewer.
// Ownership checks match this sentinel, not a loose substring, so a foreign hook
// that happens to mention "code-reviewer" in a comment is never overwritten.
const managedSentinel = "# managed-by: code-reviewer"

const preCommitHookContent = `#!/bin/sh
` + managedSentinel + `
# code-reviewer pre-push hook
# Installed by: code-reviewer hook install
# Remove with: code-reviewer hook uninstall
#
# This hook reviews your changes before pushing.
# To skip: git push --no-verify

set -e

# Only run if there are commits to push.
if ! git diff --quiet @{push} 2>/dev/null; then
echo "🔍 code-reviewer: reviewing changes before push..."
code-reviewer --diff --min-severity high --no-color
Comment on lines +29 to +31

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^|/)hook\.go$|hook|\.code-reviewer|README|go.mod' || true

echo "== hook.go outline =="
if [ -f internal/hook/hook.go ]; then
  ast-grep outline internal/hook/hook.go || true
  echo "== hook.go lines 1-140 =="
  cat -n internal/hook/hook.go | sed -n '1,140p'
else
  fd -a 'hook\.go$' . | sed -n '1,20p'
fi

echo "== searches for diff ref handling and flags =="
rg -n "code-reviewer|--diff|@\\{push\\}|origin/HEAD|push|first|fallback|no-first|first-push|diff ref|first" . git 2>/dev/null | head -200 || true

echo "== git config references =="
rg -n "core\.hooksPath|pre-push|hooksPath|code-reviewer" . -g '!vendor' -g '!node_modules' -g '!dist' -g '!build' | head -250

Repository: OpticDiff/code-reviewer

Length of output: 48354


Review against the same base used by the push guard.

The guard checks @{push}, but code-reviewer --diff without an explicit ref compares against its documented default, origin/HEAD. A branch tracking a different remote branch can therefore fail on unrelated findings, and a first-push scenario needs an explicit fallback. Pass an explicit @{push}-equivalent ref and define a first-push behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/hook/hook.go` around lines 23 - 25, Update the push-review hook’s
code-reviewer invocation to use the same explicit @{push}-equivalent base as the
preceding git diff guard, rather than its default origin/HEAD; add a defined
fallback for branches without an upstream or first-push state, preserving the
existing high-severity and no-color options.

fi
`

// Install writes the pre-push hook to the repository's hooks directory.
// If a hook already exists and wasn't installed by code-reviewer, it returns an error.
func Install() error {
hooksDir, err := resolveHooksDir()
if err != nil {
return err
}

hookPath := filepath.Join(hooksDir, "pre-push")

// Check for existing hook.
if data, err := os.ReadFile(hookPath); err == nil {
if !strings.Contains(string(data), managedSentinel) {
return fmt.Errorf("pre-push hook already exists at %s\n\nTo overwrite, remove it first:\n rm %s", hookPath, hookPath)
}
// Our hook — safe to overwrite.
}

// Ensure hooks directory exists.
if err := os.MkdirAll(hooksDir, 0o755); err != nil {
return fmt.Errorf("creating hooks directory: %w", err)
}

if err := os.WriteFile(hookPath, []byte(preCommitHookContent), 0o755); err != nil {
return fmt.Errorf("writing pre-push hook: %w", err)
}

fmt.Printf("✅ Installed pre-push hook at %s\n", hookPath)
fmt.Println(" Reviews will run automatically on git push.")
fmt.Println(" Skip with: git push --no-verify")
return nil
}

// Uninstall removes the pre-push hook if it was installed by code-reviewer.
func Uninstall() error {
hooksDir, err := resolveHooksDir()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document Uninstall.

Add a doc comment immediately above this exported function. As per coding guidelines, “All exported Go functions and types MUST have doc comments.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/hook/hook.go` at line 70, Add a Go doc comment immediately above the
exported Uninstall function, starting with “Uninstall” and briefly describing
its behavior. Do not alter the existing resolveHooksDir flow.

Source: Coding guidelines

if err != nil {
return err
}

hookPath := filepath.Join(hooksDir, "pre-push")

data, err := os.ReadFile(hookPath)
if err != nil {
if os.IsNotExist(err) {
fmt.Println("No pre-push hook found.")
return nil
}
return fmt.Errorf("reading hook: %w", err)
}

if !strings.Contains(string(data), managedSentinel) {
return fmt.Errorf("pre-push hook at %s was not installed by code-reviewer; refusing to remove", hookPath)
}

if err := os.Remove(hookPath); err != nil {
return fmt.Errorf("removing hook: %w", err)
}

fmt.Printf("✅ Removed pre-push hook from %s\n", hookPath)
return nil
}

// resolveHooksDir returns the hooks directory for the current repository,
// honoring Git's core.hooksPath configuration if set.
func resolveHooksDir() (string, error) {
// Try core.hooksPath first.
cmd := exec.Command("git", "rev-parse", "--git-path", "hooks")
out, err := cmd.Output()
if err == nil {
resolved := strings.TrimSpace(string(out))
if resolved != "" {
return resolved, nil
}
}

// Fallback: <gitdir>/hooks.
gitDir, err := findGitDir()
if err != nil {
return "", err
}
return filepath.Join(gitDir, "hooks"), nil
}

// findGitDir locates the .git directory by running git rev-parse.
func findGitDir() (string, error) {
cmd := exec.Command("git", "rev-parse", "--git-dir")
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("not a git repository (run this from inside a git repo): %w", err)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return strings.TrimSpace(string(out)), nil
}
Loading
Loading