diff --git a/packages/gitguard/README.md b/packages/gitguard/README.md index 3f3d0788..37e4f0c8 100644 --- a/packages/gitguard/README.md +++ b/packages/gitguard/README.md @@ -48,20 +48,26 @@ cd packages/gitguard ## Features -- šŸŽÆ **Automatic Scope Detection**: Automatically detects the package scope based on changed files +- šŸŽÆ **Smart Repository Detection**: + - Automatically detects monorepo vs standard repository structure + - Adapts commit message format accordingly - šŸ¤– **Multi-Provider AI Suggestions**: Offers intelligent commit message suggestions using: - Azure OpenAI (with fallback model support) - Local Ollama models -- šŸ“¦ **Monorepo Awareness**: Detects changes across multiple packages and suggests appropriate formatting -- āœØ **Conventional Commits**: Enforces conventional commit format (`type(scope): description`) -- šŸ” **Change Analysis**: Analyzes file changes to suggest appropriate commit types -- šŸšØ **Multi-Package Warning**: Alerts when changes span multiple packages, encouraging atomic commits -- šŸ”’ **Security Checks**: - - Detects accidentally committed secrets and sensitive data - - Identifies problematic files (env files, keys, logs, etc.) - - Only warns about newly added problematic files - - Provides specific remediation steps - - Blocks commits containing secrets +- šŸ“¦ **Repository-Aware Formatting**: + - Monorepo: Enforces package scopes and detects cross-package changes + - Standard Repos: Uses conventional commits without forcing scopes +- āœØ **Conventional Commits**: + - Enforces conventional commit format + - Monorepo: `type(scope): description` + - Standard: `type: description` (scope optional) +- šŸ” **Smart Change Analysis**: + - Analyzes file changes to suggest appropriate commit types + - Groups changes by directory type in standard repos + - Groups by package in monorepos +- šŸšØ **Change Cohesion Checks**: + - Monorepo: Alerts when changes span multiple packages + - Standard: Warns about changes across unrelated components ## Security Features @@ -87,33 +93,34 @@ Warns about newly added sensitive files: ## How It Works 1. When you create a commit, GitGuard: + - Detects repository type (monorepo vs standard) - Analyzes your staged changes - Performs security checks for secrets and sensitive files - - Warns about multi-package changes + - Suggests appropriate commit structure based on repository type - Offers AI suggestions for commit messages -2. Security checks: - - Blocks commits containing detected secrets - - Warns about newly added sensitive files - - Provides specific remediation steps -3. For multi-package changes: - - Warns about atomic commit violations - - Suggests splitting the commit - - Adds "Affected packages" section + +2. Repository-specific behavior: + - Monorepo: + - Enforces package scopes + - Warns about cross-package changes + - Adds "Affected packages" section for multi-package commits + - Standard Repository: + - Makes scopes optional + - Groups changes by directory type (src, test, docs, etc.) + - Focuses on change type and description clarity ## Example Usage ```bash -# Regular commit +# Monorepo commit git commit -m "update login form" # GitGuard will transform to: feat(auth): update login form -# Commit with security issues -git commit -m "add config" -# GitGuard will detect secrets or sensitive files and: -# - Block the commit if secrets are found -# - Warn about sensitive files and suggest .gitignore +# Standard repo commit +git commit -m "update login form" +# GitGuard will transform to: feat: update login form -# Multi-package changes +# Monorepo multi-package changes git commit -m "update theme colors" # GitGuard will warn about multiple packages and suggest: # style(design-system): update theme colors @@ -121,6 +128,18 @@ git commit -m "update theme colors" # Affected packages: # - @siteed/design-system # - @siteed/mobile-components + +# Standard repo complex changes +git commit -m "update authentication" +# GitGuard will suggest: +# feat: update authentication +# +# Changes: +# Source: +# ā€¢ src/auth/login.ts +# ā€¢ src/auth/session.ts +# Tests: +# ā€¢ tests/auth/login.test.ts ``` ## Testing Security Features diff --git a/packages/gitguard/gitguard-prepare.py b/packages/gitguard/gitguard-prepare.py index b63918c5..8c544be0 100644 --- a/packages/gitguard/gitguard-prepare.py +++ b/packages/gitguard/gitguard-prepare.py @@ -217,6 +217,7 @@ def group_files_by_type(files: List[str]) -> Dict[str, List[str]]: def enhance_ai_prompt(packages: List[Dict], original_msg: str) -> str: """Generate detailed AI prompt based on commit complexity analysis.""" complexity = calculate_commit_complexity(packages) + is_mono = is_monorepo() try: diff = check_output(["git", "diff", "--cached"]).decode("utf-8") @@ -227,6 +228,7 @@ def enhance_ai_prompt(packages: List[Dict], original_msg: str) -> str: analysis = { "complexity_score": complexity["score"], "complexity_reasons": complexity["reasons"], + "repository_type": "monorepo" if is_mono else "standard", "packages": [] } @@ -240,14 +242,19 @@ def enhance_ai_prompt(packages: List[Dict], original_msg: str) -> str: prompt = f"""Analyze the following git changes and suggest a commit message. +Repository Type: {analysis['repository_type']} Complexity Analysis: - Score: {complexity['score']} (threshold for structured format: 5) - Factors: {', '.join(complexity['reasons'])} -Changed Packages:""" +Changed Files:""" for pkg in analysis["packages"]: - prompt += f"\n\nšŸ“¦ {pkg['name']} ({pkg['scope']})" + if is_mono: + prompt += f"\n\nšŸ“¦ {pkg['name']}" + (f" ({pkg['scope']})" if pkg['scope'] else "") + else: + prompt += f"\n\nDirectory: {pkg['name']}" + for file_type, files in pkg["files_by_type"].items(): prompt += f"\n{file_type}:" for file in files: @@ -269,11 +276,14 @@ def enhance_ai_prompt(packages: List[Dict], original_msg: str) -> str: "message": "complete commit message", "explanation": "reasoning", "type": "commit type", - "scope": "scope", + "scope": "{'scope (required for monorepo)' if is_mono else 'scope (optional)'}", "description": "title description" }} ] -}}""" +}} + +{'Note: This is a monorepo, so package scope is required.' if is_mono else 'Note: This is a standard repository, so scope is optional.'} +""" return prompt @@ -439,14 +449,12 @@ def get_ai_suggestion(prompt: str, original_message: str) -> Optional[List[Dict[ def create_commit_message(commit_info: Dict[str, Any], packages: List[Dict[str, Any]]) -> str: """Create appropriate commit message based on complexity.""" - # Clean the description to remove any existing type prefix description = commit_info['description'] type_pattern = r'^(feat|fix|docs|style|refactor|test|chore|build|ci|perf|revert)(\([^)]+\))?:\s*' if re.match(type_pattern, description): description = re.sub(type_pattern, '', description) commit_info['description'] = description.strip() - # Calculate complexity complexity = calculate_commit_complexity(packages) if Config().get("debug"): @@ -456,25 +464,28 @@ def create_commit_message(commit_info: Dict[str, Any], packages: List[Dict[str, for reason in complexity["reasons"]: print(f"- {reason}") - # For simple commits, just return the title + # For simple commits if not complexity["needs_structure"]: - return f"{commit_info['type']}({commit_info['scope']}): {commit_info['description']}" + # Only include scope if it exists (for monorepo) or is explicitly set + if commit_info.get('scope'): + return f"{commit_info['type']}({commit_info['scope']}): {commit_info['description']}" + return f"{commit_info['type']}: {commit_info['description']}" - # For complex commits, use structured format + # For complex commits return create_structured_commit(commit_info, packages) def create_structured_commit(commit_info: Dict[str, Any], packages: List[Dict[str, Any]]) -> str: """Create a structured commit message for complex changes.""" - # Clean the description to remove any existing type prefix description = commit_info['description'] type_pattern = r'^(feat|fix|docs|style|refactor|test|chore|build|ci|perf|revert)(\([^)]+\))?:\s*' if re.match(type_pattern, description): description = re.sub(type_pattern, '', description) # Start with the commit title - message_parts = [ - f"{commit_info['type']}({commit_info['scope']}): {description}" - ] + if commit_info.get('scope'): + message_parts = [f"{commit_info['type']}({commit_info['scope']}): {description}"] + else: + message_parts = [f"{commit_info['type']}: {description}"] # Add a blank line after title message_parts.append("") @@ -512,16 +523,43 @@ def get_package_json_name(package_path: Path) -> Optional[str]: return None return None -# ... [previous code remains the same until get_changed_packages] +def is_monorepo() -> bool: + """Detect if the current repository is a monorepo.""" + try: + git_root = Path(check_output(["git", "rev-parse", "--show-toplevel"], text=True).strip()) + + # Common monorepo indicators + monorepo_indicators = [ + git_root / "packages", + git_root / "apps", + git_root / "libs", + git_root / "services" + ] + + # Check for package.json with workspaces + package_json = git_root / "package.json" + if package_json.exists(): + try: + data = json.loads(package_json.read_text()) + if "workspaces" in data: + return True + except json.JSONDecodeError: + pass + + # Check for common monorepo directories + return any(indicator.is_dir() for indicator in monorepo_indicators) + + except Exception as e: + if Config().get("debug"): + print(f"Error detecting repository type: {e}") + return False def get_changed_packages() -> List[Dict]: """Get all packages with changes in the current commit.""" try: - # Get git root directory git_root = Path(check_output(["git", "rev-parse", "--show-toplevel"], text=True).strip()) current_dir = Path.cwd() - # Get relative path from current directory to git root try: rel_path = current_dir.relative_to(git_root) except ValueError: @@ -542,12 +580,14 @@ def get_changed_packages() -> List[Dict]: print(f"Error getting changed files: {e}") return [] + is_mono = is_monorepo() packages = {} + for file in changed_files: if not file: continue - if file.startswith("packages/"): + if is_mono and file.startswith("packages/"): parts = file.split("/") if len(parts) > 1: pkg_path = f"packages/{parts[1]}" @@ -555,21 +595,37 @@ def get_changed_packages() -> List[Dict]: packages[pkg_path] = [] packages[pkg_path].append(file) else: - if "root" not in packages: - packages["root"] = [] - packages["root"].append(file) + # For standard repos, group by directory type + path_parts = Path(file).parts + if not path_parts: + continue + + # Determine appropriate grouping based on file type/location + if path_parts[0] in {"src", "test", "docs", "scripts"}: + group = path_parts[0] + else: + group = "root" + + if group not in packages: + packages[group] = [] + packages[group].append(file) results = [] for pkg_path, files in packages.items(): - if pkg_path == "root": - scope = name = "root" - else: - pkg_name = get_package_json_name(Path(pkg_path)) - if pkg_name: - name = pkg_name - scope = pkg_name.split("/")[-1] + if is_mono: + if pkg_path == "root": + scope = name = "root" else: - name = scope = pkg_path.split("/")[-1] + pkg_name = get_package_json_name(Path(pkg_path)) + if pkg_name: + name = pkg_name + scope = pkg_name.split("/")[-1] + else: + name = scope = pkg_path.split("/")[-1] + else: + # For standard repos, scope is optional + name = pkg_path + scope = None if pkg_path == "root" else pkg_path results.append({ "name": name, diff --git a/packages/gitguard/install.sh b/packages/gitguard/install.sh index 99bf5c06..68e497b3 100755 --- a/packages/gitguard/install.sh +++ b/packages/gitguard/install.sh @@ -40,29 +40,37 @@ check_installation_status() { status+=("project:none") fi - # Check global installation - GLOBAL_GIT_DIR="$(git config --global core.hooksPath)" + # Initialize GLOBAL_GIT_DIR + GLOBAL_GIT_DIR="$(git config --global core.hooksPath || echo "")" if [ -z "$GLOBAL_GIT_DIR" ]; then - GLOBAL_GIT_DIR="$HOME/.git/hooks" + GLOBAL_GIT_DIR="$HOME/.config/git/hooks" fi + global_hook=$(check_existing_hook "$GLOBAL_GIT_DIR/prepare-commit-msg") status+=("global:$global_hook") printf "%s " "${status[@]}" } -install_hook() { - local target_dir="$1" - local hook_path="$target_dir/hooks/prepare-commit-msg" - mkdir -p "$target_dir/hooks" - cp "$SCRIPT_DIR/gitguard-prepare.py" "$hook_path" - chmod +x "$hook_path" -} - handle_installation() { local target_dir="$1" local install_type="$2" - local hook_path="$target_dir/hooks/prepare-commit-msg" + + echo -e "${BLUE}Starting $install_type installation...${NC}" + echo -e "Target directory: $target_dir" + + # For global installation, use GLOBAL_GIT_DIR directly + if [ "$install_type" = "global" ]; then + if [ -z "$GLOBAL_GIT_DIR" ]; then + echo -e "${RED}Error: Global git hooks directory is not set${NC}" + echo -e "Attempting to create default directory at: $HOME/.config/git/hooks" + GLOBAL_GIT_DIR="$HOME/.config/git/hooks" + fi + target_dir="$GLOBAL_GIT_DIR" + fi + + local hook_path="$target_dir/prepare-commit-msg" + echo -e "Installing hook to: $hook_path" # Check existing hook local existing_hook=$(check_existing_hook "$hook_path") @@ -77,9 +85,50 @@ handle_installation() { fi fi - # Install the hook without asking for confirmation if it's a reinstall - install_hook "$target_dir" - echo -e "${GREEN}āœ… GitGuard installed successfully for $install_type use!${NC}" + # Create directory with verbose output + echo -e "Creating directory: $(dirname "$hook_path")" + if ! mkdir -p "$(dirname "$hook_path")" 2>/dev/null; then + echo -e "${RED}Failed to create directory: $(dirname "$hook_path")${NC}" + echo -e "Attempting with sudo..." + sudo mkdir -p "$(dirname "$hook_path")" + fi + + # Copy hook file with verbose output + echo -e "Copying hook from: $SCRIPT_DIR/gitguard-prepare.py" + if ! cp "$SCRIPT_DIR/gitguard-prepare.py" "$hook_path" 2>/dev/null; then + echo -e "${RED}Failed to copy hook file${NC}" + echo -e "Attempting with sudo..." + sudo cp "$SCRIPT_DIR/gitguard-prepare.py" "$hook_path" + fi + + # Set permissions with verbose output + echo -e "Setting execute permissions" + if ! chmod +x "$hook_path" 2>/dev/null; then + echo -e "${RED}Failed to set permissions${NC}" + echo -e "Attempting with sudo..." + sudo chmod +x "$hook_path" + fi + + # If this is a global installation, set the global hooks path + if [ "$install_type" = "global" ]; then + echo -e "Configuring global git hooks path" + if ! git config --global core.hooksPath "$GLOBAL_GIT_DIR" 2>/dev/null; then + echo -e "${RED}Failed to set global git hooks path${NC}" + echo -e "Attempting with sudo..." + sudo git config --global core.hooksPath "$GLOBAL_GIT_DIR" + fi + fi + + # Verify installation + if [ -x "$hook_path" ]; then + echo -e "${GREEN}āœ… GitGuard installed successfully for $install_type use!${NC}" + echo -e "Hook location: $hook_path" + else + echo -e "${RED}āŒ Installation failed. Please check permissions and try again.${NC}" + echo -e "You may need to run the script with sudo or manually create the directory:" + echo -e "mkdir -p \"$(dirname "$hook_path")\"" + exit 1 + fi } main() { @@ -114,15 +163,15 @@ main() { REPLY=${REPLY:-1} case $REPLY in - 1) handle_installation "$(git rev-parse --git-dir)" "project" ;; + 1) handle_installation "$(git rev-parse --git-dir)/hooks" "project" ;; 2) handle_installation "$GLOBAL_GIT_DIR" "global" ;; 3) - handle_installation "$(git rev-parse --git-dir)" "project" + handle_installation "$(git rev-parse --git-dir)/hooks" "project" handle_installation "$GLOBAL_GIT_DIR" "global" ;; *) echo -e "${YELLOW}Invalid option. Using default: Project only${NC}" - handle_installation "$(git rev-parse --git-dir)" "project" + handle_installation "$(git rev-parse --git-dir)/hooks" "project" ;; esac else @@ -141,7 +190,7 @@ main() { 2) echo -e "${YELLOW}Installation cancelled.${NC}" ;; *) # Directly reinstall without additional confirmation - [ "$project_status" = "gitguard" ] && handle_installation "$(git rev-parse --git-dir)" "project" + [ "$project_status" = "gitguard" ] && handle_installation "$(git rev-parse --git-dir)/hooks" "project" [ "$global_status" = "gitguard" ] && handle_installation "$GLOBAL_GIT_DIR" "global" ;; esac