├── .shellspec ├── LICENSE ├── Makefile ├── spec ├── spec_helper.sh ├── unit │ ├── providers_spec.sh │ └── cache_spec.sh └── integration │ └── commands_spec.sh ├── uninstall.sh ├── install.sh ├── lib ├── providers.sh └── cache.sh ├── bin └── gga └── README.md /.shellspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | --format documentation 3 | --color 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 AI Code Review Contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test test-unit test-integration test-coverage lint clean install help 2 | 3 | # Default target 4 | help: 5 | @echo "Gentleman Guardian Angel - Development Commands" 6 | @echo "" 7 | @echo "Usage: make [target]" 8 | @echo "" 9 | @echo "Targets:" 10 | @echo " test Run all tests" 11 | @echo " test-unit Run unit tests only" 12 | @echo " test-integration Run integration tests only" 13 | @echo " test-coverage Run tests with coverage report" 14 | @echo " lint Run shellcheck linter" 15 | @echo " clean Clean cache and temp files" 16 | @echo " install Install gga locally" 17 | @echo " help Show this help" 18 | 19 | # Run all tests 20 | test: 21 | @echo "Running all tests..." 22 | shellspec 23 | 24 | # Run unit tests only 25 | test-unit: 26 | @echo "Running unit tests..." 27 | shellspec spec/unit 28 | 29 | # Run integration tests only 30 | test-integration: 31 | @echo "Running integration tests..." 32 | shellspec spec/integration 33 | 34 | # Run tests with coverage (requires kcov) 35 | test-coverage: 36 | @echo "Running tests with coverage..." 37 | shellspec --kcov 38 | 39 | # Lint shell scripts 40 | lint: 41 | @echo "Linting shell scripts..." 42 | shellcheck bin/gga lib/*.sh 43 | @echo "✅ Linting passed" 44 | 45 | # Clean temp files and cache 46 | clean: 47 | @echo "Cleaning..." 48 | rm -rf coverage/ 49 | rm -rf ~/.cache/gga/ 50 | @echo "✅ Cleaned" 51 | 52 | # Install locally 53 | install: 54 | @echo "Installing gga locally..." 55 | ./install.sh 56 | 57 | # Quick check before commit 58 | check: lint test 59 | @echo "" 60 | @echo "✅ All checks passed!" 61 | -------------------------------------------------------------------------------- /spec/spec_helper.sh: -------------------------------------------------------------------------------- 1 | # shellcheck shell=bash 2 | 3 | # Spec helper - Common setup for all tests 4 | 5 | # Set strict mode 6 | set -eu 7 | 8 | # Get the project root directory 9 | PROJECT_ROOT="$(cd "$(dirname "$SHELLSPEC_SPECDIR")" && pwd)" 10 | 11 | # Source the library files 12 | export LIB_DIR="$PROJECT_ROOT/lib" 13 | 14 | # Create a temporary directory for tests 15 | setup_temp_dir() { 16 | TEMP_DIR=$(mktemp -d) 17 | export TEMP_DIR 18 | cd "$TEMP_DIR" || exit 1 19 | } 20 | 21 | # Cleanup temporary directory 22 | cleanup_temp_dir() { 23 | if [[ -n "${TEMP_DIR:-}" && -d "$TEMP_DIR" ]]; then 24 | rm -rf "$TEMP_DIR" 25 | fi 26 | } 27 | 28 | # Initialize a git repo in temp directory 29 | init_git_repo() { 30 | git init --quiet 31 | git config user.email "test@test.com" 32 | git config user.name "Test User" 33 | } 34 | 35 | # Create a test file and stage it 36 | create_and_stage_file() { 37 | local filename="$1" 38 | local content="${2:-test content}" 39 | echo "$content" > "$filename" 40 | git add "$filename" 41 | } 42 | 43 | # Create a minimal .gga config 44 | create_test_config() { 45 | cat > .gga << 'EOF' 46 | PROVIDER="mock" 47 | FILE_PATTERNS="*.ts,*.tsx,*.js" 48 | EXCLUDE_PATTERNS="*.test.ts" 49 | RULES_FILE="AGENTS.md" 50 | STRICT_MODE="true" 51 | EOF 52 | } 53 | 54 | # Create a minimal AGENTS.md 55 | create_test_rules() { 56 | cat > AGENTS.md << 'EOF' 57 | # Test Rules 58 | - No console.log 59 | - Use const over let 60 | EOF 61 | } 62 | 63 | # Mock the provider execution 64 | mock_provider_pass() { 65 | echo "STATUS: PASSED" 66 | echo "All files comply with standards." 67 | } 68 | 69 | mock_provider_fail() { 70 | echo "STATUS: FAILED" 71 | echo "Violations found:" 72 | echo "- test.ts:1 - Rule violated" 73 | } 74 | 75 | # Export functions 76 | export -f setup_temp_dir 77 | export -f cleanup_temp_dir 78 | export -f init_git_repo 79 | export -f create_and_stage_file 80 | export -f create_test_config 81 | export -f create_test_rules 82 | export -f mock_provider_pass 83 | export -f mock_provider_fail 84 | -------------------------------------------------------------------------------- /uninstall.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # ============================================================================ 4 | # Gentleman Guardian Angel - Uninstaller 5 | # ============================================================================ 6 | # Removes the gga CLI tool from your system 7 | # ============================================================================ 8 | 9 | set -e 10 | 11 | # Colors 12 | RED='\033[0;31m' 13 | GREEN='\033[0;32m' 14 | YELLOW='\033[1;33m' 15 | CYAN='\033[0;36m' 16 | BOLD='\033[1m' 17 | NC='\033[0m' 18 | 19 | echo "" 20 | echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" 21 | echo -e "${CYAN}${BOLD} Gentleman Guardian Angel - Uninstaller${NC}" 22 | echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" 23 | echo "" 24 | 25 | # Find and remove binary 26 | LOCATIONS=( 27 | "/usr/local/bin/gga" 28 | "$HOME/.local/bin/gga" 29 | ) 30 | 31 | FOUND=false 32 | for loc in "${LOCATIONS[@]}"; do 33 | if [[ -f "$loc" ]]; then 34 | rm "$loc" 35 | echo -e "${GREEN}✅ Removed: $loc${NC}" 36 | FOUND=true 37 | fi 38 | done 39 | 40 | # Remove lib directory 41 | LIB_DIR="$HOME/.local/share/gga" 42 | if [[ -d "$LIB_DIR" ]]; then 43 | rm -rf "$LIB_DIR" 44 | echo -e "${GREEN}✅ Removed: $LIB_DIR${NC}" 45 | FOUND=true 46 | fi 47 | 48 | # Remove global config (optional) 49 | GLOBAL_CONFIG="$HOME/.config/gga" 50 | if [[ -d "$GLOBAL_CONFIG" ]]; then 51 | echo "" 52 | read -p "Remove global config ($GLOBAL_CONFIG)? (y/N): " confirm 53 | if [[ "$confirm" == "y" || "$confirm" == "Y" ]]; then 54 | rm -rf "$GLOBAL_CONFIG" 55 | echo -e "${GREEN}✅ Removed: $GLOBAL_CONFIG${NC}" 56 | else 57 | echo -e "${YELLOW}⚠️ Kept global config${NC}" 58 | fi 59 | fi 60 | 61 | if [[ "$FOUND" == false ]]; then 62 | echo -e "${YELLOW}⚠️ gga was not found on this system${NC}" 63 | fi 64 | 65 | echo "" 66 | echo -e "${BOLD}Note:${NC} Project-specific configs (.gga) and git hooks" 67 | echo " were not removed. Remove them manually if needed." 68 | echo "" 69 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # ============================================================================ 4 | # Gentleman Guardian Angel - Installer 5 | # ============================================================================ 6 | # Installs the gga CLI tool to your system 7 | # ============================================================================ 8 | 9 | set -e 10 | 11 | # Colors 12 | RED='\033[0;31m' 13 | GREEN='\033[0;32m' 14 | YELLOW='\033[1;33m' 15 | BLUE='\033[0;34m' 16 | CYAN='\033[0;36m' 17 | BOLD='\033[1m' 18 | NC='\033[0m' 19 | 20 | echo "" 21 | echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" 22 | echo -e "${CYAN}${BOLD} Gentleman Guardian Angel - Installer${NC}" 23 | echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" 24 | echo "" 25 | 26 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 27 | 28 | # Determine install location 29 | if [[ -w "/usr/local/bin" ]]; then 30 | INSTALL_DIR="/usr/local/bin" 31 | elif [[ -d "$HOME/.local/bin" && -w "$HOME/.local/bin" ]]; then 32 | INSTALL_DIR="$HOME/.local/bin" 33 | else 34 | INSTALL_DIR="$HOME/.local/bin" 35 | mkdir -p "$INSTALL_DIR" 36 | fi 37 | 38 | echo -e "${BLUE}ℹ️ Install directory: $INSTALL_DIR${NC}" 39 | echo "" 40 | 41 | if [[ ! -w "$INSTALL_DIR" ]]; then 42 | echo -e "${RED}❌ No write permission to $INSTALL_DIR${NC}" 43 | echo -e "${YELLOW}Fix ownership or permissions, e.g.:${NC}" 44 | echo " sudo chown -R $USER:$USER $INSTALL_DIR" 45 | exit 1 46 | fi 47 | 48 | # Check if already installed 49 | if [[ -f "$INSTALL_DIR/gga" ]]; then 50 | echo -e "${YELLOW}⚠️ gga is already installed${NC}" 51 | read -p "Reinstall? (y/N): " confirm 52 | if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then 53 | echo "Aborted." 54 | exit 0 55 | fi 56 | fi 57 | 58 | # Create lib directory 59 | LIB_INSTALL_DIR="$HOME/.local/share/gga/lib" 60 | mkdir -p "$LIB_INSTALL_DIR" 61 | 62 | # Copy files 63 | cp "$SCRIPT_DIR/bin/gga" "$INSTALL_DIR/gga" 64 | cp "$SCRIPT_DIR/lib/providers.sh" "$LIB_INSTALL_DIR/providers.sh" 65 | cp "$SCRIPT_DIR/lib/cache.sh" "$LIB_INSTALL_DIR/cache.sh" 66 | 67 | # Update LIB_DIR path in installed script 68 | if [[ "$(uname)" == "Darwin" ]]; then 69 | sed -i '' "s|LIB_DIR=.*|LIB_DIR=\"$LIB_INSTALL_DIR\"|" "$INSTALL_DIR/gga" 70 | else 71 | sed -i "s|LIB_DIR=.*|LIB_DIR=\"$LIB_INSTALL_DIR\"|" "$INSTALL_DIR/gga" 72 | fi 73 | 74 | # Make executable 75 | chmod +x "$INSTALL_DIR/gga" 76 | chmod +x "$LIB_INSTALL_DIR/providers.sh" 77 | chmod +x "$LIB_INSTALL_DIR/cache.sh" 78 | 79 | echo -e "${GREEN}✅ Installed gga to $INSTALL_DIR${NC}" 80 | echo "" 81 | 82 | # Check if install dir is in PATH 83 | if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then 84 | echo -e "${YELLOW}⚠️ $INSTALL_DIR is not in your PATH${NC}" 85 | echo "" 86 | echo "Add this line to your ~/.bashrc or ~/.zshrc:" 87 | echo "" 88 | echo -e " ${CYAN}export PATH=\"$INSTALL_DIR:\$PATH\"${NC}" 89 | echo "" 90 | fi 91 | 92 | echo -e "${BOLD}Getting started:${NC}" 93 | echo "" 94 | echo " 1. Navigate to your project:" 95 | echo " cd /path/to/your/project" 96 | echo "" 97 | echo " 2. Initialize config:" 98 | echo " gga init" 99 | echo "" 100 | echo " 3. Create your AGENTS.md with coding standards" 101 | echo "" 102 | echo " 4. Install the git hook:" 103 | echo " gga install" 104 | echo "" 105 | echo " 5. You're ready! The hook will run on each commit." 106 | echo "" 107 | -------------------------------------------------------------------------------- /spec/unit/providers_spec.sh: -------------------------------------------------------------------------------- 1 | # shellcheck shell=bash 2 | 3 | Describe 'providers.sh' 4 | Include "$LIB_DIR/providers.sh" 5 | 6 | Describe 'get_provider_info()' 7 | # These tests don't require mocking - they just test the info function 8 | 9 | It 'returns info for claude' 10 | When call get_provider_info "claude" 11 | The output should include "Claude" 12 | End 13 | 14 | It 'returns info for gemini' 15 | When call get_provider_info "gemini" 16 | The output should include "Gemini" 17 | End 18 | 19 | It 'returns info for codex' 20 | When call get_provider_info "codex" 21 | The output should include "Codex" 22 | End 23 | 24 | It 'returns info for ollama with model name' 25 | When call get_provider_info "ollama:llama3.2" 26 | The output should include "Ollama" 27 | The output should include "llama3.2" 28 | End 29 | 30 | It 'returns unknown for invalid provider' 31 | When call get_provider_info "invalid" 32 | The output should include "Unknown" 33 | End 34 | End 35 | 36 | Describe 'validate_provider() - invalid cases' 37 | # Test cases that don't depend on external commands 38 | # Note: validate_provider outputs to stdout (not stderr) 39 | 40 | It 'fails for unknown provider' 41 | When call validate_provider "unknown-provider" 42 | The status should be failure 43 | The output should include "Unknown provider" 44 | End 45 | 46 | It 'fails for empty provider' 47 | When call validate_provider "" 48 | The status should be failure 49 | The output should include "Unknown provider" 50 | End 51 | End 52 | 53 | Describe 'validate_provider() - ollama model validation' 54 | # Ollama validation has logic that checks model format 55 | # This can fail BEFORE checking if ollama CLI exists 56 | 57 | # We need to test the model parsing logic 58 | # The function first checks CLI existence, then model 59 | # So we can't easily test the model validation without the CLI 60 | 61 | # Instead, let's test the parsing helper if we had one 62 | # For now, we'll skip these or mark them as pending 63 | 64 | Skip "Requires refactoring validate_provider to separate concerns" 65 | End 66 | 67 | Describe 'provider base extraction' 68 | # Test the base provider extraction logic 69 | 70 | helper_get_base_provider() { 71 | local provider="$1" 72 | echo "${provider%%:*}" 73 | } 74 | 75 | It 'extracts base provider from simple provider' 76 | When call helper_get_base_provider "claude" 77 | The output should eq "claude" 78 | End 79 | 80 | It 'extracts base provider from ollama:model format' 81 | When call helper_get_base_provider "ollama:llama3.2" 82 | The output should eq "ollama" 83 | End 84 | 85 | It 'extracts base provider from ollama:model:version format' 86 | When call helper_get_base_provider "ollama:codellama:7b" 87 | The output should eq "ollama" 88 | End 89 | End 90 | 91 | Describe 'provider model extraction' 92 | # Test the model extraction logic for ollama 93 | 94 | helper_get_model() { 95 | local provider="$1" 96 | echo "${provider#*:}" 97 | } 98 | 99 | It 'extracts model from ollama:model format' 100 | When call helper_get_model "ollama:llama3.2" 101 | The output should eq "llama3.2" 102 | End 103 | 104 | It 'extracts model with version from ollama:model:version' 105 | When call helper_get_model "ollama:codellama:7b" 106 | The output should eq "codellama:7b" 107 | End 108 | 109 | It 'returns original when no colon present' 110 | When call helper_get_model "claude" 111 | The output should eq "claude" 112 | End 113 | End 114 | End 115 | -------------------------------------------------------------------------------- /lib/providers.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # ============================================================================ 4 | # Gentleman Guardian Angel - Provider Functions 5 | # ============================================================================ 6 | # Handles execution for different AI providers: 7 | # - claude: Anthropic Claude Code CLI 8 | # - gemini: Google Gemini CLI 9 | # - codex: OpenAI Codex CLI 10 | # - ollama:: Ollama with specified model 11 | # ============================================================================ 12 | 13 | # Colors (in case sourced independently) 14 | RED='\033[0;31m' 15 | NC='\033[0m' 16 | 17 | # ============================================================================ 18 | # Provider Validation 19 | # ============================================================================ 20 | 21 | validate_provider() { 22 | local provider="$1" 23 | local base_provider="${provider%%:*}" 24 | 25 | case "$base_provider" in 26 | claude) 27 | if ! command -v claude &> /dev/null; then 28 | echo -e "${RED}❌ Claude CLI not found${NC}" 29 | echo "" 30 | echo "Install Claude Code CLI:" 31 | echo " https://claude.ai/code" 32 | echo "" 33 | return 1 34 | fi 35 | ;; 36 | gemini) 37 | if ! command -v gemini &> /dev/null; then 38 | echo -e "${RED}❌ Gemini CLI not found${NC}" 39 | echo "" 40 | echo "Install Gemini CLI:" 41 | echo " npm install -g @anthropic-ai/gemini-cli" 42 | echo " # or" 43 | echo " brew install gemini" 44 | echo "" 45 | return 1 46 | fi 47 | ;; 48 | codex) 49 | if ! command -v codex &> /dev/null; then 50 | echo -e "${RED}❌ Codex CLI not found${NC}" 51 | echo "" 52 | echo "Install OpenAI Codex CLI:" 53 | echo " npm install -g @openai/codex" 54 | echo " # or" 55 | echo " brew install --cask codex" 56 | echo "" 57 | return 1 58 | fi 59 | ;; 60 | ollama) 61 | if ! command -v ollama &> /dev/null; then 62 | echo -e "${RED}❌ Ollama not found${NC}" 63 | echo "" 64 | echo "Install Ollama:" 65 | echo " https://ollama.ai/download" 66 | echo " # or" 67 | echo " brew install ollama" 68 | echo "" 69 | return 1 70 | fi 71 | # Check if model is specified 72 | local model="${provider#*:}" 73 | if [[ "$model" == "$provider" || -z "$model" ]]; then 74 | echo -e "${RED}❌ Ollama requires a model${NC}" 75 | echo "" 76 | echo "Specify model in provider config:" 77 | echo " PROVIDER=\"ollama:llama3.2\"" 78 | echo " PROVIDER=\"ollama:codellama\"" 79 | echo "" 80 | return 1 81 | fi 82 | ;; 83 | *) 84 | echo -e "${RED}❌ Unknown provider: $provider${NC}" 85 | echo "" 86 | echo "Supported providers:" 87 | echo " - claude" 88 | echo " - gemini" 89 | echo " - codex" 90 | echo " - ollama:" 91 | echo "" 92 | return 1 93 | ;; 94 | esac 95 | 96 | return 0 97 | } 98 | 99 | # ============================================================================ 100 | # Provider Execution 101 | # ============================================================================ 102 | 103 | execute_provider() { 104 | local provider="$1" 105 | local prompt="$2" 106 | local base_provider="${provider%%:*}" 107 | 108 | case "$base_provider" in 109 | claude) 110 | execute_claude "$prompt" 111 | ;; 112 | gemini) 113 | execute_gemini "$prompt" 114 | ;; 115 | codex) 116 | execute_codex "$prompt" 117 | ;; 118 | ollama) 119 | local model="${provider#*:}" 120 | execute_ollama "$model" "$prompt" 121 | ;; 122 | esac 123 | } 124 | 125 | # ============================================================================ 126 | # Individual Provider Implementations 127 | # ============================================================================ 128 | 129 | execute_claude() { 130 | local prompt="$1" 131 | 132 | # Claude CLI accepts prompt via stdin pipe 133 | echo "$prompt" | claude --print 2>&1 134 | return "${PIPESTATUS[1]}" 135 | } 136 | 137 | execute_gemini() { 138 | local prompt="$1" 139 | 140 | # Gemini CLI accepts prompt via stdin pipe or -p flag 141 | echo "$prompt" | gemini 2>&1 142 | return "${PIPESTATUS[1]}" 143 | } 144 | 145 | execute_codex() { 146 | local prompt="$1" 147 | 148 | # Codex uses exec subcommand for non-interactive mode 149 | # Using --output-last-message to get just the final response 150 | codex exec "$prompt" 2>&1 151 | return $? 152 | } 153 | 154 | execute_ollama() { 155 | local model="$1" 156 | local prompt="$2" 157 | 158 | # Ollama accepts prompt as argument after model name 159 | ollama run "$model" "$prompt" 2>&1 160 | return $? 161 | } 162 | 163 | # ============================================================================ 164 | # Provider Info 165 | # ============================================================================ 166 | 167 | get_provider_info() { 168 | local provider="$1" 169 | local base_provider="${provider%%:*}" 170 | 171 | case "$base_provider" in 172 | claude) 173 | echo "Anthropic Claude Code CLI" 174 | ;; 175 | gemini) 176 | echo "Google Gemini CLI" 177 | ;; 178 | codex) 179 | echo "OpenAI Codex CLI" 180 | ;; 181 | ollama) 182 | local model="${provider#*:}" 183 | echo "Ollama (model: $model)" 184 | ;; 185 | *) 186 | echo "Unknown provider" 187 | ;; 188 | esac 189 | } 190 | -------------------------------------------------------------------------------- /lib/cache.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # ============================================================================ 4 | # Gentleman Guardian Angel - Cache Functions 5 | # ============================================================================ 6 | # Intelligent caching to avoid re-reviewing unchanged files. 7 | # Cache invalidates when: 8 | # - File content changes (hash) 9 | # - Rules file (AGENTS.md) changes 10 | # - Config file (.gga) changes 11 | # ============================================================================ 12 | 13 | CACHE_DIR="$HOME/.cache/gga" 14 | 15 | # ============================================================================ 16 | # Cache Functions 17 | # ============================================================================ 18 | 19 | # Get hash of a file's content 20 | get_file_hash() { 21 | local file="$1" 22 | if [[ -f "$file" ]]; then 23 | shasum -a 256 "$file" 2>/dev/null | cut -d' ' -f1 24 | else 25 | echo "" 26 | fi 27 | } 28 | 29 | # Get hash of a string 30 | get_string_hash() { 31 | local str="$1" 32 | echo -n "$str" | shasum -a 256 | cut -d' ' -f1 33 | } 34 | 35 | # Get project identifier (based on git root path) 36 | get_project_id() { 37 | local git_root 38 | git_root=$(git rev-parse --show-toplevel 2>/dev/null) 39 | if [[ -n "$git_root" ]]; then 40 | get_string_hash "$git_root" 41 | else 42 | echo "" 43 | fi 44 | } 45 | 46 | # Get metadata hash (rules + config combined) 47 | get_metadata_hash() { 48 | local rules_file="$1" 49 | local config_file="$2" 50 | 51 | local rules_hash="" 52 | local config_hash="" 53 | 54 | if [[ -f "$rules_file" ]]; then 55 | rules_hash=$(get_file_hash "$rules_file") 56 | fi 57 | 58 | if [[ -f "$config_file" ]]; then 59 | config_hash=$(get_file_hash "$config_file") 60 | fi 61 | 62 | get_string_hash "${rules_hash}:${config_hash}" 63 | } 64 | 65 | # Get project cache directory 66 | get_project_cache_dir() { 67 | local project_id 68 | project_id=$(get_project_id) 69 | 70 | if [[ -z "$project_id" ]]; then 71 | echo "" 72 | return 1 73 | fi 74 | 75 | echo "$CACHE_DIR/$project_id" 76 | } 77 | 78 | # Initialize cache for project 79 | init_cache() { 80 | local rules_file="$1" 81 | local config_file="$2" 82 | 83 | local cache_dir 84 | cache_dir=$(get_project_cache_dir) 85 | 86 | if [[ -z "$cache_dir" ]]; then 87 | return 1 88 | fi 89 | 90 | # Create cache directories 91 | mkdir -p "$cache_dir/files" 92 | 93 | # Store metadata hash 94 | local metadata_hash 95 | metadata_hash=$(get_metadata_hash "$rules_file" "$config_file") 96 | echo "$metadata_hash" > "$cache_dir/metadata" 97 | 98 | echo "$cache_dir" 99 | } 100 | 101 | # Check if cache is valid (metadata hasn't changed) 102 | is_cache_valid() { 103 | local rules_file="$1" 104 | local config_file="$2" 105 | 106 | local cache_dir 107 | cache_dir=$(get_project_cache_dir) 108 | 109 | if [[ -z "$cache_dir" || ! -d "$cache_dir" ]]; then 110 | return 1 111 | fi 112 | 113 | # Check if metadata file exists 114 | if [[ ! -f "$cache_dir/metadata" ]]; then 115 | return 1 116 | fi 117 | 118 | # Compare metadata hashes 119 | local stored_hash 120 | local current_hash 121 | stored_hash=$(cat "$cache_dir/metadata") 122 | current_hash=$(get_metadata_hash "$rules_file" "$config_file") 123 | 124 | if [[ "$stored_hash" == "$current_hash" ]]; then 125 | return 0 126 | else 127 | return 1 128 | fi 129 | } 130 | 131 | # Invalidate entire project cache 132 | invalidate_cache() { 133 | local cache_dir 134 | cache_dir=$(get_project_cache_dir) 135 | 136 | if [[ -n "$cache_dir" && -d "$cache_dir" ]]; then 137 | rm -rf "$cache_dir" 138 | fi 139 | } 140 | 141 | # Check if a file is cached (and cache is still valid for that file) 142 | is_file_cached() { 143 | local file="$1" 144 | 145 | local cache_dir 146 | cache_dir=$(get_project_cache_dir) 147 | 148 | if [[ -z "$cache_dir" || ! -d "$cache_dir/files" ]]; then 149 | return 1 150 | fi 151 | 152 | # Get current file hash 153 | local file_hash 154 | file_hash=$(get_file_hash "$file") 155 | 156 | if [[ -z "$file_hash" ]]; then 157 | return 1 158 | fi 159 | 160 | # Check if cached file exists with this hash 161 | local cache_file="$cache_dir/files/$file_hash" 162 | 163 | if [[ -f "$cache_file" ]]; then 164 | # Verify the cached status is PASSED 165 | local cached_status 166 | cached_status=$(cat "$cache_file") 167 | if [[ "$cached_status" == "PASSED" ]]; then 168 | return 0 169 | fi 170 | fi 171 | 172 | return 1 173 | } 174 | 175 | # Cache a file's review result 176 | cache_file_result() { 177 | local file="$1" 178 | local status="$2" # PASSED or FAILED 179 | 180 | local cache_dir 181 | cache_dir=$(get_project_cache_dir) 182 | 183 | if [[ -z "$cache_dir" ]]; then 184 | return 1 185 | fi 186 | 187 | mkdir -p "$cache_dir/files" 188 | 189 | # Get file hash 190 | local file_hash 191 | file_hash=$(get_file_hash "$file") 192 | 193 | if [[ -n "$file_hash" ]]; then 194 | echo "$status" > "$cache_dir/files/$file_hash" 195 | fi 196 | } 197 | 198 | # Cache multiple files as passed 199 | cache_files_passed() { 200 | local files="$1" 201 | 202 | while IFS= read -r file; do 203 | if [[ -n "$file" ]]; then 204 | cache_file_result "$file" "PASSED" 205 | fi 206 | done <<< "$files" 207 | } 208 | 209 | # Filter out cached files from list 210 | filter_uncached_files() { 211 | local files="$1" 212 | local uncached="" 213 | 214 | while IFS= read -r file; do 215 | if [[ -n "$file" ]]; then 216 | if ! is_file_cached "$file"; then 217 | if [[ -n "$uncached" ]]; then 218 | uncached="$uncached"$'\n'"$file" 219 | else 220 | uncached="$file" 221 | fi 222 | fi 223 | fi 224 | done <<< "$files" 225 | 226 | echo "$uncached" 227 | } 228 | 229 | # Get cache stats for display 230 | get_cache_stats() { 231 | local files="$1" 232 | local total=0 233 | local cached=0 234 | 235 | while IFS= read -r file; do 236 | if [[ -n "$file" ]]; then 237 | ((total++)) 238 | if is_file_cached "$file"; then 239 | ((cached++)) 240 | fi 241 | fi 242 | done <<< "$files" 243 | 244 | echo "$cached/$total" 245 | } 246 | 247 | # Clear all cache 248 | clear_all_cache() { 249 | if [[ -d "$CACHE_DIR" ]]; then 250 | rm -rf "$CACHE_DIR" 251 | fi 252 | } 253 | 254 | # Clear project cache 255 | clear_project_cache() { 256 | invalidate_cache 257 | } 258 | -------------------------------------------------------------------------------- /spec/integration/commands_spec.sh: -------------------------------------------------------------------------------- 1 | # shellcheck shell=bash 2 | 3 | Describe 'gga commands' 4 | # Path to the gga script 5 | gga() { 6 | "$PROJECT_ROOT/bin/gga" "$@" 7 | } 8 | 9 | Describe 'gga version' 10 | It 'returns version number' 11 | When call gga version 12 | The status should be success 13 | The output should include "gga v" 14 | End 15 | 16 | It 'accepts --version flag' 17 | When call gga --version 18 | The status should be success 19 | The output should include "gga v" 20 | End 21 | 22 | It 'accepts -v flag' 23 | When call gga -v 24 | The status should be success 25 | The output should include "gga v" 26 | End 27 | End 28 | 29 | Describe 'gga help' 30 | It 'shows help message' 31 | When call gga help 32 | The status should be success 33 | The output should include "USAGE" 34 | The output should include "COMMANDS" 35 | End 36 | 37 | It 'accepts --help flag' 38 | When call gga --help 39 | The status should be success 40 | The output should include "USAGE" 41 | End 42 | 43 | It 'shows help when no command given' 44 | When call gga 45 | The status should be success 46 | The output should include "USAGE" 47 | End 48 | 49 | It 'lists all commands' 50 | When call gga help 51 | The output should include "run" 52 | The output should include "install" 53 | The output should include "uninstall" 54 | The output should include "config" 55 | The output should include "init" 56 | The output should include "cache" 57 | End 58 | End 59 | 60 | Describe 'gga init' 61 | setup() { 62 | TEMP_DIR=$(mktemp -d) 63 | cd "$TEMP_DIR" 64 | } 65 | 66 | cleanup() { 67 | cd / 68 | rm -rf "$TEMP_DIR" 69 | } 70 | 71 | BeforeEach 'setup' 72 | AfterEach 'cleanup' 73 | 74 | It 'creates .gga config file' 75 | When call gga init 76 | The status should be success 77 | The output should be present 78 | The path ".gga" should be file 79 | End 80 | 81 | It 'config file contains PROVIDER' 82 | gga init > /dev/null 83 | The contents of file ".gga" should include "PROVIDER" 84 | End 85 | 86 | It 'config file contains FILE_PATTERNS' 87 | gga init > /dev/null 88 | The contents of file ".gga" should include "FILE_PATTERNS" 89 | End 90 | 91 | It 'config file contains EXCLUDE_PATTERNS' 92 | gga init > /dev/null 93 | The contents of file ".gga" should include "EXCLUDE_PATTERNS" 94 | End 95 | 96 | It 'config file contains RULES_FILE' 97 | gga init > /dev/null 98 | The contents of file ".gga" should include "RULES_FILE" 99 | End 100 | 101 | It 'config file contains STRICT_MODE' 102 | gga init > /dev/null 103 | The contents of file ".gga" should include "STRICT_MODE" 104 | End 105 | End 106 | 107 | Describe 'gga config' 108 | setup() { 109 | TEMP_DIR=$(mktemp -d) 110 | cd "$TEMP_DIR" 111 | } 112 | 113 | cleanup() { 114 | cd / 115 | rm -rf "$TEMP_DIR" 116 | } 117 | 118 | BeforeEach 'setup' 119 | AfterEach 'cleanup' 120 | 121 | It 'shows configuration' 122 | When call gga config 123 | The status should be success 124 | The output should include "Configuration" 125 | End 126 | 127 | It 'shows provider not configured when no config' 128 | When call gga config 129 | The output should include "Not configured" 130 | End 131 | 132 | It 'shows provider when configured' 133 | echo 'PROVIDER="claude"' > .gga 134 | When call gga config 135 | The output should include "claude" 136 | End 137 | 138 | It 'shows rules file status' 139 | When call gga config 140 | The output should include "Rules File" 141 | End 142 | End 143 | 144 | Describe 'gga install' 145 | setup() { 146 | TEMP_DIR=$(mktemp -d) 147 | cd "$TEMP_DIR" 148 | git init --quiet 149 | } 150 | 151 | cleanup() { 152 | cd / 153 | rm -rf "$TEMP_DIR" 154 | } 155 | 156 | BeforeEach 'setup' 157 | AfterEach 'cleanup' 158 | 159 | It 'creates pre-commit hook' 160 | When call gga install 161 | The status should be success 162 | The output should be present 163 | The path ".git/hooks/pre-commit" should be file 164 | End 165 | 166 | It 'hook contains gga run command' 167 | gga install > /dev/null 168 | The contents of file ".git/hooks/pre-commit" should include "gga run" 169 | End 170 | 171 | It 'hook is executable' 172 | gga install > /dev/null 173 | The path ".git/hooks/pre-commit" should be executable 174 | End 175 | 176 | It 'fails if not in git repo' 177 | rm -rf .git 178 | When call gga install 179 | The status should be failure 180 | The output should include "Not a git repository" 181 | End 182 | End 183 | 184 | Describe 'gga uninstall' 185 | setup() { 186 | TEMP_DIR=$(mktemp -d) 187 | cd "$TEMP_DIR" 188 | git init --quiet 189 | gga install > /dev/null 190 | } 191 | 192 | cleanup() { 193 | cd / 194 | rm -rf "$TEMP_DIR" 195 | } 196 | 197 | BeforeEach 'setup' 198 | AfterEach 'cleanup' 199 | 200 | It 'removes pre-commit hook' 201 | When call gga uninstall 202 | The status should be success 203 | The output should be present 204 | The path ".git/hooks/pre-commit" should not be exist 205 | End 206 | 207 | It 'succeeds if hook does not exist' 208 | rm .git/hooks/pre-commit 209 | When call gga uninstall 210 | The status should be success 211 | The output should be present 212 | End 213 | End 214 | 215 | Describe 'gga cache' 216 | setup() { 217 | TEMP_DIR=$(mktemp -d) 218 | cd "$TEMP_DIR" 219 | git init --quiet 220 | echo "rules" > AGENTS.md 221 | echo 'PROVIDER="claude"' > .gga 222 | } 223 | 224 | cleanup() { 225 | cd / 226 | rm -rf "$TEMP_DIR" 227 | } 228 | 229 | BeforeEach 'setup' 230 | AfterEach 'cleanup' 231 | 232 | Describe 'gga cache status' 233 | It 'shows cache status' 234 | When call gga cache status 235 | The status should be success 236 | The output should include "Cache Status" 237 | End 238 | End 239 | 240 | Describe 'gga cache clear' 241 | It 'clears project cache' 242 | When call gga cache clear 243 | The status should be success 244 | The output should include "Cleared cache" 245 | End 246 | End 247 | 248 | Describe 'gga cache clear-all' 249 | It 'clears all cache' 250 | When call gga cache clear-all 251 | The status should be success 252 | The output should include "Cleared all cache" 253 | End 254 | End 255 | 256 | Describe 'invalid subcommand' 257 | It 'fails for unknown cache subcommand' 258 | When call gga cache invalid 259 | The status should be failure 260 | The output should include "Unknown cache command" 261 | End 262 | End 263 | End 264 | 265 | Describe 'unknown command' 266 | It 'fails with error message' 267 | When call gga unknown-command 268 | The status should be failure 269 | The output should include "Unknown command" 270 | End 271 | End 272 | End 273 | -------------------------------------------------------------------------------- /spec/unit/cache_spec.sh: -------------------------------------------------------------------------------- 1 | # shellcheck shell=bash 2 | 3 | Describe 'cache.sh' 4 | Include "$LIB_DIR/cache.sh" 5 | 6 | Describe 'get_file_hash()' 7 | setup() { 8 | TEMP_DIR=$(mktemp -d) 9 | echo "test content" > "$TEMP_DIR/test.txt" 10 | } 11 | 12 | cleanup() { 13 | rm -rf "$TEMP_DIR" 14 | } 15 | 16 | BeforeEach 'setup' 17 | AfterEach 'cleanup' 18 | 19 | It 'returns a 64 character SHA256 hash' 20 | When call get_file_hash "$TEMP_DIR/test.txt" 21 | The status should be success 22 | The length of output should eq 64 23 | End 24 | 25 | It 'returns empty for non-existent file' 26 | When call get_file_hash "$TEMP_DIR/nonexistent.txt" 27 | The output should eq "" 28 | End 29 | 30 | It 'returns different hashes for different content' 31 | echo "different content" > "$TEMP_DIR/other.txt" 32 | hash1=$(get_file_hash "$TEMP_DIR/test.txt") 33 | hash2=$(get_file_hash "$TEMP_DIR/other.txt") 34 | The value "$hash1" should not eq "$hash2" 35 | End 36 | 37 | It 'returns same hash for same content' 38 | echo "test content" > "$TEMP_DIR/copy.txt" 39 | hash1=$(get_file_hash "$TEMP_DIR/test.txt") 40 | hash2=$(get_file_hash "$TEMP_DIR/copy.txt") 41 | The value "$hash1" should eq "$hash2" 42 | End 43 | End 44 | 45 | Describe 'get_string_hash()' 46 | It 'returns a 64 character SHA256 hash' 47 | When call get_string_hash "test string" 48 | The status should be success 49 | The length of output should eq 64 50 | End 51 | 52 | It 'returns different hashes for different strings' 53 | hash1=$(get_string_hash "string1") 54 | hash2=$(get_string_hash "string2") 55 | The value "$hash1" should not eq "$hash2" 56 | End 57 | 58 | It 'returns same hash for same string' 59 | hash1=$(get_string_hash "same") 60 | hash2=$(get_string_hash "same") 61 | The value "$hash1" should eq "$hash2" 62 | End 63 | End 64 | 65 | Describe 'get_project_id()' 66 | setup() { 67 | TEMP_DIR=$(mktemp -d) 68 | cd "$TEMP_DIR" 69 | git init --quiet 70 | } 71 | 72 | cleanup() { 73 | cd / 74 | rm -rf "$TEMP_DIR" 75 | } 76 | 77 | BeforeEach 'setup' 78 | AfterEach 'cleanup' 79 | 80 | It 'returns a hash for a git repository' 81 | When call get_project_id 82 | The status should be success 83 | The length of output should eq 64 84 | End 85 | 86 | It 'returns consistent hash for same repo' 87 | hash1=$(get_project_id) 88 | hash2=$(get_project_id) 89 | The value "$hash1" should eq "$hash2" 90 | End 91 | End 92 | 93 | Describe 'get_metadata_hash()' 94 | setup() { 95 | TEMP_DIR=$(mktemp -d) 96 | echo "rules content" > "$TEMP_DIR/AGENTS.md" 97 | echo "config content" > "$TEMP_DIR/.gga" 98 | } 99 | 100 | cleanup() { 101 | rm -rf "$TEMP_DIR" 102 | } 103 | 104 | BeforeEach 'setup' 105 | AfterEach 'cleanup' 106 | 107 | It 'returns a hash combining rules and config' 108 | When call get_metadata_hash "$TEMP_DIR/AGENTS.md" "$TEMP_DIR/.gga" 109 | The status should be success 110 | The length of output should eq 64 111 | End 112 | 113 | It 'changes when rules file changes' 114 | hash1=$(get_metadata_hash "$TEMP_DIR/AGENTS.md" "$TEMP_DIR/.gga") 115 | echo "new rules" > "$TEMP_DIR/AGENTS.md" 116 | hash2=$(get_metadata_hash "$TEMP_DIR/AGENTS.md" "$TEMP_DIR/.gga") 117 | The value "$hash1" should not eq "$hash2" 118 | End 119 | 120 | It 'changes when config file changes' 121 | hash1=$(get_metadata_hash "$TEMP_DIR/AGENTS.md" "$TEMP_DIR/.gga") 122 | echo "new config" > "$TEMP_DIR/.gga" 123 | hash2=$(get_metadata_hash "$TEMP_DIR/AGENTS.md" "$TEMP_DIR/.gga") 124 | The value "$hash1" should not eq "$hash2" 125 | End 126 | End 127 | 128 | Describe 'init_cache()' 129 | setup() { 130 | TEMP_DIR=$(mktemp -d) 131 | cd "$TEMP_DIR" 132 | git init --quiet 133 | echo "rules" > AGENTS.md 134 | echo "config" > .gga 135 | # Override cache dir for testing 136 | export CACHE_DIR="$TEMP_DIR/.cache/gga" 137 | } 138 | 139 | cleanup() { 140 | cd / 141 | rm -rf "$TEMP_DIR" 142 | } 143 | 144 | BeforeEach 'setup' 145 | AfterEach 'cleanup' 146 | 147 | It 'creates cache directory structure' 148 | When call init_cache "AGENTS.md" ".gga" 149 | The status should be success 150 | The output should be present 151 | The path "$CACHE_DIR" should be directory 152 | End 153 | 154 | It 'creates metadata file' 155 | init_cache "AGENTS.md" ".gga" > /dev/null 156 | cache_dir=$(get_project_cache_dir) 157 | The path "$cache_dir/metadata" should be file 158 | End 159 | 160 | It 'creates files subdirectory' 161 | init_cache "AGENTS.md" ".gga" > /dev/null 162 | cache_dir=$(get_project_cache_dir) 163 | The path "$cache_dir/files" should be directory 164 | End 165 | End 166 | 167 | Describe 'is_cache_valid()' 168 | setup() { 169 | TEMP_DIR=$(mktemp -d) 170 | cd "$TEMP_DIR" 171 | git init --quiet 172 | echo "rules" > AGENTS.md 173 | echo "config" > .gga 174 | export CACHE_DIR="$TEMP_DIR/.cache/gga" 175 | init_cache "AGENTS.md" ".gga" > /dev/null 176 | } 177 | 178 | cleanup() { 179 | cd / 180 | rm -rf "$TEMP_DIR" 181 | } 182 | 183 | BeforeEach 'setup' 184 | AfterEach 'cleanup' 185 | 186 | It 'returns success when cache is valid' 187 | When call is_cache_valid "AGENTS.md" ".gga" 188 | The status should be success 189 | End 190 | 191 | It 'returns failure when rules change' 192 | echo "new rules" > AGENTS.md 193 | When call is_cache_valid "AGENTS.md" ".gga" 194 | The status should be failure 195 | End 196 | 197 | It 'returns failure when config changes' 198 | echo "new config" > .gga 199 | When call is_cache_valid "AGENTS.md" ".gga" 200 | The status should be failure 201 | End 202 | End 203 | 204 | Describe 'cache_file_result() and is_file_cached()' 205 | setup() { 206 | TEMP_DIR=$(mktemp -d) 207 | cd "$TEMP_DIR" 208 | git init --quiet 209 | echo "rules" > AGENTS.md 210 | echo "config" > .gga 211 | echo "file content" > test.ts 212 | export CACHE_DIR="$TEMP_DIR/.cache/gga" 213 | init_cache "AGENTS.md" ".gga" > /dev/null 214 | } 215 | 216 | cleanup() { 217 | cd / 218 | rm -rf "$TEMP_DIR" 219 | } 220 | 221 | BeforeEach 'setup' 222 | AfterEach 'cleanup' 223 | 224 | It 'caches a file with PASSED status' 225 | When call cache_file_result "test.ts" "PASSED" 226 | The status should be success 227 | End 228 | 229 | It 'detects cached file' 230 | cache_file_result "test.ts" "PASSED" 231 | When call is_file_cached "test.ts" 232 | The status should be success 233 | End 234 | 235 | It 'does not detect uncached file' 236 | When call is_file_cached "uncached.ts" 237 | The status should be failure 238 | End 239 | 240 | It 'invalidates cache when file content changes' 241 | cache_file_result "test.ts" "PASSED" 242 | echo "new content" > test.ts 243 | When call is_file_cached "test.ts" 244 | The status should be failure 245 | End 246 | End 247 | 248 | Describe 'filter_uncached_files()' 249 | setup() { 250 | TEMP_DIR=$(mktemp -d) 251 | cd "$TEMP_DIR" 252 | git init --quiet 253 | echo "rules" > AGENTS.md 254 | echo "config" > .gga 255 | echo "content1" > file1.ts 256 | echo "content2" > file2.ts 257 | echo "content3" > file3.ts 258 | export CACHE_DIR="$TEMP_DIR/.cache/gga" 259 | init_cache "AGENTS.md" ".gga" > /dev/null 260 | } 261 | 262 | cleanup() { 263 | cd / 264 | rm -rf "$TEMP_DIR" 265 | } 266 | 267 | BeforeEach 'setup' 268 | AfterEach 'cleanup' 269 | 270 | It 'returns all files when none are cached' 271 | files=$'file1.ts\nfile2.ts\nfile3.ts' 272 | When call filter_uncached_files "$files" 273 | The output should include "file1.ts" 274 | The output should include "file2.ts" 275 | The output should include "file3.ts" 276 | End 277 | 278 | It 'filters out cached files' 279 | cache_file_result "file1.ts" "PASSED" 280 | cache_file_result "file2.ts" "PASSED" 281 | files=$'file1.ts\nfile2.ts\nfile3.ts' 282 | When call filter_uncached_files "$files" 283 | The output should not include "file1.ts" 284 | The output should not include "file2.ts" 285 | The output should include "file3.ts" 286 | End 287 | 288 | It 'returns empty when all files are cached' 289 | cache_file_result "file1.ts" "PASSED" 290 | cache_file_result "file2.ts" "PASSED" 291 | cache_file_result "file3.ts" "PASSED" 292 | files=$'file1.ts\nfile2.ts\nfile3.ts' 293 | When call filter_uncached_files "$files" 294 | The output should eq "" 295 | End 296 | End 297 | 298 | Describe 'clear_project_cache()' 299 | setup() { 300 | TEMP_DIR=$(mktemp -d) 301 | cd "$TEMP_DIR" 302 | git init --quiet 303 | echo "rules" > AGENTS.md 304 | echo "config" > .gga 305 | export CACHE_DIR="$TEMP_DIR/.cache/gga" 306 | init_cache "AGENTS.md" ".gga" > /dev/null 307 | } 308 | 309 | cleanup() { 310 | cd / 311 | rm -rf "$TEMP_DIR" 312 | } 313 | 314 | BeforeEach 'setup' 315 | AfterEach 'cleanup' 316 | 317 | It 'removes project cache directory' 318 | cache_dir=$(get_project_cache_dir) 319 | clear_project_cache 320 | The path "$cache_dir" should not be exist 321 | End 322 | End 323 | 324 | Describe 'clear_all_cache()' 325 | setup() { 326 | TEMP_DIR=$(mktemp -d) 327 | export CACHE_DIR="$TEMP_DIR/.cache/gga" 328 | mkdir -p "$CACHE_DIR/project1" 329 | mkdir -p "$CACHE_DIR/project2" 330 | } 331 | 332 | cleanup() { 333 | rm -rf "$TEMP_DIR" 334 | } 335 | 336 | BeforeEach 'setup' 337 | AfterEach 'cleanup' 338 | 339 | It 'removes entire cache directory' 340 | clear_all_cache 341 | The path "$CACHE_DIR" should not be exist 342 | End 343 | End 344 | End 345 | -------------------------------------------------------------------------------- /bin/gga: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # ============================================================================ 4 | # Gentleman Guardian Angel - Provider-agnostic code review using AI 5 | # ============================================================================ 6 | # A standalone CLI tool that validates staged files against your project's 7 | # coding standards using any AI provider (Claude, Gemini, Codex, Ollama, etc.) 8 | # ============================================================================ 9 | 10 | set -e 11 | 12 | VERSION="2.2.1" 13 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 14 | LIB_DIR="$(dirname "$SCRIPT_DIR")/lib" 15 | 16 | # Source library functions 17 | source "$LIB_DIR/providers.sh" 18 | source "$LIB_DIR/cache.sh" 19 | 20 | # Colors 21 | RED='\033[0;31m' 22 | GREEN='\033[0;32m' 23 | YELLOW='\033[1;33m' 24 | BLUE='\033[0;34m' 25 | CYAN='\033[0;36m' 26 | BOLD='\033[1m' 27 | NC='\033[0m' # No Color 28 | 29 | # Defaults 30 | DEFAULT_FILE_PATTERNS="*" 31 | DEFAULT_RULES_FILE="AGENTS.md" 32 | DEFAULT_STRICT_MODE="true" 33 | 34 | # ============================================================================ 35 | # Helper Functions 36 | # ============================================================================ 37 | 38 | print_banner() { 39 | echo "" 40 | echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" 41 | echo -e "${CYAN}${BOLD} Gentleman Guardian Angel v${VERSION}${NC}" 42 | echo -e "${CYAN} Provider-agnostic code review using AI${NC}" 43 | echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" 44 | echo "" 45 | } 46 | 47 | print_help() { 48 | print_banner 49 | echo -e "${BOLD}USAGE:${NC}" 50 | echo " gga [options]" 51 | echo "" 52 | echo -e "${BOLD}COMMANDS:${NC}" 53 | echo " run [--no-cache] Run code review on staged files" 54 | echo " install Install git pre-commit hook in current repo" 55 | echo " uninstall Remove git pre-commit hook from current repo" 56 | echo " config Show current configuration" 57 | echo " init Create a sample .gga config file" 58 | echo " cache clear Clear cache for current project" 59 | echo " cache clear-all Clear all cached data" 60 | echo " cache status Show cache status" 61 | echo " help Show this help message" 62 | echo " version Show version" 63 | echo "" 64 | echo -e "${BOLD}RUN OPTIONS:${NC}" 65 | echo " --no-cache Force review all files, ignoring cache" 66 | echo "" 67 | echo -e "${BOLD}CONFIGURATION:${NC}" 68 | echo " Create a ${CYAN}.gga${NC} file in your project root or" 69 | echo " ${CYAN}~/.config/gga/config${NC} for global settings." 70 | echo "" 71 | echo -e "${BOLD}CONFIG OPTIONS:${NC}" 72 | echo " PROVIDER AI provider to use (required)" 73 | echo " Values: claude, gemini, codex, ollama:" 74 | echo " FILE_PATTERNS File patterns to review (default: *)" 75 | echo " Example: *.ts,*.tsx,*.js,*.jsx" 76 | echo " EXCLUDE_PATTERNS Patterns to exclude from review" 77 | echo " Example: *.test.ts,*.spec.ts,*.d.ts" 78 | echo " RULES_FILE File containing review rules (default: AGENTS.md)" 79 | echo " STRICT_MODE Fail on ambiguous AI response (default: true)" 80 | echo "" 81 | echo -e "${BOLD}EXAMPLES:${NC}" 82 | echo " gga init # Create sample config" 83 | echo " gga install # Install git hook" 84 | echo " gga run # Run review (with cache)" 85 | echo " gga run --no-cache # Run review (ignore cache)" 86 | echo " gga cache status # Show cache info" 87 | echo "" 88 | echo -e "${BOLD}ENVIRONMENT VARIABLES:${NC}" 89 | echo " GGA_PROVIDER Override provider from config" 90 | echo "" 91 | } 92 | 93 | print_version() { 94 | echo "gga v${VERSION}" 95 | } 96 | 97 | log_info() { 98 | echo -e "${BLUE}ℹ️ $1${NC}" 99 | } 100 | 101 | log_success() { 102 | echo -e "${GREEN}✅ $1${NC}" 103 | } 104 | 105 | log_warning() { 106 | echo -e "${YELLOW}⚠️ $1${NC}" 107 | } 108 | 109 | log_error() { 110 | echo -e "${RED}❌ $1${NC}" 111 | } 112 | 113 | # ============================================================================ 114 | # Configuration Loading 115 | # ============================================================================ 116 | 117 | load_config() { 118 | # Reset to defaults 119 | PROVIDER="" 120 | FILE_PATTERNS="$DEFAULT_FILE_PATTERNS" 121 | EXCLUDE_PATTERNS="" 122 | RULES_FILE="$DEFAULT_RULES_FILE" 123 | STRICT_MODE="$DEFAULT_STRICT_MODE" 124 | 125 | # Load global config first 126 | GLOBAL_CONFIG="$HOME/.config/gga/config" 127 | if [[ -f "$GLOBAL_CONFIG" ]]; then 128 | source "$GLOBAL_CONFIG" 129 | fi 130 | 131 | # Load project config (overrides global) 132 | PROJECT_CONFIG=".gga" 133 | if [[ -f "$PROJECT_CONFIG" ]]; then 134 | source "$PROJECT_CONFIG" 135 | fi 136 | 137 | # Environment variable overrides everything 138 | if [[ -n "$GGA_PROVIDER" ]]; then 139 | PROVIDER="$GGA_PROVIDER" 140 | fi 141 | } 142 | 143 | # ============================================================================ 144 | # Commands 145 | # ============================================================================ 146 | 147 | cmd_config() { 148 | print_banner 149 | load_config 150 | 151 | echo -e "${BOLD}Current Configuration:${NC}" 152 | echo "" 153 | 154 | # Check config sources 155 | GLOBAL_CONFIG="$HOME/.config/gga/config" 156 | PROJECT_CONFIG=".gga" 157 | 158 | echo -e "${BOLD}Config Files:${NC}" 159 | if [[ -f "$GLOBAL_CONFIG" ]]; then 160 | echo -e " Global: ${GREEN}$GLOBAL_CONFIG${NC}" 161 | else 162 | echo -e " Global: ${YELLOW}Not found${NC}" 163 | fi 164 | 165 | if [[ -f "$PROJECT_CONFIG" ]]; then 166 | echo -e " Project: ${GREEN}$PROJECT_CONFIG${NC}" 167 | else 168 | echo -e " Project: ${YELLOW}Not found${NC}" 169 | fi 170 | echo "" 171 | 172 | echo -e "${BOLD}Values:${NC}" 173 | if [[ -n "$PROVIDER" ]]; then 174 | echo -e " PROVIDER: ${GREEN}$PROVIDER${NC}" 175 | else 176 | echo -e " PROVIDER: ${RED}Not configured${NC}" 177 | fi 178 | echo -e " FILE_PATTERNS: ${CYAN}$FILE_PATTERNS${NC}" 179 | if [[ -n "$EXCLUDE_PATTERNS" ]]; then 180 | echo -e " EXCLUDE_PATTERNS: ${CYAN}$EXCLUDE_PATTERNS${NC}" 181 | else 182 | echo -e " EXCLUDE_PATTERNS: ${YELLOW}None${NC}" 183 | fi 184 | echo -e " RULES_FILE: ${CYAN}$RULES_FILE${NC}" 185 | echo -e " STRICT_MODE: ${CYAN}$STRICT_MODE${NC}" 186 | echo "" 187 | 188 | # Check if rules file exists 189 | if [[ -f "$RULES_FILE" ]]; then 190 | echo -e "${BOLD}Rules File:${NC} ${GREEN}Found${NC}" 191 | else 192 | echo -e "${BOLD}Rules File:${NC} ${RED}Not found ($RULES_FILE)${NC}" 193 | fi 194 | echo "" 195 | } 196 | 197 | cmd_init() { 198 | print_banner 199 | 200 | PROJECT_CONFIG=".gga" 201 | 202 | if [[ -f "$PROJECT_CONFIG" ]]; then 203 | log_warning "Config file already exists: $PROJECT_CONFIG" 204 | read -p "Overwrite? (y/N): " confirm 205 | if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then 206 | echo "Aborted." 207 | exit 0 208 | fi 209 | fi 210 | 211 | cat > "$PROJECT_CONFIG" << 'EOF' 212 | # Gentleman Guardian Angel Configuration 213 | # https://github.com/your-org/gga 214 | 215 | # AI Provider (required) 216 | # Options: claude, gemini, codex, ollama: 217 | # Examples: 218 | # PROVIDER="claude" 219 | # PROVIDER="gemini" 220 | # PROVIDER="codex" 221 | # PROVIDER="ollama:llama3.2" 222 | # PROVIDER="ollama:codellama" 223 | PROVIDER="claude" 224 | 225 | # File patterns to include in review (comma-separated) 226 | # Default: * (all files) 227 | # Examples: 228 | # FILE_PATTERNS="*.ts,*.tsx" 229 | # FILE_PATTERNS="*.py" 230 | # FILE_PATTERNS="*.go,*.mod" 231 | FILE_PATTERNS="*.ts,*.tsx,*.js,*.jsx" 232 | 233 | # File patterns to exclude from review (comma-separated) 234 | # Default: none 235 | # Examples: 236 | # EXCLUDE_PATTERNS="*.test.ts,*.spec.ts" 237 | # EXCLUDE_PATTERNS="*_test.go,*.mock.ts" 238 | EXCLUDE_PATTERNS="*.test.ts,*.spec.ts,*.test.tsx,*.spec.tsx,*.d.ts" 239 | 240 | # File containing code review rules 241 | # Default: AGENTS.md 242 | RULES_FILE="AGENTS.md" 243 | 244 | # Strict mode: fail if AI response is ambiguous 245 | # Default: true 246 | STRICT_MODE="true" 247 | EOF 248 | 249 | log_success "Created config file: $PROJECT_CONFIG" 250 | echo "" 251 | log_info "Next steps:" 252 | echo " 1. Edit $PROJECT_CONFIG to set your preferred provider" 253 | echo " 2. Create $DEFAULT_RULES_FILE with your coding standards" 254 | echo " 3. Run: gga install" 255 | echo "" 256 | } 257 | 258 | cmd_install() { 259 | print_banner 260 | 261 | # Check if we're in a git repo 262 | if ! git rev-parse --git-dir > /dev/null 2>&1; then 263 | log_error "Not a git repository" 264 | exit 1 265 | fi 266 | 267 | GIT_ROOT=$(git rev-parse --show-toplevel) 268 | HOOK_PATH="$GIT_ROOT/.git/hooks/pre-commit" 269 | 270 | # Check if hook already exists 271 | if [[ -f "$HOOK_PATH" ]]; then 272 | # Check for legacy ai-code-review and migrate 273 | if grep -q "ai-code-review" "$HOOK_PATH"; then 274 | log_warning "Found legacy 'ai-code-review' in hook, migrating to 'gga'..." 275 | if [[ "$(uname)" == "Darwin" ]]; then 276 | sed -i '' 's/ai-code-review/gga/g' "$HOOK_PATH" 277 | sed -i '' 's/AI Code Review/Gentleman Guardian Angel/g' "$HOOK_PATH" 278 | else 279 | sed -i 's/ai-code-review/gga/g' "$HOOK_PATH" 280 | sed -i 's/AI Code Review/Gentleman Guardian Angel/g' "$HOOK_PATH" 281 | fi 282 | log_success "Migrated hook to use 'gga'" 283 | exit 0 284 | fi 285 | 286 | if grep -q "gga" "$HOOK_PATH"; then 287 | log_warning "Gentleman Guardian Angel hook already installed" 288 | exit 0 289 | else 290 | log_warning "A pre-commit hook already exists" 291 | read -p "Append Gentleman Guardian Angel to existing hook? (y/N): " confirm 292 | if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then 293 | echo "Aborted." 294 | exit 0 295 | fi 296 | # Append to existing hook 297 | echo "" >> "$HOOK_PATH" 298 | echo "# Gentleman Guardian Angel" >> "$HOOK_PATH" 299 | echo 'gga run || exit 1' >> "$HOOK_PATH" 300 | log_success "Appended Gentleman Guardian Angel to existing hook" 301 | exit 0 302 | fi 303 | fi 304 | 305 | # Create new hook 306 | cat > "$HOOK_PATH" << 'EOF' 307 | #!/usr/bin/env bash 308 | # Git pre-commit hook - Gentleman Guardian Angel 309 | 310 | gga run || exit 1 311 | EOF 312 | 313 | chmod +x "$HOOK_PATH" 314 | log_success "Installed pre-commit hook: $HOOK_PATH" 315 | echo "" 316 | } 317 | 318 | cmd_uninstall() { 319 | print_banner 320 | 321 | # Check if we're in a git repo 322 | if ! git rev-parse --git-dir > /dev/null 2>&1; then 323 | log_error "Not a git repository" 324 | exit 1 325 | fi 326 | 327 | GIT_ROOT=$(git rev-parse --show-toplevel) 328 | HOOK_PATH="$GIT_ROOT/.git/hooks/pre-commit" 329 | 330 | if [[ ! -f "$HOOK_PATH" ]]; then 331 | log_warning "No pre-commit hook found" 332 | exit 0 333 | fi 334 | 335 | if ! grep -q "gga" "$HOOK_PATH"; then 336 | log_warning "Gentleman Guardian Angel hook not found in pre-commit" 337 | exit 0 338 | fi 339 | 340 | # Check if it's our hook only or mixed 341 | if grep -q "^gga run" "$HOOK_PATH" && [[ $(wc -l < "$HOOK_PATH") -le 5 ]]; then 342 | # It's only our hook, remove the file 343 | rm "$HOOK_PATH" 344 | log_success "Removed pre-commit hook" 345 | else 346 | # Mixed hook, just remove our lines 347 | sed -i.bak '/# Gentleman Guardian Angel/d' "$HOOK_PATH" 348 | sed -i.bak '/gga run/d' "$HOOK_PATH" 349 | rm -f "$HOOK_PATH.bak" 350 | log_success "Removed Gentleman Guardian Angel from pre-commit hook" 351 | fi 352 | echo "" 353 | } 354 | 355 | cmd_cache() { 356 | local subcommand="${1:-status}" 357 | 358 | print_banner 359 | 360 | case "$subcommand" in 361 | clear) 362 | clear_project_cache 363 | log_success "Cleared cache for current project" 364 | echo "" 365 | ;; 366 | clear-all) 367 | clear_all_cache 368 | log_success "Cleared all cache data" 369 | echo "" 370 | ;; 371 | status) 372 | load_config 373 | 374 | local cache_dir 375 | cache_dir=$(get_project_cache_dir) 376 | 377 | echo -e "${BOLD}Cache Status:${NC}" 378 | echo "" 379 | 380 | if [[ -z "$cache_dir" ]]; then 381 | echo -e " Project cache: ${YELLOW}Not initialized (not in a git repo?)${NC}" 382 | elif [[ ! -d "$cache_dir" ]]; then 383 | echo -e " Project cache: ${YELLOW}Not initialized${NC}" 384 | else 385 | echo -e " Cache directory: ${CYAN}$cache_dir${NC}" 386 | 387 | # Check if cache is valid 388 | if is_cache_valid "$RULES_FILE" ".gga"; then 389 | echo -e " Cache validity: ${GREEN}Valid${NC}" 390 | else 391 | echo -e " Cache validity: ${YELLOW}Invalid (rules or config changed)${NC}" 392 | fi 393 | 394 | # Count cached files 395 | local cached_count=0 396 | if [[ -d "$cache_dir/files" ]]; then 397 | cached_count=$(find "$cache_dir/files" -type f 2>/dev/null | wc -l | xargs) 398 | fi 399 | echo -e " Cached files: ${CYAN}$cached_count${NC}" 400 | 401 | # Show cache size 402 | if command -v du &> /dev/null; then 403 | local cache_size 404 | cache_size=$(du -sh "$cache_dir" 2>/dev/null | cut -f1) 405 | echo -e " Cache size: ${CYAN}$cache_size${NC}" 406 | fi 407 | fi 408 | echo "" 409 | ;; 410 | *) 411 | log_error "Unknown cache command: $subcommand" 412 | echo "" 413 | echo "Available commands:" 414 | echo " gga cache status - Show cache status" 415 | echo " gga cache clear - Clear project cache" 416 | echo " gga cache clear-all - Clear all cache" 417 | echo "" 418 | exit 1 419 | ;; 420 | esac 421 | } 422 | 423 | cmd_run() { 424 | local use_cache=true 425 | 426 | # Parse arguments 427 | for arg in "$@"; do 428 | case "$arg" in 429 | --no-cache) 430 | use_cache=false 431 | ;; 432 | esac 433 | done 434 | 435 | print_banner 436 | load_config 437 | 438 | # Validate provider is configured 439 | if [[ -z "$PROVIDER" ]]; then 440 | log_error "No provider configured" 441 | echo "" 442 | echo "Configure a provider in .gga or set GGA_PROVIDER" 443 | echo "Run 'gga init' to create a config file" 444 | echo "" 445 | exit 1 446 | fi 447 | 448 | # Validate provider is available 449 | if ! validate_provider "$PROVIDER"; then 450 | exit 1 451 | fi 452 | 453 | # Check rules file exists 454 | if [[ ! -f "$RULES_FILE" ]]; then 455 | log_error "Rules file not found: $RULES_FILE" 456 | echo "" 457 | echo "Please create a ${BOLD}$RULES_FILE${NC} file with your coding standards." 458 | echo "" 459 | echo "Example content:" 460 | echo " # Code Review Rules" 461 | echo " " 462 | echo " ## TypeScript" 463 | echo " - Use const/let, never var" 464 | echo " - Prefer interfaces over types" 465 | echo " - No any types" 466 | echo " " 467 | echo " ## React" 468 | echo " - Use functional components" 469 | echo " - Prefer named exports" 470 | echo "" 471 | exit 1 472 | fi 473 | 474 | log_info "Provider: $PROVIDER" 475 | log_info "Rules file: $RULES_FILE" 476 | log_info "File patterns: $FILE_PATTERNS" 477 | if [[ -n "$EXCLUDE_PATTERNS" ]]; then 478 | log_info "Exclude patterns: $EXCLUDE_PATTERNS" 479 | fi 480 | 481 | # Cache status 482 | if [[ "$use_cache" == true ]]; then 483 | log_info "Cache: ${GREEN}enabled${NC}" 484 | else 485 | log_info "Cache: ${YELLOW}disabled (--no-cache)${NC}" 486 | fi 487 | echo "" 488 | 489 | # Get staged files 490 | STAGED_FILES=$(get_staged_files "$FILE_PATTERNS" "$EXCLUDE_PATTERNS") 491 | 492 | if [[ -z "$STAGED_FILES" ]]; then 493 | log_warning "No matching files staged for commit" 494 | echo "" 495 | exit 0 496 | fi 497 | 498 | # Initialize/validate cache 499 | local files_to_review="$STAGED_FILES" 500 | local cache_initialized=false 501 | 502 | if [[ "$use_cache" == true ]]; then 503 | # Check if cache is valid, if not invalidate it 504 | if ! is_cache_valid "$RULES_FILE" ".gga"; then 505 | log_info "Cache invalidated (rules or config changed)" 506 | invalidate_cache 507 | fi 508 | 509 | # Initialize cache 510 | init_cache "$RULES_FILE" ".gga" > /dev/null 511 | cache_initialized=true 512 | 513 | # Filter out cached files 514 | files_to_review=$(filter_uncached_files "$STAGED_FILES") 515 | 516 | # Calculate cached files 517 | local total_count=0 518 | local uncached_count=0 519 | 520 | while IFS= read -r file; do 521 | [[ -n "$file" ]] && total_count=$((total_count + 1)) 522 | done <<< "$STAGED_FILES" 523 | 524 | while IFS= read -r file; do 525 | [[ -n "$file" ]] && uncached_count=$((uncached_count + 1)) 526 | done <<< "$files_to_review" 527 | 528 | local cached_count=$((total_count - uncached_count)) 529 | 530 | if [[ $cached_count -gt 0 ]]; then 531 | log_success "$cached_count file(s) passed from cache" 532 | fi 533 | fi 534 | 535 | echo -e "${BOLD}Files to review:${NC}" 536 | if [[ -z "$files_to_review" ]]; then 537 | echo -e " ${GREEN}All files passed from cache!${NC}" 538 | echo "" 539 | log_success "CODE REVIEW PASSED (cached)" 540 | echo "" 541 | exit 0 542 | fi 543 | 544 | echo "$files_to_review" | while IFS= read -r file; do 545 | if [[ -n "$file" ]]; then 546 | echo " - $file" 547 | fi 548 | done 549 | echo "" 550 | 551 | # Read rules 552 | RULES=$(cat "$RULES_FILE") 553 | 554 | # Build prompt only with files to review 555 | PROMPT=$(build_prompt "$RULES" "$files_to_review") 556 | 557 | # Execute review 558 | log_info "Sending to $PROVIDER for review..." 559 | echo "" 560 | 561 | RESULT=$(execute_provider "$PROVIDER" "$PROMPT") 562 | EXEC_STATUS=$? 563 | 564 | if [[ $EXEC_STATUS -ne 0 ]]; then 565 | log_error "Provider execution failed" 566 | if [[ "$STRICT_MODE" == "true" ]]; then 567 | exit 1 568 | fi 569 | exit 0 570 | fi 571 | 572 | echo "$RESULT" 573 | echo "" 574 | 575 | # Parse result 576 | if echo "$RESULT" | grep -q "^STATUS: PASSED"; then 577 | # Cache the passed files 578 | if [[ "$cache_initialized" == true ]]; then 579 | cache_files_passed "$files_to_review" 580 | fi 581 | log_success "CODE REVIEW PASSED" 582 | echo "" 583 | exit 0 584 | elif echo "$RESULT" | grep -q "^STATUS: FAILED"; then 585 | log_error "CODE REVIEW FAILED" 586 | echo "" 587 | echo "Fix the violations listed above before committing." 588 | echo "" 589 | exit 1 590 | else 591 | log_warning "Could not determine review status" 592 | if [[ "$STRICT_MODE" == "true" ]]; then 593 | log_error "STRICT MODE: Failing due to ambiguous response" 594 | echo "" 595 | echo "The AI response must start with 'STATUS: PASSED' or 'STATUS: FAILED'" 596 | echo "Set STRICT_MODE=false in config to allow ambiguous responses" 597 | echo "" 598 | exit 1 599 | else 600 | log_warning "Allowing commit (STRICT_MODE=false)" 601 | echo "" 602 | exit 0 603 | fi 604 | fi 605 | } 606 | 607 | # ============================================================================ 608 | # Utility Functions 609 | # ============================================================================ 610 | 611 | get_staged_files() { 612 | local patterns="$1" 613 | local excludes="$2" 614 | 615 | # Get all staged files (added, copied, modified) 616 | local staged 617 | staged=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null) 618 | 619 | if [[ -z "$staged" ]]; then 620 | return 621 | fi 622 | 623 | # Convert comma-separated patterns to array 624 | IFS=',' read -ra PATTERN_ARRAY <<< "$patterns" 625 | IFS=',' read -ra EXCLUDE_ARRAY <<< "$excludes" 626 | 627 | # Filter files 628 | echo "$staged" | while IFS= read -r file; do 629 | local match=false 630 | local excluded=false 631 | 632 | # Check if file matches any include pattern 633 | for pattern in "${PATTERN_ARRAY[@]}"; do 634 | pattern=$(echo "$pattern" | xargs) # trim whitespace 635 | # Convert glob pattern to regex-like suffix match 636 | # *.tsx -> check if file ends with .tsx 637 | if [[ "$pattern" == \** ]]; then 638 | # Pattern starts with *, so match suffix 639 | local suffix="${pattern#\*}" 640 | if [[ "$file" == *"$suffix" ]]; then 641 | match=true 642 | break 643 | fi 644 | else 645 | # Exact match or other pattern 646 | # shellcheck disable=SC2053 647 | if [[ "$file" == $pattern ]] || [[ "$(basename "$file")" == $pattern ]]; then 648 | match=true 649 | break 650 | fi 651 | fi 652 | done 653 | 654 | # Check if file matches any exclude pattern 655 | if [[ "$match" == true && -n "$excludes" ]]; then 656 | for pattern in "${EXCLUDE_ARRAY[@]}"; do 657 | pattern=$(echo "$pattern" | xargs) # trim whitespace 658 | # Convert glob pattern to regex-like suffix match 659 | if [[ "$pattern" == \** ]]; then 660 | local suffix="${pattern#\*}" 661 | if [[ "$file" == *"$suffix" ]]; then 662 | excluded=true 663 | break 664 | fi 665 | else 666 | # shellcheck disable=SC2053 667 | if [[ "$file" == $pattern ]] || [[ "$(basename "$file")" == $pattern ]]; then 668 | excluded=true 669 | break 670 | fi 671 | fi 672 | done 673 | fi 674 | 675 | if [[ "$match" == true && "$excluded" == false ]]; then 676 | echo "$file" 677 | fi 678 | done 679 | } 680 | 681 | build_prompt() { 682 | local rules="$1" 683 | local files="$2" 684 | 685 | # Start building the prompt 686 | cat << EOF 687 | You are a code reviewer. Analyze the files below and validate they comply with the coding standards provided. 688 | 689 | === CODING STANDARDS === 690 | $rules 691 | === END CODING STANDARDS === 692 | 693 | === FILES TO REVIEW === 694 | EOF 695 | 696 | # Add file contents - using process substitution to avoid subshell 697 | while IFS= read -r file; do 698 | if [[ -n "$file" && -f "$file" ]]; then 699 | echo "" 700 | echo "--- FILE: $file ---" 701 | cat "$file" 702 | fi 703 | done <<< "$files" 704 | 705 | cat << 'EOF' 706 | 707 | === END FILES === 708 | 709 | **IMPORTANT: Your response MUST start with exactly one of these lines:** 710 | STATUS: PASSED 711 | STATUS: FAILED 712 | 713 | **If FAILED:** List each violation with: 714 | - File name 715 | - Line number (if applicable) 716 | - Rule violated 717 | - Description of the issue 718 | 719 | **If PASSED:** Confirm all files comply with the coding standards. 720 | 721 | **Start your response now with STATUS:** 722 | EOF 723 | } 724 | 725 | # ============================================================================ 726 | # Main 727 | # ============================================================================ 728 | 729 | main() { 730 | case "${1:-}" in 731 | run) 732 | shift 733 | cmd_run "$@" 734 | ;; 735 | install) 736 | cmd_install 737 | ;; 738 | uninstall) 739 | cmd_uninstall 740 | ;; 741 | config) 742 | cmd_config 743 | ;; 744 | init) 745 | cmd_init 746 | ;; 747 | cache) 748 | shift 749 | cmd_cache "$@" 750 | ;; 751 | version|--version|-v) 752 | print_version 753 | ;; 754 | help|--help|-h|"") 755 | print_help 756 | ;; 757 | *) 758 | log_error "Unknown command: $1" 759 | echo "" 760 | print_help 761 | exit 1 762 | ;; 763 | esac 764 | } 765 | 766 | main "$@" 767 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Version 3 | License 4 | Bash 5 | Homebrew 6 | Tests 7 | PRs Welcome 8 |

