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:
4 files changed
+90
-5
No files matched your search
@@ -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
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user