diff --git a/README.md b/README.md index adc7cd6..c56c159 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,9 @@ devgen commit run --dry-run # Commit and push in one go devgen commit run --push +# Review and edit AI messages before committing +devgen commit run --check + # Made a mistake? Undo the last AI commit and keep changes staged devgen commit undo ``` diff --git a/TODO.md b/TODO.md index 0e71222..4e2372d 100644 --- a/TODO.md +++ b/TODO.md @@ -4,7 +4,7 @@ This document outlines future improvements, features, and technical debt for `de ## 🚀 Priority: Short-Term (Reliability & DX) - [ ] **Full Test Suite**: Implement `pytest` for all modules, especially `commit_generator` and `utils`. -- [ ] **Interactive Commit Review**: Add a `--check` flag to `devgen commit run` that allows users to approve/edit the AI message before committing. +- [x] **Interactive Commit Review**: Add a `--check` flag to `devgen commit run` that allows users to approve/edit the AI message before committing. - [ ] **Project-Level Config**: Support `.devgen.yaml` in the repository root that overrides global `~/.devgen.yaml`. - [ ] **Improved Gitignore Detection**: Auto-detect project type (Python, Node, Go) and suggest relevant `.gitignore` templates during `devgen gitignore run`. @@ -18,7 +18,7 @@ This document outlines future improvements, features, and technical debt for `de - [ ] **Pre-commit Hook Integration**: Provide a command to easily install `devgen` as a pre-commit hook. - [x] **Shell Completion**: Implement `typer` shell completion for `bash`, `zsh`, and `fish`. - [x] **Commit Undo**: `devgen commit undo` to easily revert the last AI-generated commit and unstage files. -- [ ] **Multi-Group Commits**: Better handling of repositories where changes are spread across many unrelated directories. +- [x] **Multi-Group Commits**: Better handling of repositories where changes are spread across many unrelated directories. ## 🏗️ Refactoring & Technical Debt - [ ] **Code Split**: Move CLI logic out of `devgen/cli` into separate service classes to improve testability. diff --git a/devgen/cli/commit.py b/devgen/cli/commit.py index 16c9c47..e1c1324 100644 --- a/devgen/cli/commit.py +++ b/devgen/cli/commit.py @@ -51,12 +51,19 @@ def run_commit( help="Force regeneration of commit messages.", ), ] = False, + check: Annotated[ + bool, + typer.Option( + "--check", + help="Review/edit commit message before committing.", + ), + ] = False, ) -> None: log_file = get_main_log_path() logger = configure_logger("devgen.cli.commit", log_file, console=debug) logger.info(f"Log file: {log_file}") logger.info( - f"Options: dry_run={dry_run}, push={push}, debug={debug}, force={force_rebuild}" + f"Options: dry_run={dry_run}, push={push}, debug={debug}, force={force_rebuild}, check={check}" ) run_commit_engine( @@ -64,6 +71,7 @@ def run_commit( push=push, debug=debug, force_rebuild=force_rebuild, + check=check, logger=logger, ) diff --git a/devgen/modules/commit_generator.py b/devgen/modules/commit_generator.py index aca2086..2d4adbe 100644 --- a/devgen/modules/commit_generator.py +++ b/devgen/modules/commit_generator.py @@ -1,3 +1,5 @@ +import sys +import questionary import subprocess from collections import defaultdict from datetime import datetime @@ -52,6 +54,7 @@ class CommitEngine: push: bool = False, debug: bool = False, force_rebuild: bool = False, + check: bool = False, provider: str = "gemini", model: str = "gemini-2.5-flash", logger: Any | None = None, @@ -61,6 +64,7 @@ class CommitEngine: self.push = push self.debug = debug self.force_rebuild = force_rebuild + self.check = check self.provider = provider self.model = model self.logger = logger or configure_logger( @@ -112,13 +116,44 @@ class CommitEngine: raise CommitEngineError(msg) from e def group_files(self, files: List[str]) -> Dict[str, List[str]]: - """Groups files by their parent directory.""" + """Groups files by their parent directory with smart merging if limit is exceeded.""" + max_groups = self.config.get("max_groups", 5) + + # 1. Initial grouping by immediate parent groups = defaultdict(list) for f in files: parent = str(Path(f).parent) key = "root" if parent == "." else parent groups[key].append(f) - return groups + + if len(groups) <= max_groups: + return dict(groups) + + self.logger.info( + f"Too many groups ({len(groups)}). Merging based on max_groups={max_groups}" + ) + + # 2. Iteratively merge the deepest group into its parent until we hit the limit + while len(groups) > max_groups: + # Find the deepest path among the current group keys + # Skip 'root' as it's the top level + potential_merges = [k for k in groups.keys() if k != "root"] + if not potential_merges: + # This could happen if only 'root' is left or if max_groups is very small + break + + # Deepest path is the one with the most segments + deepest = max(potential_merges, key=lambda p: len(Path(p).parts)) + + # Find the parent of this deepest path + parent_path = str(Path(deepest).parent) + new_key = "root" if parent_path == "." else parent_path + + # Merge files into the new key + self.logger.debug(f"Merging group '{deepest}' into '{new_key}'") + groups[new_key].extend(groups.pop(deepest)) + + return dict(groups) def generate_diff(self, files: List[str]) -> str: """Generates diff for specific files, with truncation for token optimization.""" @@ -366,6 +401,42 @@ class CommitEngine: self._log_dry_run(group, msg) self._reset_group(files) else: + if self.check: + self.console.print( + Panel( + Markdown(msg), + title=f"Proposed Commit Message [group: {group}]", + border_style="cyan", + ) + ) + choice = questionary.select( + "How would you like to proceed?", + choices=[ + "Confirm", + "Edit", + "Abort", + ], + default="Confirm", + ).ask() + + if not choice or choice == "Abort": + self.logger.info(f"Commit aborted by user at group {group}") + self._reset_group(files) + raise KeyboardInterrupt("User aborted") + + if choice == "Edit": + msg = questionary.text( + "Edit commit message:", + multiline=True, + default=msg, + ).ask() + if not msg: + self.logger.info( + f"Empty edit, commit cancelled for {group}" + ) + self._reset_group(files) + return True + self.commit_staged(msg) return True @@ -428,6 +499,9 @@ def run_commit_engine(**kwargs): try: engine = CommitEngine(**kwargs) engine.execute() + except KeyboardInterrupt: + logger.warning("Interrupted by user. Exiting...") + sys.exit(0) except Exception as e: logger.error(f"Commit engine failed: {e}", exc_info=True)