Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc94b53d48 | ||
|
|
b09de402cc | ||
|
|
c121a7fa59 | ||
|
|
525aebadf4 | ||
|
|
1219c1ad81 | ||
|
|
cfa891dcde | ||
|
|
286c46d2b5 | ||
|
|
f2ff3327c3 | ||
|
|
37e5d94373 | ||
|
|
40c9b3ca57 | ||
|
|
4d309c313d | ||
|
|
ba747c727c | ||
|
|
3635887a85 | ||
|
|
a7507255c7 | ||
|
|
d373f95388 | ||
|
|
fe7da77ed6 | ||
|
|
407458149b |
No files matched your search
@@ -20,7 +20,7 @@
|
||||
## ⚡ Why DevGen?
|
||||
|
||||
**🧠 AI Brain**
|
||||
Semantic commit messages powered by Gemini, OpenAI, Claude, HuggingFace, and OpenRouter. It reads your diffs and understands your code.
|
||||
Semantic, context-aware commit messages powered by Gemini, OpenAI, Claude, HuggingFace, and OpenRouter. It reads your diffs, identifies project manifests, and understands your code.
|
||||
|
||||
**🚀 Battle Tested**
|
||||
Generates **Conventional Commits** and **Semantic Versioning** compliant changelogs that actually make sense.
|
||||
@@ -47,6 +47,9 @@ uv tool install devgen-cli
|
||||
|
||||
# Standard pip install
|
||||
pip install devgen-cli
|
||||
|
||||
# Enable Shell Completion (bash/zsh/fish)
|
||||
devgen --install-completion
|
||||
```
|
||||
|
||||
## 🚀 Quick Start
|
||||
@@ -80,8 +83,21 @@ 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
|
||||
```
|
||||
|
||||
### 🧠 Context Awareness
|
||||
DevGen isn't just looking at the code changes; it's looking at the big picture. It automatically detects and analyzes:
|
||||
- **Manifests**: `package.json`, `pyproject.toml`, `go.mod`, `Cargo.toml`, etc.
|
||||
- **Lock Files**: `uv.lock`, `package-lock.json`, `poetry.lock`, etc.
|
||||
|
||||
This allows the AI to understand *exactly* which library versions were updated, leading to hyper-accurate dependency commit messages.
|
||||
|
||||
### 📝 Changelogs & Release Notes
|
||||
|
||||
Turn your git history into beautiful, readable documentation.
|
||||
@@ -114,7 +130,22 @@ Your settings live in `~/.devgen.yaml`. You can tweak your AI provider, model, a
|
||||
| :--------- | :----------------------------------------------------------- |
|
||||
| `provider` | `gemini`, `openai`, `anthropic`, `huggingface`, `openrouter` |
|
||||
| `model` | Specific model name (e.g., `gemini-2.5-flash`, `gpt-4o`) |
|
||||
| `emoji` | Enable/disable gitmojis in commits (`true`/`false`) |
|
||||
| `emoji` | Enable/disable gitmojis in commits (`true`/`false`) |
|
||||
| `custom_template` | Custom Jinja2 template for commit messages. |
|
||||
|
||||
### 🎨 Custom Templates
|
||||
You can define your own commit message structure in `~/.devgen.yaml`. Use `devgen config info` to see available variables like `{{ group_name }}`, `{{ diff_text }}`, and `{{ context }}`.
|
||||
|
||||
Example:
|
||||
```yaml
|
||||
custom_template: |
|
||||
{{ group_name }}: {{ diff_text }}
|
||||
---
|
||||
Manifests: {{ context }}
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> Use `devgen config info` to see a full list of available variables and a template tip!
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# 📋 Project Roadmap & TODOs
|
||||
|
||||
This document outlines future improvements, features, and technical debt for `devgen`.
|
||||
|
||||
## 🚀 Priority: Short-Term (Reliability & DX)
|
||||
- [ ] **Full Test Suite**: Implement `pytest` for all modules, especially `commit_generator` and `utils`.
|
||||
- [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`.
|
||||
|
||||
## 🤖 AI & Intelligence
|
||||
- [x] **Custom Prompts**: Allow users to define their own Jinja2 templates for commits in the config file(~/.devgen.yaml).
|
||||
- [x] **Context-Aware Commits**: Send the manifest file (`package.json`, `pyproject.toml`) to the AI to provide better context for dependency changes.
|
||||
- [ ] **Token Usage Dashboard**: Add a `devgen config tokens` command to estimate or track AI token consumption.
|
||||
- [ ] **More Providers**: Add support for **Mistral**, **Cohere**, and **Groq** for faster/cheaper generations.
|
||||
|
||||
## 🛠️ Developer Experience (DX)
|
||||
- [ ] **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.
|
||||
- [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.
|
||||
- [ ] **Consistent Logging**: Standardize log output across all modules.
|
||||
- [ ] **API Documentation**: Use Sphinx or MkDocs to generate documentation for the core modules.
|
||||
|
||||
## 🌍 Ecosystem
|
||||
- [ ] **GitHub Action**: Create an official `devgen` GitHub Action for automated changelog updates on PR merge.
|
||||
- [ ] **VS Code Extension**: A simple extension to trigger `devgen commit` from the sidebar.
|
||||
+35
-1
@@ -1,5 +1,6 @@
|
||||
from typing import Annotated
|
||||
|
||||
import questionary
|
||||
import typer
|
||||
|
||||
from devgen.modules.commit_generator import run_commit_engine
|
||||
@@ -50,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(
|
||||
@@ -63,6 +71,7 @@ def run_commit(
|
||||
push=push,
|
||||
debug=debug,
|
||||
force_rebuild=force_rebuild,
|
||||
check=check,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
@@ -94,3 +103,28 @@ def validate() -> None:
|
||||
typer.echo(f"- {f}")
|
||||
else:
|
||||
typer.secho("[i] No staged files.", fg=typer.colors.RED)
|
||||
|
||||
|
||||
@app.command("undo")
|
||||
def undo_commit() -> None:
|
||||
"""Undoes the last commit but keeps changes staged."""
|
||||
from devgen.utils import run_git_command
|
||||
|
||||
try:
|
||||
# Check if there's at least one commit
|
||||
run_git_command(["git", "rev-parse", "HEAD"])
|
||||
except Exception:
|
||||
typer.secho("No commits found to undo.", fg=typer.colors.RED)
|
||||
return
|
||||
|
||||
if questionary.confirm(
|
||||
"Are you sure you want to undo the last commit? (Changes will remain staged)",
|
||||
default=False,
|
||||
).ask():
|
||||
try:
|
||||
run_git_command(["git", "reset", "--soft", "HEAD~1"])
|
||||
typer.secho(
|
||||
"Last commit undone. Changes are still staged.", fg=typer.colors.GREEN
|
||||
)
|
||||
except Exception as e:
|
||||
typer.secho(f"Failed to undo commit: {e}", fg=typer.colors.RED)
|
||||
@@ -167,3 +167,30 @@ def set_config() -> None:
|
||||
_save_config(config)
|
||||
typer.secho("\nConfiguration saved.", fg=typer.colors.GREEN)
|
||||
typer.echo(yaml.dump(new_config, default_flow_style=False))
|
||||
|
||||
|
||||
@app.command("info")
|
||||
def config_info() -> None:
|
||||
"""Show information about configuration options and templates."""
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
table = Table(title="Custom Template Variables", box=None)
|
||||
table.add_column("Variable", style="cyan")
|
||||
table.add_column("Description", style="white")
|
||||
|
||||
table.add_row("{{ group_name }}", "The folder name being committed (or 'root').")
|
||||
table.add_row("{{ diff_text }}", "The git diff of the changes.")
|
||||
table.add_row("{{ context }}", "Project context (manifest files content).")
|
||||
|
||||
console.print(table)
|
||||
console.print(
|
||||
"\n[bold]Tip:[/bold] If you hardcode emojis in your template, the AI will likely include them regardless of the 'emoji' setting.",
|
||||
style="yellow",
|
||||
)
|
||||
console.print("\n[bold]Example Template:[/bold]")
|
||||
console.print(
|
||||
"custom_template: |\n [type]: [desc]\n \n Diff: {{ diff_text }}\n",
|
||||
style="dim",
|
||||
)
|
||||
+1
-1
@@ -15,7 +15,7 @@ from devgen.cli.setup import app as setup_app
|
||||
app = typer.Typer(
|
||||
name="devgen",
|
||||
help="devgen-py: AI-Powered Git Commit & Release Automation.",
|
||||
add_completion=False,
|
||||
add_completion=True,
|
||||
no_args_is_help=True,
|
||||
rich_markup_mode="markdown",
|
||||
)
|
||||
|
||||
@@ -120,7 +120,7 @@ class ChangelogGenerator:
|
||||
) -> str:
|
||||
"""Generates markdown changelog from parsed commits."""
|
||||
date_str = datetime.now().strftime("%Y-%m-%d")
|
||||
md = [f"# {version} ({date_str})\n"]
|
||||
md = [f"## {version} ({date_str})\n"]
|
||||
|
||||
# Order: Breaking, Features, Fixes, Docs, Others
|
||||
order = [
|
||||
@@ -163,14 +163,19 @@ class ChangelogGenerator:
|
||||
|
||||
if output_file:
|
||||
path = Path(output_file)
|
||||
# If file exists, prepend? For now, just overwrite or append logic could be complex.
|
||||
# Let's implement prepend logic if file exists.
|
||||
if path.exists():
|
||||
old_content = path.read_text(encoding="utf-8")
|
||||
new_content = old_content + "\n\n" + md_content
|
||||
# Prepend the new content, assume # CHANGELOG is at the top or needs to be
|
||||
if old_content.strip().startswith("# CHANGELOG"):
|
||||
lines = old_content.split("\n", 1)
|
||||
header = lines[0]
|
||||
rest = lines[1] if len(lines) > 1 else ""
|
||||
new_content = f"{header}\n\n{md_content}\n{rest.lstrip()}"
|
||||
else:
|
||||
new_content = f"# CHANGELOG\n\n{md_content}\n\n{old_content}"
|
||||
path.write_text(new_content, encoding="utf-8")
|
||||
else:
|
||||
path.write_text(md_content, encoding="utf-8")
|
||||
path.write_text(f"# CHANGELOG\n\n{md_content}", encoding="utf-8")
|
||||
|
||||
self.logger.info(f"Changelog written to {output_file}")
|
||||
print(f" Changelog updated: {output_file}")
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import sys
|
||||
import questionary
|
||||
import subprocess
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
@@ -13,6 +15,7 @@ from devgen.utils import (
|
||||
load_template_env,
|
||||
run_git_command,
|
||||
sanitize_ai_commit_message,
|
||||
render_custom_template,
|
||||
)
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
@@ -26,6 +29,19 @@ 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.
|
||||
@@ -38,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,
|
||||
@@ -47,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(
|
||||
@@ -72,6 +90,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,27 +101,93 @@ 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)
|
||||
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."""
|
||||
"""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)
|
||||
@@ -165,9 +250,52 @@ class CommitEngine:
|
||||
f"Git command failed: {' '.join(e.cmd)}\nError: {e.stderr.strip()}"
|
||||
)
|
||||
self.logger.error(msg)
|
||||
# Check for "no upstream branch" specifically to give a hint?
|
||||
if "no upstream branch" in msg.lower():
|
||||
self.console.print(
|
||||
"[warning]No upstream branch. Skipping push.[/warning]"
|
||||
)
|
||||
return
|
||||
raise CommitEngineError(msg) from e
|
||||
self.console.print("[bold green]Push successful.[/bold green]")
|
||||
|
||||
def _get_manifest_context(self) -> str:
|
||||
"""Finds and reads manifest files to provide context."""
|
||||
manifests = [
|
||||
"pyproject.toml",
|
||||
"package.json",
|
||||
"go.mod",
|
||||
"Cargo.toml",
|
||||
"Gemfile",
|
||||
"requirements.txt",
|
||||
"uv.lock",
|
||||
"package-lock.json",
|
||||
"pnpm-lock.yaml",
|
||||
"yarn.lock",
|
||||
"composer.lock",
|
||||
"Gemfile.lock",
|
||||
"poetry.lock",
|
||||
]
|
||||
found = []
|
||||
for m in manifests:
|
||||
path = Path(m)
|
||||
if path.exists():
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
# Take only first 100 lines to avoid token bloat
|
||||
lines = content.splitlines()
|
||||
summary = "\n".join(lines[:100])
|
||||
if len(lines) > 100:
|
||||
summary += "\n... (truncated)"
|
||||
found.append(f"File: {m}\n---\n{summary}\n---")
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not found:
|
||||
return ""
|
||||
|
||||
return "\n\n### Project Context (Manifests)\n" + "\n".join(found)
|
||||
|
||||
def generate_message(self, group: str, diff: str, cache: Dict[str, str]) -> str:
|
||||
"""Generates a commit message using AI or cache."""
|
||||
if not self.force_rebuild and group in cache:
|
||||
@@ -181,9 +309,33 @@ class CommitEngine:
|
||||
model = self.kwargs.get("model") or self.config.get("model") or self.model
|
||||
api_key = self.kwargs.get("api_key") or self.config.get("api_key")
|
||||
use_emoji = self.config.get("emoji", True)
|
||||
custom_template = self.config.get("custom_template")
|
||||
|
||||
template = self.template_env.get_template("commit_message.j2")
|
||||
prompt = template.render(group_name=group, diff_text=diff, use_emoji=use_emoji)
|
||||
manifest_context = self._get_manifest_context()
|
||||
if manifest_context:
|
||||
self.logger.info("Including manifest context in prompt")
|
||||
|
||||
if custom_template:
|
||||
self.logger.info("Using custom template from config")
|
||||
prompt = render_custom_template(
|
||||
custom_template,
|
||||
group_name=group,
|
||||
diff_text=diff,
|
||||
use_emoji=use_emoji,
|
||||
context=manifest_context,
|
||||
)
|
||||
else:
|
||||
template = self.template_env.get_template("commit_message.j2")
|
||||
prompt = template.render(
|
||||
group_name=group,
|
||||
diff_text=diff,
|
||||
use_emoji=use_emoji,
|
||||
context=manifest_context,
|
||||
)
|
||||
|
||||
# Automatically append emoji instruction based on global setting
|
||||
emoji_instr = "Use emojis (🚀, 🐛)." if use_emoji else "No emojis."
|
||||
prompt = f"{prompt.strip()}\n\n- {emoji_instr}"
|
||||
|
||||
with self.console.status("[bold blue]Generating commit message...[/bold blue]"):
|
||||
raw = generate_with_ai(
|
||||
@@ -239,20 +391,66 @@ 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:
|
||||
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
|
||||
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."""
|
||||
@@ -301,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)
|
||||
|
||||
|
||||
@@ -8,9 +8,7 @@ class LicenseGenerator:
|
||||
"""Generates license files from templates."""
|
||||
|
||||
def __init__(self):
|
||||
self.templates_dir = (
|
||||
Path(__file__).parent.parent / "prompt_templates" / "licenses"
|
||||
)
|
||||
self.templates_dir = Path(__file__).parent.parent / "templates" / "licenses"
|
||||
|
||||
def list_licenses(self) -> List[Dict[str, str]]:
|
||||
"""Lists available license templates."""
|
||||
|
||||
@@ -39,6 +39,9 @@ class OpenaiProvider:
|
||||
# Add error handling if the client fails to initialize
|
||||
raise RuntimeError(f"Failed to initialize OpenAI client: {e}")
|
||||
|
||||
# Remove debug from kwargs if present
|
||||
kwargs.pop("debug", None)
|
||||
|
||||
# 2. Use the modern API syntax: client.chat.completions.create
|
||||
response = client.chat.completions.create(
|
||||
model=model or self.DEFAULT_MODEL,
|
||||
|
||||
@@ -19,6 +19,9 @@ class OpenrouterProvider:
|
||||
api_key=api_key,
|
||||
)
|
||||
|
||||
# Remove debug from kwargs if present
|
||||
kwargs.pop("debug", None)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
Analyze the following git diff for `{{ group_name }}` and generate a semantic commit message.
|
||||
|
||||
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.
|
||||
{% if use_emoji %}
|
||||
4. Use appropriate emojis for the commit type and sections (e.g., 🚀 for features, 🐛 for bugs).
|
||||
{% else %}
|
||||
4. Do NOT use any emojis in the commit message.
|
||||
{% endif %}
|
||||
Output only the commit message, don't include explanations or extra text.
|
||||
|
||||
Here is the git diff for `{{ group_name }}`:
|
||||
Guidelines:
|
||||
- Title line: `<type>(<scope>): <summary>` (scope is `{{ group_name }}` or `root`).
|
||||
- Optional body: A short summary of why.
|
||||
- Details: Bullet points for `New Features`, `Enhancements`, `Bug Fixes`.
|
||||
- Output ONLY the message itself, without any labels like "Title:" or "Format:".
|
||||
|
||||
Diff for `{{ group_name }}`:
|
||||
{{ diff_text }}
|
||||
{{ context }}
|
||||
+56
-10
@@ -31,25 +31,63 @@ 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:
|
||||
# First, strip bolding if it wraps the whole line or parts of it
|
||||
# This is simpler than handling it in the regex
|
||||
candidate = stripped.replace("**", "").strip()
|
||||
|
||||
# Clean the line by removing labels like "1. Title:", "### Message:", etc.
|
||||
clean_line = re.sub(
|
||||
r"^(#+\s+)?(\d+\.?|\*|-)?\s*(Title|Commit Message|Message):\s*",
|
||||
"",
|
||||
candidate,
|
||||
flags=re.IGNORECASE,
|
||||
).strip()
|
||||
|
||||
if header_pattern.match(clean_line):
|
||||
found_header = True
|
||||
cleaned_lines.append(clean_line)
|
||||
else:
|
||||
# If we hit another header or a known separator, we stop
|
||||
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(
|
||||
@@ -174,6 +212,13 @@ def load_template_env(sub_dir: str) -> Environment:
|
||||
return Environment(loader=FileSystemLoader(template_dir))
|
||||
|
||||
|
||||
def render_custom_template(template_str: str, **context) -> str:
|
||||
"""Renders a Jinja2 template from a string."""
|
||||
env = Environment()
|
||||
template = env.from_string(template_str)
|
||||
return template.render(**context)
|
||||
|
||||
|
||||
def load_config() -> Dict[str, Any]:
|
||||
config_path = Path.home() / ".devgen.yaml"
|
||||
|
||||
@@ -183,6 +228,7 @@ def load_config() -> Dict[str, Any]:
|
||||
"model": "gemini-2.5-flash",
|
||||
"api_key": "",
|
||||
"emoji": True,
|
||||
"max_groups": 5,
|
||||
}
|
||||
try:
|
||||
with config_path.open("w", encoding="utf-8") as f:
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "devgen-cli"
|
||||
version = "0.2.2"
|
||||
version = "0.2.4"
|
||||
description = "A collection of developer tools"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -6,7 +6,7 @@ from setuptools import find_packages, setup
|
||||
|
||||
setup(
|
||||
name="devgen-cli",
|
||||
version="0.2.2",
|
||||
version="0.2.4",
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
install_requires=[
|
||||
|
||||
Reference in new issue
Block a user