feat(devgen/modules): add interactive commit review and smart file grouping

- New Features:
  - Added `check` parameter to enable interactive commit message review
  - Implemented user confirmation/editing/abort workflow using questionary
  - Added smart file grouping with configurable max_groups limit
  - Files are now merged into parent directories when group count exceeds limit

- Enhancements:
  - Improved `group_files()` method with hierarchical merging logic
  - Added graceful KeyboardInterrupt handling during user review
  - Enhanced logging for group merging operations
```
This commit is contained in:
sankalp committed 2025-12-25 09:35:33 +05:45
1 parent 525aebadf4
commit c121a7fa59
4 files changed
+90 -5

No files matched your search

+3
View File
@@ -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
```
+2 -2
View File
@@ -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.
+9 -1
View File
@@ -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,
)
+76 -2
View File
@@ -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)