From 3df312a7fba2440b0c4dc94b84a3f7937d28534a Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Mon, 25 Aug 2025 14:47:43 +0545 Subject: [PATCH] feat(cli): add per-file commit generation option --- README.md | 16 ++++++++++++ devtools/commitgen/cli.py | 51 +++++++++++++++++++++++++++++++++------ devtools/commitgen/git.py | 16 ++++++++++++ devtools/shared/git.py | 17 +++++++++++++ 4 files changed, 93 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 2e0e8cd..1054a14 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,7 @@ devtools commit generate [OPTIONS] - `--sign` Sign commits with GPG - `--temperature FLOAT` AI temperature (0.0-1.0) - `--emoji/--no-emoji` Include emoji prefixes (default: disabled) + - `--smart-group/--per-file` Group multi-file changes into one commit (default: smart-group) Commit messages follow the conventional format (emojis optional): @@ -214,6 +215,21 @@ When emojis are enabled, these prefixes are used: - ✅ test: Adding tests - 🔧 chore: Maintenance +Per-file vs grouped commits: + +- Use `--smart-group` (default) to generate a single message that covers all staged changes. +- Use `--per-file` to generate a message per changed file. The CLI will create separate git commits, one per file, each with its own AI-generated message. + +Examples: + +```bash +# Single grouped message (default behavior) +devtools commit generate -c + +# Separate commits per file +devtools commit generate --per-file -c +``` + #### Changelog Generation ```bash diff --git a/devtools/commitgen/cli.py b/devtools/commitgen/cli.py index b6cf41a..1a7698e 100644 --- a/devtools/commitgen/cli.py +++ b/devtools/commitgen/cli.py @@ -48,6 +48,11 @@ def cli(): default=False, help="Include emoji prefixes in commit messages", ) +@click.option( + "--smart-group/--per-file", + default=True, + help="Smartly group multiple file changes into one commit (disable to commit per-file)", +) def generate( files: tuple, repo: str, @@ -58,6 +63,7 @@ def generate( sign: bool, temperature: float, emoji: bool, + smart_group: bool, ): """Generate commit messages for staged changes""" try: @@ -88,20 +94,51 @@ def generate( console.print("[yellow]No staged changes found.[/yellow]") return - # Generate commit message + # Generate commit message(s) task = progress.add_task("Generating commit message...", total=None) - commit_message = ai_service.generate_commit_message( - staged_changes, temperature - ) + messages_by_file = None + if smart_group: + commit_message = ai_service.generate_commit_message( + staged_changes, temperature + ) + else: + diffs_map = git_service.get_staged_changes_map( + list(files) if files else None + ) + messages_by_file = ai_service.generate_batch_messages( + diffs_map, temperature + ) + commit_message = None progress.update(task, completed=True) # Show preview and confirm - console.print("\n[bold]Generated commit message:[/bold]") - console.print(Panel(commit_message, title="Preview", border_style="blue")) + console.print("\n[bold]Generated commit message(s):[/bold]") + if messages_by_file: + for fp, msg in messages_by_file.items(): + console.print( + Panel(f"{fp}\n\n{msg}", title="Preview", border_style="blue") + ) + else: + console.print( + Panel(commit_message, title="Preview", border_style="blue") + ) if commit: task = progress.add_task("Committing changes...", total=None) - git_service.commit_changes(commit_message, sign=sign) + if messages_by_file: + # Commit each file separately with its message + # Ensure only that file is included in the commit by using pathspec + # Assumes files are already staged; git commit -- will include only that path + for fp, msg in messages_by_file.items(): + ok, out, err, code = git_service.commit_paths_verbose( + msg, [fp], sign=sign + ) + if not ok: + raise Exception( + f"Failed to commit {fp}: {err or out or code}" + ) + else: + git_service.commit_changes(commit_message, sign=sign) progress.update(task, completed=True) if push: diff --git a/devtools/commitgen/git.py b/devtools/commitgen/git.py index 2ec7d7c..8e0e9ce 100644 --- a/devtools/commitgen/git.py +++ b/devtools/commitgen/git.py @@ -29,6 +29,22 @@ class CommitGenGitService(GitService): f"Changes in {file}:\n{diff}" for file, diff in diffs.items() ) + def get_staged_changes_map( + self, files: Optional[List[str]] = None + ) -> dict[str, str]: + """Get staged changes as a mapping of file -> diff. + + Args: + files: Optional list of files to get changes for + + Returns: + Dict mapping file path to its staged diff + """ + diffs = self.get_all_diffs(staged=True) + if files: + diffs = {file: diff for file, diff in diffs.items() if file in files} + return diffs + def get_unstaged_changes(self) -> str: """Get unstaged changes as a diff.""" diffs = self.get_all_diffs(staged=False) diff --git a/devtools/shared/git.py b/devtools/shared/git.py index 5128f13..51ed707 100644 --- a/devtools/shared/git.py +++ b/devtools/shared/git.py @@ -94,6 +94,23 @@ class GitService: stdout, stderr, returncode = self._run_git_command(args) return returncode == 0, stdout, stderr, returncode + def commit_paths_verbose( + self, message: str, files: List[str], sign: bool = False + ) -> Tuple[bool, str, str, int]: + """Create a commit including only the specified files. + + Returns: + (ok, stdout, stderr, returncode) + """ + if not files: + return False, "", "no files specified", 1 + args = ["commit"] + if sign: + args.append("-S") + args.extend(["-m", message, "--"] + list(files)) + stdout, stderr, returncode = self._run_git_command(args) + return returncode == 0, stdout, stderr, returncode + def get_repo_name(self) -> str: """Get the repository name.""" stdout, _, _ = self._run_git_command(["rev-parse", "--show-toplevel"])