From d373f953885eeb588909b9b6a5df4f81eccaae48 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Wed, 24 Dec 2025 23:29:45 +0545 Subject: [PATCH] refactor(devgen): enhance AI commit message sanitization The `sanitize_ai_commit_message` function has been significantly improved to better handle AI-generated commit messages, including support for bolded headers, more robust header detection, and a fallback mechanism for non-conventional messages. **Enhancements & Refinements:** - Added comprehensive docstring explaining the function's purpose and behavior - Improved regex pattern to match conventional commit headers with optional bolding (e.g., **feat: ...**) - Added case-insensitive matching for commit types - Implemented a state-based approach with `found_header` flag for clearer logic - Added automatic removal of bold markers (**) from headers - Introduced fallback behavior to return the first non-empty line if no conventional header is found - Improved code structure with better variable naming and comments - Added early return for empty input strings **Bug Fixes:** - Fixed potential issue where the function could return empty strings for non-conventional messages - Resolved edge case handling for messages without proper headers ``` --- devgen/modules/commit_generator.py | 94 +++++++++++++++++++---- devgen/templates/commit/commit_message.j2 | 19 +++-- devgen/utils.py | 48 +++++++++--- 3 files changed, 124 insertions(+), 37 deletions(-) diff --git a/devgen/modules/commit_generator.py b/devgen/modules/commit_generator.py index 94fbb28..2f97b5b 100644 --- a/devgen/modules/commit_generator.py +++ b/devgen/modules/commit_generator.py @@ -26,6 +26,20 @@ class CommitEngineError(Exception): pass +# Token Optimization Constants +MAX_DIFF_SIZE = 8000 # Maximum characters for a single group diff +IGNORE_PATTERNS = [ + "uv.lock", + "package-lock.json", + "pnpm-lock.yaml", + "yarn.lock", + "composer.lock", + "Gemfile.lock", + "poetry.lock", +] + + + class CommitEngine: """ Engine for generating AI-powered commit messages. @@ -72,6 +86,7 @@ class CommitEngine: def detect_changes(self) -> List[str]: """Detects changed, deleted, or untracked files.""" try: + # Get modified, deleted and untracked files out = run_git_command( [ "git", @@ -82,7 +97,15 @@ class CommitEngine: "--exclude-standard", ] ) - return [f.strip() for f in out.split("\n") if f.strip()] + files = [f.strip() for f in out.split("\n") if f.strip()] + + # Also get staged files (in case of a previous failed run) + staged_out = run_git_command(["git", "diff", "--name-only", "--cached"]) + staged_files = [f.strip() for f in staged_out.split("\n") if f.strip()] + + # Combine and deduplicate + all_files = list(set(files + staged_files)) + return all_files except subprocess.CalledProcessError as e: msg = f"Git command failed: {' '.join(e.cmd)}\nError: {e.stderr.strip()}" self.logger.error(msg) @@ -98,11 +121,38 @@ class CommitEngine: return groups def generate_diff(self, files: List[str]) -> str: - """Generates diff for specific files.""" + """Generates diff for specific files, with truncation for token optimization.""" try: - return run_git_command( - ["git", "--no-pager", "diff", "--staged", "--", *files] - ) + # Filter out very large metadata files that don't need full diffs + summary_info = [] + files_to_diff = [] + for f in files: + if any(p in f for p in IGNORE_PATTERNS): + summary_info.append(f"[METADATA UPDATED] {f}") + else: + files_to_diff.append(f) + + diff = "" + if files_to_diff: + diff = run_git_command( + ["git", "--no-pager", "diff", "--staged", "--", *files_to_diff] + ) + + full_content = "\n".join(summary_info + [diff]).strip() + + # Truncate if too large + if len(full_content) > MAX_DIFF_SIZE: + self.logger.info( + f"Truncating diff from {len(full_content)} to {MAX_DIFF_SIZE} chars" + ) + half = MAX_DIFF_SIZE // 2 + return ( + full_content[:half] + + "\n\n... [DIFF TRUNCATED FOR TOKEN OPTIMIZATION] ...\n\n" + + full_content[-half:] + ) + + return full_content except subprocess.CalledProcessError as e: msg = f"Git command failed: {' '.join(e.cmd)}\nError: {e.stderr.strip()}" self.logger.error(msg) @@ -239,20 +289,30 @@ class CommitEngine: return True msg = self.generate_message(group, diff, cache) - if not msg: - self.logger.error(f"Empty message for {group}") + try: + if not msg: + self.logger.error(f"Empty message for {group}") + self._reset_group(files) + return False + + if self.dry_run: + self._log_dry_run(group, msg) + self._reset_group(files) + else: + self.commit_staged(msg) + + return True + except Exception as e: + self.logger.error(f"Failed to process group {group}: {e}") + self._reset_group(files) return False - if self.dry_run: - self._log_dry_run(group, msg) - try: - run_git_command(["git", "reset", "HEAD", "--", *files]) - except subprocess.CalledProcessError: - pass - else: - self.commit_staged(msg) - - return True + def _reset_group(self, files: List[str]): + """Unstages files for a group.""" + try: + run_git_command(["git", "reset", "HEAD", "--", *files]) + except subprocess.CalledProcessError: + pass # Ignore reset errors def execute(self): """Main execution method.""" diff --git a/devgen/templates/commit/commit_message.j2 b/devgen/templates/commit/commit_message.j2 index 0a282fe..4f859c9 100644 --- a/devgen/templates/commit/commit_message.j2 +++ b/devgen/templates/commit/commit_message.j2 @@ -1,15 +1,14 @@ - -Analyze the following git diff for the directory `{{ group_name }}` and generate a detailed semantic release commit message4. Format the output as: -1. A summary title in semantic release format (type(scope): summary) — scope can be the directory or 'root'. -2. A short summary paragraph if needed. -3. Bullet points listing the main changes, grouped as 'New Features', 'Enhancements & Refinements', and 'Bug Fixes' if relevant. Use bold for feature/section names. +Analyze the following git diff for `{{ group_name }}` and generate a semantic commit message. +Format: +1. Title: `(): ` (scope is `{{ group_name }}` or `root`). +2. Optional short summary. +3. Bullet points: `New Features`, `Enhancements`, `Bug Fixes`. Use **bold** for section names. {% if use_emoji %} -4. Use appropriate emojis for the commit type and sections (e.g., 🚀 for features, 🐛 for bugs). +4. Use emojis (e.g., 🚀, 🐛). {% else %} -4. Do NOT use any emojis in the commit message. +4. No emojis. {% endif %} -Output only the commit message, don't include explanations or extra text. - -Here is the git diff for `{{ group_name }}`: +Output ONLY the message. +Diff for `{{ group_name }}`: {{ diff_text }} diff --git a/devgen/utils.py b/devgen/utils.py index 5ebc8a4..7820c2c 100644 --- a/devgen/utils.py +++ b/devgen/utils.py @@ -31,25 +31,53 @@ def is_file_recent(file_path: Path | str, max_age_minutes: int = 120) -> bool: def sanitize_ai_commit_message(raw_text: str) -> str: + """ + Cleans up the AI-generated commit message. + It looks for a conventional commit header and extracts everything from there. + It handles bolded headers and common AI prefixes. + """ + if not raw_text: + return "" + lines = raw_text.strip().split("\n") - cleaned_lines = [] - in_block = False - # Regex for conventional commit header + + # Regex for conventional commit header (including optional bolding) + # Examples: + # - feat(root): summary + # - **fix: bug fix** + # - chore(deps)!: breaking change header_pattern = re.compile( - r"^(feat|fix|chore|refactor|docs|style|test|build|ci)(\(.*\))?!?: .*" + r"^(\*\*)?(feat|fix|chore|refactor|docs|style|test|build|ci)(\(.*\))?!?: .*", + re.IGNORECASE ) + cleaned_lines = [] + found_header = False + for line in lines: stripped = line.strip() - if in_block: - if header_pattern.match(stripped) or "**Sponsor**" in line: + if not found_header: + if header_pattern.match(stripped): + found_header = True + # Remove bolding if present + clean_line = stripped.replace("**", "").strip() + cleaned_lines.append(clean_line) + else: + # If we hit another header or a known separator, we stop (optional, but keep for now) + if "**Sponsor**" in line: break cleaned_lines.append(line) - elif header_pattern.match(stripped): - in_block = True - cleaned_lines.append(line) - return "\n".join(cleaned_lines).strip() if cleaned_lines else "" + if cleaned_lines: + return "\n".join(cleaned_lines).strip() + + # Fallback: if no conventional commit header found, just take the first non-empty line + # to avoid failing completely, but log a warning if possible. + for line in lines: + if line.strip(): + return line.strip() + + return "" def parse_markdown_sections(