9 | 10 |

🤖 Gentleman Guardian Angel

11 | 12 |

13 | Provider-agnostic code review using AI
14 | Use Claude, Gemini, Codex, Ollama, or any AI to enforce your coding standards.
15 | Zero dependencies. Pure Bash. Works everywhere. 16 |

17 | 18 |

19 | Installation • 20 | Quick Start • 21 | Providers • 22 | Commands • 23 | Configuration • 24 | Caching • 25 | Development 26 |

27 | 28 | --- 29 | 30 | ## Example 31 | 32 | image 33 | 34 | ## 🎯 Why? 35 | 36 | You have coding standards. Your team ignores them. Code reviews catch issues too late. 37 | 38 | **Gentleman Guardian Angel** runs on every commit, validating your staged files against your project's `AGENTS.md` (or any rules file). It's like having a senior developer review every line before it hits the repo. 39 | 40 | ``` 41 | ┌─────────────────┐ ┌──────────────┐ ┌─────────────────┐ 42 | │ git commit │ ──▶ │ AI Review │ ──▶ │ ✅ Pass/Fail │ 43 | │ (staged files) │ │ (any LLM) │ │ (with details) │ 44 | └─────────────────┘ └──────────────┘ └─────────────────┘ 45 | ``` 46 | 47 | **Key features:** 48 | - 🔌 **Provider agnostic** - Use whatever AI you have installed 49 | - 📦 **Zero dependencies** - Pure Bash, no Node/Python/Go required 50 | - 🪝 **Git native** - Installs as a standard pre-commit hook 51 | - ⚙️ **Highly configurable** - File patterns, exclusions, custom rules 52 | - 🚨 **Strict mode** - Fail CI on ambiguous responses 53 | - ⚡ **Smart caching** - Skip unchanged files for faster reviews 54 | - 🍺 **Homebrew ready** - One command install 55 | 56 | --- 57 | 58 | ## 📦 Installation 59 | 60 | ### Homebrew (Recommended) 🍺 61 | 62 | ```bash 63 | brew tap gentleman-programming/tap 64 | brew install gga 65 | ``` 66 | 67 | Or in a single command: 68 | 69 | ```bash 70 | brew install gentleman-programming/tap/gga 71 | ``` 72 | 73 | ### Manual Installation 74 | 75 | ```bash 76 | git clone https://github.com/Gentleman-Programming/gentleman-guardian-angel.git 77 | cd gga 78 | ./install.sh 79 | ``` 80 | 81 | ### Verify Installation 82 | 83 | ```bash 84 | gga version 85 | # Output: gga v2.2.0 86 | ``` 87 | 88 | --- 89 | 90 | ## 🚀 Quick Start 91 | 92 | ```bash 93 | # 1. Go to your project 94 | cd ~/your-project 95 | 96 | # 2. Initialize config 97 | gga init 98 | 99 | # 3. Create your rules file 100 | touch AGENTS.md # Add your coding standards 101 | 102 | # 4. Install the git hook 103 | gga install 104 | 105 | # 5. Done! Now every commit gets reviewed 🎉 106 | ``` 107 | 108 | --- 109 | 110 | ## 🎬 Real World Example 111 | 112 | Let's walk through a complete example from setup to commit: 113 | 114 | ### Step 1: Setup in your project 115 | 116 | ```bash 117 | $ cd ~/projects/my-react-app 118 | 119 | $ gga init 120 | 121 | ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 122 | Gentleman Guardian Angel v2.2.0 123 | Provider-agnostic code review using AI 124 | ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 125 | 126 | ✅ Created config file: .gga 127 | 128 | ℹ️ Next steps: 129 | 1. Edit .gga to set your preferred provider 130 | 2. Create AGENTS.md with your coding standards 131 | 3. Run: gga install 132 | ``` 133 | 134 | ### Step 2: Configure your provider 135 | 136 | ```bash 137 | $ cat .gga 138 | 139 | # AI Provider (required) 140 | PROVIDER="claude" 141 | 142 | # File patterns to include in review (comma-separated) 143 | FILE_PATTERNS="*.ts,*.tsx,*.js,*.jsx" 144 | 145 | # File patterns to exclude from review (comma-separated) 146 | EXCLUDE_PATTERNS="*.test.ts,*.spec.ts,*.test.tsx,*.spec.tsx,*.d.ts" 147 | 148 | # File containing code review rules 149 | RULES_FILE="AGENTS.md" 150 | 151 | # Strict mode: fail if AI response is ambiguous 152 | STRICT_MODE="true" 153 | ``` 154 | 155 | ### Step 3: Create your coding standards 156 | 157 | ```bash 158 | $ cat > AGENTS.md << 'EOF' 159 | # Code Review Rules 160 | 161 | ## TypeScript 162 | - No `any` types - use proper typing 163 | - Use `const` over `let` when possible 164 | - Prefer interfaces over type aliases for objects 165 | 166 | ## React 167 | - Use functional components with hooks 168 | - No `import * as React` - use named imports like `import { useState }` 169 | - All images must have alt text for accessibility 170 | 171 | ## Styling 172 | - Use Tailwind CSS utilities only 173 | - No inline styles or CSS-in-JS 174 | - No hardcoded colors - use design system tokens 175 | EOF 176 | ``` 177 | 178 | ### Step 4: Install the git hook 179 | 180 | ```bash 181 | $ gga install 182 | 183 | ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 184 | Gentleman Guardian Angel v2.2.0 185 | Provider-agnostic code review using AI 186 | ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 187 | 188 | ✅ Installed pre-commit hook: /Users/dev/projects/my-react-app/.git/hooks/pre-commit 189 | ``` 190 | 191 | ### Step 5: Make some changes and commit 192 | 193 | ```bash 194 | $ git add src/components/Button.tsx 195 | $ git commit -m "feat: add new button component" 196 | 197 | ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 198 | Gentleman Guardian Angel v2.2.0 199 | Provider-agnostic code review using AI 200 | ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 201 | 202 | ℹ️ Provider: claude 203 | ℹ️ Rules file: AGENTS.md 204 | ℹ️ File patterns: *.ts,*.tsx,*.js,*.jsx 205 | ℹ️ Cache: enabled 206 | 207 | Files to review: 208 | - src/components/Button.tsx 209 | 210 | ℹ️ Sending to claude for review... 211 | 212 | STATUS: FAILED 213 | 214 | Violations found: 215 | 216 | 1. **src/components/Button.tsx:3** - TypeScript Rule 217 | - Issue: Using `any` type for props 218 | - Fix: Define proper interface for ButtonProps 219 | 220 | 2. **src/components/Button.tsx:15** - React Rule 221 | - Issue: Using `import * as React` 222 | - Fix: Use `import { useState, useCallback } from 'react'` 223 | 224 | 3. **src/components/Button.tsx:22** - Styling Rule 225 | - Issue: Hardcoded color `#3b82f6` 226 | - Fix: Use Tailwind class `bg-blue-500` instead 227 | 228 | ❌ CODE REVIEW FAILED 229 | 230 | Fix the violations listed above before committing. 231 | ``` 232 | 233 | ### Step 6: Fix issues and commit again 234 | 235 | ```bash 236 | $ git add src/components/Button.tsx 237 | $ git commit -m "feat: add new button component" 238 | 239 | ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 240 | Gentleman Guardian Angel v2.2.0 241 | ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 242 | 243 | ℹ️ Provider: claude 244 | ℹ️ Cache: enabled 245 | 246 | Files to review: 247 | - src/components/Button.tsx 248 | 249 | ℹ️ Sending to claude for review... 250 | 251 | STATUS: PASSED 252 | 253 | All files comply with the coding standards defined in AGENTS.md. 254 | 255 | ✅ CODE REVIEW PASSED 256 | 257 | [main 4a2b3c1] feat: add new button component 258 | 1 file changed, 45 insertions(+) 259 | create mode 100644 src/components/Button.tsx 260 | ``` 261 | 262 | --- 263 | 264 | ## 📋 Commands 265 | 266 | | Command | Description | Example | 267 | |---------|-------------|---------| 268 | | `init` | Create sample `.gga` config file | `gga init` | 269 | | `install` | Install git pre-commit hook in current repo | `gga install` | 270 | | `uninstall` | Remove git pre-commit hook from current repo | `gga uninstall` | 271 | | `run` | Run code review on staged files | `gga run` | 272 | | `run --no-cache` | Run review ignoring cache | `gga run --no-cache` | 273 | | `config` | Display current configuration and status | `gga config` | 274 | | `cache status` | Show cache status for current project | `gga cache status` | 275 | | `cache clear` | Clear cache for current project | `gga cache clear` | 276 | | `cache clear-all` | Clear all cached data | `gga cache clear-all` | 277 | | `help` | Show help message with all commands | `gga help` | 278 | | `version` | Show installed version | `gga version` | 279 | 280 | ### Command Details 281 | 282 | #### `gga init` 283 | 284 | Creates a sample `.gga` configuration file in your project root with sensible defaults. 285 | 286 | ```bash 287 | $ gga init 288 | ✅ Created config file: .gga 289 | ``` 290 | 291 | #### `gga install` 292 | 293 | Installs a git pre-commit hook that automatically runs code review on every commit. 294 | 295 | ```bash 296 | $ gga install 297 | ✅ Installed pre-commit hook: .git/hooks/pre-commit 298 | ``` 299 | 300 | If a pre-commit hook already exists, it will ask if you want to append to it. 301 | 302 | #### `gga uninstall` 303 | 304 | Removes the git pre-commit hook from your repository. 305 | 306 | ```bash 307 | $ gga uninstall 308 | ✅ Removed pre-commit hook 309 | ``` 310 | 311 | #### `gga run [--no-cache]` 312 | 313 | Runs code review on currently staged files. Uses intelligent caching by default to skip unchanged files. 314 | 315 | ```bash 316 | $ git add src/components/Button.tsx 317 | $ gga run 318 | # Reviews the staged file (uses cache) 319 | 320 | $ gga run --no-cache 321 | # Forces review of all files, ignoring cache 322 | ``` 323 | 324 | #### `gga config` 325 | 326 | Shows the current configuration, including where config files are loaded from and all settings. 327 | 328 | ```bash 329 | $ gga config 330 | 331 | Current Configuration: 332 | 333 | Config Files: 334 | Global: Not found 335 | Project: .gga 336 | 337 | Values: 338 | PROVIDER: claude 339 | FILE_PATTERNS: *.ts,*.tsx,*.js,*.jsx 340 | EXCLUDE_PATTERNS: *.test.ts,*.spec.ts 341 | RULES_FILE: AGENTS.md 342 | STRICT_MODE: true 343 | 344 | Rules File: Found 345 | ``` 346 | 347 | --- 348 | 349 | ## ⚡ Smart Caching 350 | 351 | GGA includes intelligent caching to speed up reviews by skipping files that haven't changed. 352 | 353 | ### How It Works 354 | 355 | ``` 356 | ┌─────────────────────────────────────────────────────────────────┐ 357 | │ Cache Logic │ 358 | ├─────────────────────────────────────────────────────────────────┤ 359 | │ 1. Hash AGENTS.md + .gga config │ 360 | │ └─► If changed → Invalidate ALL cache │ 361 | │ │ 362 | │ 2. For each staged file: │ 363 | │ └─► Hash file content │ 364 | │ └─► If hash exists in cache with PASSED → Skip │ 365 | │ └─► If not cached → Send to AI for review │ 366 | │ │ 367 | │ 3. After PASSED review: │ 368 | │ └─► Store file hash in cache │ 369 | └─────────────────────────────────────────────────────────────────┘ 370 | ``` 371 | 372 | ### Cache Invalidation 373 | 374 | The cache automatically invalidates when: 375 | 376 | | Change | Effect | 377 | |--------|--------| 378 | | File content changes | Only that file is re-reviewed | 379 | | `AGENTS.md` changes | **All files** are re-reviewed | 380 | | `.gga` config changes | **All files** are re-reviewed | 381 | 382 | ### Cache Commands 383 | 384 | ```bash 385 | # Check cache status 386 | $ gga cache status 387 | 388 | Cache Status: 389 | 390 | Cache directory: ~/.cache/gga/a1b2c3d4... 391 | Cache validity: Valid 392 | Cached files: 12 393 | Cache size: 4.0K 394 | 395 | # Clear project cache 396 | $ gga cache clear 397 | ✅ Cleared cache for current project 398 | 399 | # Clear all cache (all projects) 400 | $ gga cache clear-all 401 | ✅ Cleared all cache data 402 | ``` 403 | 404 | ### Bypass Cache 405 | 406 | ```bash 407 | # Force review all files, ignoring cache 408 | gga run --no-cache 409 | ``` 410 | 411 | ### Cache Location 412 | 413 | ``` 414 | ~/.cache/gga/ 415 | ├── / 416 | │ ├── metadata # Hash of AGENTS.md + .gga 417 | │ └── files/ 418 | │ ├── # "PASSED" 419 | │ └── # "PASSED" 420 | └── / 421 | └── ... 422 | ``` 423 | 424 | --- 425 | 426 | ## 🔌 Providers 427 | 428 | Use whichever AI CLI you have installed: 429 | 430 | | Provider | Config Value | CLI Command Used | Installation | 431 | |----------|-------------|------------------|--------------| 432 | | **Claude** | `claude` | `echo "prompt" \| claude --print` | [claude.ai/code](https://claude.ai/code) | 433 | | **Gemini** | `gemini` | `echo "prompt" \| gemini` | [github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli) | 434 | | **Codex** | `codex` | `codex exec "prompt"` | `npm i -g @openai/codex` | 435 | | **Ollama** | `ollama:` | `ollama run "prompt"` | [ollama.ai](https://ollama.ai) | 436 | 437 | ### Provider Examples 438 | 439 | ```bash 440 | # Use Claude (recommended - most reliable) 441 | PROVIDER="claude" 442 | 443 | # Use Google Gemini 444 | PROVIDER="gemini" 445 | 446 | # Use OpenAI Codex 447 | PROVIDER="codex" 448 | 449 | # Use Ollama with Llama 3.2 450 | PROVIDER="ollama:llama3.2" 451 | 452 | # Use Ollama with CodeLlama (optimized for code) 453 | PROVIDER="ollama:codellama" 454 | 455 | # Use Ollama with Qwen Coder 456 | PROVIDER="ollama:qwen2.5-coder" 457 | 458 | # Use Ollama with DeepSeek Coder 459 | PROVIDER="ollama:deepseek-coder" 460 | ``` 461 | 462 | --- 463 | 464 | ## ⚙️ Configuration 465 | 466 | ### Config File: `.gga` 467 | 468 | Create this file in your project root: 469 | 470 | ```bash 471 | # AI Provider (required) 472 | # Options: claude, gemini, codex, ollama: 473 | PROVIDER="claude" 474 | 475 | # File patterns to review (comma-separated globs) 476 | # Default: * (all files) 477 | FILE_PATTERNS="*.ts,*.tsx,*.js,*.jsx" 478 | 479 | # Patterns to exclude from review (comma-separated globs) 480 | # Default: none 481 | EXCLUDE_PATTERNS="*.test.ts,*.spec.ts,*.d.ts" 482 | 483 | # File containing your coding standards 484 | # Default: AGENTS.md 485 | RULES_FILE="AGENTS.md" 486 | 487 | # Fail if AI response is ambiguous (recommended for CI) 488 | # Default: true 489 | STRICT_MODE="true" 490 | ``` 491 | 492 | ### Configuration Options 493 | 494 | | Option | Required | Default | Description | 495 | |--------|----------|---------|-------------| 496 | | `PROVIDER` | ✅ Yes | - | AI provider to use | 497 | | `FILE_PATTERNS` | No | `*` | Comma-separated file patterns to include | 498 | | `EXCLUDE_PATTERNS` | No | - | Comma-separated file patterns to exclude | 499 | | `RULES_FILE` | No | `AGENTS.md` | Path to your coding standards file | 500 | | `STRICT_MODE` | No | `true` | Fail on ambiguous AI responses | 501 | 502 | ### Config Hierarchy (Priority Order) 503 | 504 | 1. **Environment variable** `GGA_PROVIDER` (highest priority) 505 | 2. **Project config** `.gga` (in project root) 506 | 3. **Global config** `~/.config/gga/config` (lowest priority) 507 | 508 | ```bash 509 | # Override provider for a single run 510 | GGA_PROVIDER="gemini" gga run 511 | 512 | # Or export for the session 513 | export GGA_PROVIDER="ollama:llama3.2" 514 | ``` 515 | 516 | --- 517 | 518 | ## 📝 Rules File (AGENTS.md) 519 | 520 | The AI needs to know your standards. Create an `AGENTS.md` file: 521 | 522 | ```markdown 523 | # Code Review Rules 524 | 525 | ## TypeScript 526 | - Use `const` and `let`, never `var` 527 | - No `any` types - always use proper typing 528 | - Prefer interfaces over type aliases for objects 529 | - Use optional chaining (`?.`) and nullish coalescing (`??`) 530 | 531 | ## React 532 | - Functional components only, no class components 533 | - No `import * as React` - use named imports 534 | - Use semantic HTML elements 535 | - All images need alt text 536 | - Interactive elements need aria labels 537 | 538 | ## Styling 539 | - Use Tailwind CSS utilities 540 | - No inline styles 541 | - No hex colors - use design tokens 542 | 543 | ## Testing 544 | - All new features need tests 545 | - Test files must be co-located with source files 546 | - Use descriptive test names that explain the behavior 547 | ``` 548 | 549 | > 💡 **Pro tip**: Your `AGENTS.md` can also serve as documentation for human reviewers! 550 | 551 | --- 552 | 553 | ## 🎨 Project Examples 554 | 555 | ### TypeScript/React Project 556 | 557 | ```bash 558 | # .gga 559 | PROVIDER="claude" 560 | FILE_PATTERNS="*.ts,*.tsx" 561 | EXCLUDE_PATTERNS="*.test.ts,*.test.tsx,*.spec.ts,*.d.ts,*.stories.tsx" 562 | RULES_FILE="AGENTS.md" 563 | ``` 564 | 565 | ### Python Project 566 | 567 | ```bash 568 | # .gga 569 | PROVIDER="ollama:codellama" 570 | FILE_PATTERNS="*.py" 571 | EXCLUDE_PATTERNS="*_test.py,test_*.py,conftest.py,__pycache__/*" 572 | RULES_FILE=".coding-standards.md" 573 | ``` 574 | 575 | ### Go Project 576 | 577 | ```bash 578 | # .gga 579 | PROVIDER="gemini" 580 | FILE_PATTERNS="*.go" 581 | EXCLUDE_PATTERNS="*_test.go,mock_*.go,*_mock.go" 582 | ``` 583 | 584 | ### Full-Stack Monorepo 585 | 586 | ```bash 587 | # .gga 588 | PROVIDER="claude" 589 | FILE_PATTERNS="*.ts,*.tsx,*.py,*.go" 590 | EXCLUDE_PATTERNS="*.test.*,*_test.*,*.mock.*,*.d.ts,dist/*,build/*" 591 | ``` 592 | 593 | --- 594 | 595 | ## 🔄 How It Works 596 | 597 | ``` 598 | git commit -m "feat: add feature" 599 | │ 600 | ▼ 601 | ┌───────────────────────────────────────┐ 602 | │ Pre-commit Hook (gga run) │ 603 | └───────────────────────────────────────┘ 604 | │ 605 | ├──▶ 1. Load config from .gga 606 | │ 607 | ├──▶ 2. Validate provider is installed 608 | │ 609 | ├──▶ 3. Check AGENTS.md exists 610 | │ 611 | ├──▶ 4. Get staged files matching FILE_PATTERNS 612 | │ (excluding EXCLUDE_PATTERNS) 613 | │ 614 | ├──▶ 5. Read coding rules from AGENTS.md 615 | │ 616 | ├──▶ 6. Build prompt: rules + file contents 617 | │ 618 | ├──▶ 7. Send to AI provider (claude/gemini/codex/ollama) 619 | │ 620 | └──▶ 8. Parse response 621 | │ 622 | ├── "STATUS: PASSED" ──▶ ✅ Commit proceeds 623 | │ 624 | └── "STATUS: FAILED" ──▶ ❌ Commit blocked 625 | (shows violation details) 626 | ``` 627 | 628 | --- 629 | 630 | ## 🚫 Bypass Review 631 | 632 | Sometimes you need to commit without review: 633 | 634 | ```bash 635 | # Skip pre-commit hook entirely 636 | git commit --no-verify -m "wip: work in progress" 637 | 638 | # Short form 639 | git commit -n -m "hotfix: urgent fix" 640 | ``` 641 | 642 | --- 643 | 644 | ## 🔗 Integrations 645 | 646 | Gentleman Guardian Angel works standalone with native git hooks, but you can also integrate it with popular hook managers. 647 | 648 | ### Native Git Hook (Default) 649 | 650 | This is what `gga install` does automatically: 651 | 652 | ```bash 653 | # .git/hooks/pre-commit 654 | #!/usr/bin/env bash 655 | gga run || exit 1 656 | ``` 657 | 658 | ### Husky (Node.js projects) 659 | 660 | [Husky](https://typicode.github.io/husky/) is popular in JavaScript/TypeScript projects. 661 | 662 | #### Setup with Husky v9+ 663 | 664 | ```bash 665 | # Install husky 666 | npm install -D husky 667 | 668 | # Initialize husky 669 | npx husky init 670 | ``` 671 | 672 | Edit `.husky/pre-commit`: 673 | 674 | ```bash 675 | #!/usr/bin/env bash 676 | 677 | # Run Gentleman Guardian Angel 678 | gga run || exit 1 679 | 680 | # Your other checks (optional) 681 | npm run lint 682 | npm run typecheck 683 | ``` 684 | 685 | #### With lint-staged (review only staged files) 686 | 687 | ```bash 688 | # Install dependencies 689 | npm install -D husky lint-staged 690 | ``` 691 | 692 | `package.json`: 693 | ```json 694 | { 695 | "scripts": { 696 | "prepare": "husky" 697 | }, 698 | "lint-staged": { 699 | "*.{ts,tsx,js,jsx}": [ 700 | "eslint --fix", 701 | "prettier --write" 702 | ] 703 | } 704 | } 705 | ``` 706 | 707 | `.husky/pre-commit`: 708 | ```bash 709 | #!/usr/bin/env bash 710 | 711 | # AI Review first (uses git staged files internally) 712 | gga run || exit 1 713 | 714 | # Then lint-staged for formatting 715 | npx lint-staged 716 | ``` 717 | 718 | ### pre-commit (Python projects) 719 | 720 | [pre-commit](https://pre-commit.com/) is the standard for Python projects. 721 | 722 | #### Setup 723 | 724 | ```bash 725 | # Install pre-commit 726 | pip install pre-commit 727 | 728 | # Or with brew 729 | brew install pre-commit 730 | ``` 731 | 732 | Create `.pre-commit-config.yaml`: 733 | 734 | ```yaml 735 | repos: 736 | # Gentleman Guardian Angel (runs first) 737 | - repo: local 738 | hooks: 739 | - id: gga 740 | name: Gentleman Guardian Angel 741 | entry: gga run 742 | language: system 743 | pass_filenames: false 744 | stages: [pre-commit] 745 | 746 | # Your other hooks 747 | - repo: https://github.com/psf/black 748 | rev: 24.4.2 749 | hooks: 750 | - id: black 751 | 752 | - repo: https://github.com/pycqa/flake8 753 | rev: 7.0.0 754 | hooks: 755 | - id: flake8 756 | 757 | - repo: https://github.com/pre-commit/mirrors-mypy 758 | rev: v1.10.0 759 | hooks: 760 | - id: mypy 761 | ``` 762 | 763 | Install the hooks: 764 | 765 | ```bash 766 | pre-commit install 767 | ``` 768 | 769 | #### Running manually 770 | 771 | ```bash 772 | # Run all hooks 773 | pre-commit run --all-files 774 | 775 | # Run only AI review 776 | pre-commit run gga 777 | ``` 778 | 779 | ### Lefthook (Fast, language-agnostic) 780 | 781 | [Lefthook](https://github.com/evilmartians/lefthook) is a fast Git hooks manager written in Go. 782 | 783 | #### Setup 784 | 785 | ```bash 786 | # Install 787 | brew install lefthook 788 | 789 | # Or with npm 790 | npm install -D lefthook 791 | ``` 792 | 793 | Create `lefthook.yml`: 794 | 795 | ```yaml 796 | pre-commit: 797 | parallel: false 798 | commands: 799 | ai-review: 800 | run: gga run 801 | fail_text: "Gentleman Guardian Angel failed. Fix violations before committing." 802 | 803 | lint: 804 | glob: "*.{ts,tsx,js,jsx}" 805 | run: npm run lint 806 | 807 | typecheck: 808 | run: npm run typecheck 809 | ``` 810 | 811 | Install hooks: 812 | 813 | ```bash 814 | lefthook install 815 | ``` 816 | 817 | ### CI/CD Integration 818 | 819 | You can also run Gentleman Guardian Angel in your CI pipeline: 820 | 821 | #### GitHub Actions 822 | 823 | ```yaml 824 | # .github/workflows/ai-review.yml 825 | name: Gentleman Guardian Angel 826 | 827 | on: 828 | pull_request: 829 | types: [opened, synchronize] 830 | 831 | jobs: 832 | review: 833 | runs-on: ubuntu-latest 834 | steps: 835 | - uses: actions/checkout@v4 836 | with: 837 | fetch-depth: 0 838 | 839 | - name: Install Gentleman Guardian Angel 840 | run: | 841 | git clone https://github.com/Gentleman-Programming/gentleman-guardian-angel.git /tmp/gga 842 | chmod +x /tmp/gga/bin/gga 843 | echo "/tmp/gga/bin" >> $GITHUB_PATH 844 | 845 | - name: Install Claude CLI 846 | run: | 847 | # Install your preferred provider CLI 848 | npm install -g @anthropic-ai/claude-code 849 | env: 850 | ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} 851 | 852 | - name: Run AI Review 853 | run: | 854 | # Get changed files in PR 855 | git diff --name-only origin/${{ github.base_ref }}...HEAD > /tmp/changed_files.txt 856 | 857 | # Stage them for review 858 | cat /tmp/changed_files.txt | xargs git add 859 | 860 | # Run review 861 | gga run 862 | ``` 863 | 864 | #### GitLab CI 865 | 866 | ```yaml 867 | # .gitlab-ci.yml 868 | gga: 869 | stage: test 870 | image: ubuntu:latest 871 | before_script: 872 | - apt-get update && apt-get install -y git curl 873 | - git clone https://github.com/Gentleman-Programming/gentleman-guardian-angel.git /opt/gga 874 | - export PATH="/opt/gga/bin:$PATH" 875 | # Install your provider CLI here 876 | script: 877 | - git diff --name-only $CI_MERGE_REQUEST_DIFF_BASE_SHA | xargs git add 878 | - gga run 879 | only: 880 | - merge_requests 881 | ``` 882 | 883 | --- 884 | 885 | ## 🐛 Troubleshooting 886 | 887 | ### "Provider not found" 888 | 889 | ```bash 890 | # Check if your provider CLI is installed and in PATH 891 | which claude # Should show: /usr/local/bin/claude or similar 892 | which gemini 893 | which codex 894 | which ollama 895 | 896 | # Test if the provider works 897 | echo "Say hello" | claude --print 898 | ``` 899 | 900 | ### "Rules file not found" 901 | 902 | The tool requires a rules file to know what to check: 903 | 904 | ```bash 905 | # Create your rules file 906 | touch AGENTS.md 907 | 908 | # Add your coding standards 909 | echo "# My Coding Standards" > AGENTS.md 910 | echo "- No console.log in production" >> AGENTS.md 911 | ``` 912 | 913 | ### "Ambiguous response" in Strict Mode 914 | 915 | The AI must respond with `STATUS: PASSED` or `STATUS: FAILED` as the first line. If it doesn't: 916 | 917 | 1. Try Claude (most reliable at following instructions) 918 | 2. Check your rules file isn't confusing the AI 919 | 3. Temporarily disable strict mode: `STRICT_MODE="false"` 920 | 921 | ### Slow reviews on large files 922 | 923 | The tool sends full file contents. For better performance: 924 | 925 | ```bash 926 | # Add large/generated files to exclude 927 | EXCLUDE_PATTERNS="*.min.js,*.bundle.js,dist/*,build/*,*.generated.ts" 928 | ``` 929 | 930 | --- 931 | 932 | ## 🧪 Development 933 | 934 | ### Project Structure 935 | 936 | ``` 937 | gentleman-guardian-angel/ 938 | ├── bin/ 939 | │ └── gga # Main CLI script 940 | ├── lib/ 941 | │ ├── providers.sh # AI provider implementations 942 | │ └── cache.sh # Smart caching logic 943 | ├── spec/ # ShellSpec test suite 944 | │ ├── spec_helper.sh # Test setup and helpers 945 | │ ├── unit/ 946 | │ │ ├── cache_spec.sh # Cache unit tests (27 tests) 947 | │ │ └── providers_spec.sh # Provider unit tests (13 tests) 948 | │ └── integration/ 949 | │ └── commands_spec.sh # CLI integration tests (28 tests) 950 | ├── Makefile # Development commands 951 | ├── .shellspec # Test runner config 952 | ├── install.sh # Manual installer 953 | ├── uninstall.sh # Uninstaller 954 | └── README.md 955 | ``` 956 | 957 | ### Running Tests 958 | 959 | GGA uses [ShellSpec](https://shellspec.info/) for testing - a BDD-style testing framework for shell scripts. 960 | 961 | ```bash 962 | # Install dependencies (once) 963 | brew install shellspec shellcheck 964 | 965 | # Run all tests (68 total) 966 | make test 967 | 968 | # Run specific test suites 969 | make test-unit # Unit tests only (40 tests) 970 | make test-integration # Integration tests only (28 tests) 971 | 972 | # Lint shell scripts with shellcheck 973 | make lint 974 | 975 | # Run all checks before commit (lint + tests) 976 | make check 977 | ``` 978 | 979 | ### Test Coverage 980 | 981 | | Module | Tests | Description | 982 | |--------|-------|-------------| 983 | | `cache.sh` | 27 | Hash functions, cache validation, file caching | 984 | | `providers.sh` | 13 | Provider parsing, validation, info display | 985 | | CLI commands | 28 | init, install, uninstall, run, config, cache | 986 | | **Total** | **68** | Full coverage of core functionality | 987 | 988 | ### Adding New Tests 989 | 990 | ```bash 991 | # Create a new spec file 992 | touch spec/unit/my_feature_spec.sh 993 | 994 | # Run only your new tests 995 | shellspec spec/unit/my_feature_spec.sh 996 | ``` 997 | 998 | --- 999 | 1000 | ## 📋 Changelog 1001 | 1002 | ### v2.2.0 (Latest) 1003 | - ✅ Added comprehensive test suite with **68 tests** 1004 | - ✅ Unit tests for `cache.sh` and `providers.sh` 1005 | - ✅ Integration tests for all CLI commands 1006 | - ✅ Added `Makefile` with `test`, `lint`, `check` targets 1007 | - ✅ Fixed shellcheck warnings 1008 | 1009 | ### v2.1.0 1010 | - ✅ Smart caching system - skip unchanged files 1011 | - ✅ Auto-invalidation when AGENTS.md or .gga changes 1012 | - ✅ Cache commands: `status`, `clear`, `clear-all` 1013 | - ✅ `--no-cache` flag to bypass caching 1014 | 1015 | ### v2.0.0 1016 | - ✅ Renamed to Gentleman Guardian Angel (gga) 1017 | - ✅ Auto-migration from legacy `ai-code-review` hooks 1018 | - ✅ Homebrew tap distribution 1019 | 1020 | ### v1.0.0 1021 | - ✅ Initial release with Claude, Gemini, Codex, Ollama support 1022 | - ✅ File patterns and exclusions 1023 | - ✅ Strict mode for CI/CD 1024 | 1025 | --- 1026 | 1027 | ## 🤝 Contributing 1028 | 1029 | Contributions are welcome! Some ideas: 1030 | 1031 | - [ ] Add more providers (Copilot, Codeium, etc.) 1032 | - [ ] Support for `.gga.yaml` format 1033 | - [x] ~~Caching to avoid re-reviewing unchanged files~~ ✅ Done in v2.1.0 1034 | - [x] ~~Add test suite~~ ✅ Done in v2.2.0 1035 | - [ ] GitHub Action version 1036 | - [ ] Output formats (JSON, SARIF for IDE integration) 1037 | 1038 | ```bash 1039 | # Fork, clone, and submit PRs! 1040 | git clone https://github.com/Gentleman-Programming/gentleman-guardian-angel.git 1041 | cd gentleman-guardian-angel 1042 | 1043 | # Run tests before submitting 1044 | make check 1045 | ``` 1046 | 1047 | --- 1048 | 1049 | ## 📄 License 1050 | 1051 | MIT © 2024 1052 | 1053 | --- 1054 | 1055 |

1056 | Built with 🧉 by developers who got tired of repeating the same code review comments 1057 |

1058 | --------------------------------------------------------------------------------