Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc94b53d48 | ||
|
|
b09de402cc | ||
|
|
c121a7fa59 | ||
|
|
525aebadf4 | ||
|
|
1219c1ad81 | ||
|
|
cfa891dcde | ||
|
|
286c46d2b5 | ||
|
|
f2ff3327c3 | ||
|
|
37e5d94373 | ||
|
|
40c9b3ca57 | ||
|
|
4d309c313d | ||
|
|
ba747c727c | ||
|
|
3635887a85 | ||
|
|
a7507255c7 | ||
|
|
d373f95388 |
No files matched your search
@@ -20,7 +20,7 @@
|
|||||||
## ⚡ Why DevGen?
|
## ⚡ Why DevGen?
|
||||||
|
|
||||||
**🧠 AI Brain**
|
**🧠 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**
|
**🚀 Battle Tested**
|
||||||
Generates **Conventional Commits** and **Semantic Versioning** compliant changelogs that actually make sense.
|
Generates **Conventional Commits** and **Semantic Versioning** compliant changelogs that actually make sense.
|
||||||
@@ -47,6 +47,9 @@ uv tool install devgen-cli
|
|||||||
|
|
||||||
# Standard pip install
|
# Standard pip install
|
||||||
pip install devgen-cli
|
pip install devgen-cli
|
||||||
|
|
||||||
|
# Enable Shell Completion (bash/zsh/fish)
|
||||||
|
devgen --install-completion
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
@@ -80,8 +83,21 @@ devgen commit run --dry-run
|
|||||||
|
|
||||||
# Commit and push in one go
|
# Commit and push in one go
|
||||||
devgen commit run --push
|
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
|
### 📝 Changelogs & Release Notes
|
||||||
|
|
||||||
Turn your git history into beautiful, readable documentation.
|
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` |
|
| `provider` | `gemini`, `openai`, `anthropic`, `huggingface`, `openrouter` |
|
||||||
| `model` | Specific model name (e.g., `gemini-2.5-flash`, `gpt-4o`) |
|
| `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
|
## 🤝 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
|
from typing import Annotated
|
||||||
|
|
||||||
|
import questionary
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
from devgen.modules.commit_generator import run_commit_engine
|
from devgen.modules.commit_generator import run_commit_engine
|
||||||
@@ -50,12 +51,19 @@ def run_commit(
|
|||||||
help="Force regeneration of commit messages.",
|
help="Force regeneration of commit messages.",
|
||||||
),
|
),
|
||||||
] = False,
|
] = False,
|
||||||
|
check: Annotated[
|
||||||
|
bool,
|
||||||
|
typer.Option(
|
||||||
|
"--check",
|
||||||
|
help="Review/edit commit message before committing.",
|
||||||
|
),
|
||||||
|
] = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
log_file = get_main_log_path()
|
log_file = get_main_log_path()
|
||||||
logger = configure_logger("devgen.cli.commit", log_file, console=debug)
|
logger = configure_logger("devgen.cli.commit", log_file, console=debug)
|
||||||
logger.info(f"Log file: {log_file}")
|
logger.info(f"Log file: {log_file}")
|
||||||
logger.info(
|
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(
|
run_commit_engine(
|
||||||
@@ -63,6 +71,7 @@ def run_commit(
|
|||||||
push=push,
|
push=push,
|
||||||
debug=debug,
|
debug=debug,
|
||||||
force_rebuild=force_rebuild,
|
force_rebuild=force_rebuild,
|
||||||
|
check=check,
|
||||||
logger=logger,
|
logger=logger,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -94,3 +103,28 @@ def validate() -> None:
|
|||||||
typer.echo(f"- {f}")
|
typer.echo(f"- {f}")
|
||||||
else:
|
else:
|
||||||
typer.secho("[i] No staged files.", fg=typer.colors.RED)
|
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)
|
_save_config(config)
|
||||||
typer.secho("\nConfiguration saved.", fg=typer.colors.GREEN)
|
typer.secho("\nConfiguration saved.", fg=typer.colors.GREEN)
|
||||||
typer.echo(yaml.dump(new_config, default_flow_style=False))
|
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(
|
app = typer.Typer(
|
||||||
name="devgen",
|
name="devgen",
|
||||||
help="devgen-py: AI-Powered Git Commit & Release Automation.",
|
help="devgen-py: AI-Powered Git Commit & Release Automation.",
|
||||||
add_completion=False,
|
add_completion=True,
|
||||||
no_args_is_help=True,
|
no_args_is_help=True,
|
||||||
rich_markup_mode="markdown",
|
rich_markup_mode="markdown",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ class ChangelogGenerator:
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""Generates markdown changelog from parsed commits."""
|
"""Generates markdown changelog from parsed commits."""
|
||||||
date_str = datetime.now().strftime("%Y-%m-%d")
|
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: Breaking, Features, Fixes, Docs, Others
|
||||||
order = [
|
order = [
|
||||||
@@ -163,14 +163,19 @@ class ChangelogGenerator:
|
|||||||
|
|
||||||
if output_file:
|
if output_file:
|
||||||
path = Path(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():
|
if path.exists():
|
||||||
old_content = path.read_text(encoding="utf-8")
|
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")
|
path.write_text(new_content, encoding="utf-8")
|
||||||
else:
|
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}")
|
self.logger.info(f"Changelog written to {output_file}")
|
||||||
print(f" Changelog updated: {output_file}")
|
print(f" Changelog updated: {output_file}")
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import sys
|
||||||
|
import questionary
|
||||||
import subprocess
|
import subprocess
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -13,6 +15,7 @@ from devgen.utils import (
|
|||||||
load_template_env,
|
load_template_env,
|
||||||
run_git_command,
|
run_git_command,
|
||||||
sanitize_ai_commit_message,
|
sanitize_ai_commit_message,
|
||||||
|
render_custom_template,
|
||||||
)
|
)
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
@@ -26,6 +29,19 @@ class CommitEngineError(Exception):
|
|||||||
pass
|
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:
|
class CommitEngine:
|
||||||
"""
|
"""
|
||||||
Engine for generating AI-powered commit messages.
|
Engine for generating AI-powered commit messages.
|
||||||
@@ -38,6 +54,7 @@ class CommitEngine:
|
|||||||
push: bool = False,
|
push: bool = False,
|
||||||
debug: bool = False,
|
debug: bool = False,
|
||||||
force_rebuild: bool = False,
|
force_rebuild: bool = False,
|
||||||
|
check: bool = False,
|
||||||
provider: str = "gemini",
|
provider: str = "gemini",
|
||||||
model: str = "gemini-2.5-flash",
|
model: str = "gemini-2.5-flash",
|
||||||
logger: Any | None = None,
|
logger: Any | None = None,
|
||||||
@@ -47,6 +64,7 @@ class CommitEngine:
|
|||||||
self.push = push
|
self.push = push
|
||||||
self.debug = debug
|
self.debug = debug
|
||||||
self.force_rebuild = force_rebuild
|
self.force_rebuild = force_rebuild
|
||||||
|
self.check = check
|
||||||
self.provider = provider
|
self.provider = provider
|
||||||
self.model = model
|
self.model = model
|
||||||
self.logger = logger or configure_logger(
|
self.logger = logger or configure_logger(
|
||||||
@@ -72,6 +90,7 @@ class CommitEngine:
|
|||||||
def detect_changes(self) -> List[str]:
|
def detect_changes(self) -> List[str]:
|
||||||
"""Detects changed, deleted, or untracked files."""
|
"""Detects changed, deleted, or untracked files."""
|
||||||
try:
|
try:
|
||||||
|
# Get modified, deleted and untracked files
|
||||||
out = run_git_command(
|
out = run_git_command(
|
||||||
[
|
[
|
||||||
"git",
|
"git",
|
||||||
@@ -82,27 +101,93 @@ class CommitEngine:
|
|||||||
"--exclude-standard",
|
"--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:
|
except subprocess.CalledProcessError as e:
|
||||||
msg = f"Git command failed: {' '.join(e.cmd)}\nError: {e.stderr.strip()}"
|
msg = f"Git command failed: {' '.join(e.cmd)}\nError: {e.stderr.strip()}"
|
||||||
self.logger.error(msg)
|
self.logger.error(msg)
|
||||||
raise CommitEngineError(msg) from e
|
raise CommitEngineError(msg) from e
|
||||||
|
|
||||||
def group_files(self, files: List[str]) -> Dict[str, List[str]]:
|
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)
|
groups = defaultdict(list)
|
||||||
for f in files:
|
for f in files:
|
||||||
parent = str(Path(f).parent)
|
parent = str(Path(f).parent)
|
||||||
key = "root" if parent == "." else parent
|
key = "root" if parent == "." else parent
|
||||||
groups[key].append(f)
|
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:
|
def generate_diff(self, files: List[str]) -> str:
|
||||||
"""Generates diff for specific files."""
|
"""Generates diff for specific files, with truncation for token optimization."""
|
||||||
try:
|
try:
|
||||||
return run_git_command(
|
# Filter out very large metadata files that don't need full diffs
|
||||||
["git", "--no-pager", "diff", "--staged", "--", *files]
|
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:
|
except subprocess.CalledProcessError as e:
|
||||||
msg = f"Git command failed: {' '.join(e.cmd)}\nError: {e.stderr.strip()}"
|
msg = f"Git command failed: {' '.join(e.cmd)}\nError: {e.stderr.strip()}"
|
||||||
self.logger.error(msg)
|
self.logger.error(msg)
|
||||||
@@ -165,9 +250,52 @@ class CommitEngine:
|
|||||||
f"Git command failed: {' '.join(e.cmd)}\nError: {e.stderr.strip()}"
|
f"Git command failed: {' '.join(e.cmd)}\nError: {e.stderr.strip()}"
|
||||||
)
|
)
|
||||||
self.logger.error(msg)
|
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
|
raise CommitEngineError(msg) from e
|
||||||
self.console.print("[bold green]Push successful.[/bold green]")
|
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:
|
def generate_message(self, group: str, diff: str, cache: Dict[str, str]) -> str:
|
||||||
"""Generates a commit message using AI or cache."""
|
"""Generates a commit message using AI or cache."""
|
||||||
if not self.force_rebuild and group in 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
|
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")
|
api_key = self.kwargs.get("api_key") or self.config.get("api_key")
|
||||||
use_emoji = self.config.get("emoji", True)
|
use_emoji = self.config.get("emoji", True)
|
||||||
|
custom_template = self.config.get("custom_template")
|
||||||
|
|
||||||
template = self.template_env.get_template("commit_message.j2")
|
manifest_context = self._get_manifest_context()
|
||||||
prompt = template.render(group_name=group, diff_text=diff, use_emoji=use_emoji)
|
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]"):
|
with self.console.status("[bold blue]Generating commit message...[/bold blue]"):
|
||||||
raw = generate_with_ai(
|
raw = generate_with_ai(
|
||||||
@@ -239,20 +391,66 @@ class CommitEngine:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
msg = self.generate_message(group, diff, cache)
|
msg = self.generate_message(group, diff, cache)
|
||||||
if not msg:
|
try:
|
||||||
self.logger.error(f"Empty message for {group}")
|
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
|
return False
|
||||||
|
|
||||||
if self.dry_run:
|
def _reset_group(self, files: List[str]):
|
||||||
self._log_dry_run(group, msg)
|
"""Unstages files for a group."""
|
||||||
try:
|
try:
|
||||||
run_git_command(["git", "reset", "HEAD", "--", *files])
|
run_git_command(["git", "reset", "HEAD", "--", *files])
|
||||||
except subprocess.CalledProcessError:
|
except subprocess.CalledProcessError:
|
||||||
pass
|
pass # Ignore reset errors
|
||||||
else:
|
|
||||||
self.commit_staged(msg)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def execute(self):
|
def execute(self):
|
||||||
"""Main execution method."""
|
"""Main execution method."""
|
||||||
@@ -301,6 +499,9 @@ def run_commit_engine(**kwargs):
|
|||||||
try:
|
try:
|
||||||
engine = CommitEngine(**kwargs)
|
engine = CommitEngine(**kwargs)
|
||||||
engine.execute()
|
engine.execute()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.warning("Interrupted by user. Exiting...")
|
||||||
|
sys.exit(0)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Commit engine failed: {e}", exc_info=True)
|
logger.error(f"Commit engine failed: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ class LicenseGenerator:
|
|||||||
"""Generates license files from templates."""
|
"""Generates license files from templates."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.templates_dir = (
|
self.templates_dir = Path(__file__).parent.parent / "templates" / "licenses"
|
||||||
Path(__file__).parent.parent / "prompt_templates" / "licenses"
|
|
||||||
)
|
|
||||||
|
|
||||||
def list_licenses(self) -> List[Dict[str, str]]:
|
def list_licenses(self) -> List[Dict[str, str]]:
|
||||||
"""Lists available license templates."""
|
"""Lists available license templates."""
|
||||||
|
|||||||
@@ -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:
|
Guidelines:
|
||||||
1. A summary title in semantic release format (type(scope): summary) — scope can be the directory or 'root'.
|
- Title line: `<type>(<scope>): <summary>` (scope is `{{ group_name }}` or `root`).
|
||||||
2. A short summary paragraph if needed.
|
- Optional body: A short summary of why.
|
||||||
3. Bullet points listing the main changes, grouped as 'New Features', 'Enhancements & Refinements', and 'Bug Fixes' if relevant. Use bold for feature/section names.
|
- Details: Bullet points for `New Features`, `Enhancements`, `Bug Fixes`.
|
||||||
{% if use_emoji %}
|
- Output ONLY the message itself, without any labels like "Title:" or "Format:".
|
||||||
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 }}`:
|
|
||||||
|
|
||||||
|
Diff for `{{ group_name }}`:
|
||||||
{{ diff_text }}
|
{{ 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:
|
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")
|
lines = raw_text.strip().split("\n")
|
||||||
cleaned_lines = []
|
|
||||||
in_block = False
|
# Regex for conventional commit header (including optional bolding)
|
||||||
# Regex for conventional commit header
|
# Examples:
|
||||||
|
# - feat(root): summary
|
||||||
|
# - **fix: bug fix**
|
||||||
|
# - chore(deps)!: breaking change
|
||||||
header_pattern = re.compile(
|
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:
|
for line in lines:
|
||||||
stripped = line.strip()
|
stripped = line.strip()
|
||||||
if in_block:
|
if not found_header:
|
||||||
if header_pattern.match(stripped) or "**Sponsor**" in line:
|
# 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
|
break
|
||||||
cleaned_lines.append(line)
|
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(
|
def parse_markdown_sections(
|
||||||
@@ -174,6 +212,13 @@ def load_template_env(sub_dir: str) -> Environment:
|
|||||||
return Environment(loader=FileSystemLoader(template_dir))
|
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]:
|
def load_config() -> Dict[str, Any]:
|
||||||
config_path = Path.home() / ".devgen.yaml"
|
config_path = Path.home() / ".devgen.yaml"
|
||||||
|
|
||||||
@@ -183,6 +228,7 @@ def load_config() -> Dict[str, Any]:
|
|||||||
"model": "gemini-2.5-flash",
|
"model": "gemini-2.5-flash",
|
||||||
"api_key": "",
|
"api_key": "",
|
||||||
"emoji": True,
|
"emoji": True,
|
||||||
|
"max_groups": 5,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
with config_path.open("w", encoding="utf-8") as f:
|
with config_path.open("w", encoding="utf-8") as f:
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "devgen-cli"
|
name = "devgen-cli"
|
||||||
version = "0.2.3"
|
version = "0.2.4"
|
||||||
description = "A collection of developer tools"
|
description = "A collection of developer tools"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from setuptools import find_packages, setup
|
|||||||
|
|
||||||
setup(
|
setup(
|
||||||
name="devgen-cli",
|
name="devgen-cli",
|
||||||
version="0.2.3",
|
version="0.2.4",
|
||||||
packages=find_packages(),
|
packages=find_packages(),
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
install_requires=[
|
install_requires=[
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "devgen-cli"
|
name = "devgen-cli"
|
||||||
version = "0.2.2"
|
version = "0.2.4"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anthropic" },
|
{ name = "anthropic" },
|
||||||
|
|||||||
Reference in new issue
Block a user