Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc94b53d48 | ||
|
|
b09de402cc | ||
|
|
c121a7fa59 | ||
|
|
525aebadf4 | ||
|
|
1219c1ad81 | ||
|
|
cfa891dcde | ||
|
|
286c46d2b5 | ||
|
|
f2ff3327c3 | ||
|
|
37e5d94373 | ||
|
|
40c9b3ca57 | ||
|
|
4d309c313d | ||
|
|
ba747c727c | ||
|
|
3635887a85 | ||
|
|
a7507255c7 | ||
|
|
d373f95388 | ||
|
|
fe7da77ed6 | ||
|
|
407458149b | ||
|
|
e9a45cf140 | ||
|
|
70e150611b | ||
|
|
f85da0461f | ||
|
|
7c15e2183b | ||
|
|
a2f3657269 | ||
|
|
c50f8a4b89 | ||
|
|
a488d5e4ac | ||
|
|
48836797b9 | ||
|
|
09151decad | ||
|
|
24dc7f57b4 | ||
|
|
449a955d15 | ||
|
|
87ed8bf878 | ||
|
|
6e4b177fe3 | ||
|
|
8db30f14bc | ||
|
|
d502849daf | ||
|
|
f3c68aaf29 | ||
|
|
7f6b247b40 | ||
|
|
6e7525241d | ||
|
|
b7faa9e17c | ||
|
|
97a0b34f9c | ||
|
|
b975ffc5e3 |
No files matched your search
@@ -12,11 +12,11 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
|
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.10'
|
||||||
cache: 'pip'
|
cache: 'pip'
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -47,3 +47,4 @@ logs/
|
|||||||
devgen.egg-info/
|
devgen.egg-info/
|
||||||
build/
|
build/
|
||||||
devgen_utils.egg-info/
|
devgen_utils.egg-info/
|
||||||
|
devgen_cli.egg-info/
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
graft devgen/templates
|
||||||
|
include README.md
|
||||||
|
include LICENSE
|
||||||
@@ -2,248 +2,170 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<img src="https://img.shields.io/pypi/v/devgen?color=blue&label=PyPI&logo=pypi&logoColor=white" alt="PyPI">
|
> **Your AI Powerhouse for Git & Project Management.**
|
||||||
|
> Stop wasting time on repetitive tasks. Automate your commits, changelogs, and project essentials with a single CLI.
|
||||||
|
>
|
||||||
|
> PyPI didn't allow the original name, so you'll find it as **devgen-cli** on PyPI
|
||||||
|
|
||||||
|
<a href="https://pypi.org/project/devgen-cli"><img src="https://img.shields.io/pypi/v/devgen-cli?color=blue&label=PyPI&logo=pypi&logoColor=white" alt="PyPI"></a>
|
||||||
<img src="https://img.shields.io/badge/Python-3.10%2B-3776AB?logo=python&logoColor=white" alt="Python">
|
<img src="https://img.shields.io/badge/Python-3.10%2B-3776AB?logo=python&logoColor=white" alt="Python">
|
||||||
<a href="https://github.com/S4NKALP/devgen/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-GPL--3.0--or--later-blue.svg" alt="License"></a>
|
<a href="https://github.com/S4NKALP/DevGen/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-GPL--3.0--or--later-blue.svg" alt="License"></a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
**DevGen** is an AI-powered developer toolkit that automates common Git and project management tasks. Generate semantic commit messages, changelogs, release notes, `.gitignore` files, and licenses—all from a single unified CLI.
|
---
|
||||||
|
|
||||||
## ✨ Features
|
**DevGen** isn't just another CLI tool it's your development workflow on steroids. By leveraging state of the art AI models, DevGen turns tedious manual tasks into one click magic. From semantic commit messages to comprehensive release notes, DevGen handles the boring stuff so you can focus on building.
|
||||||
|
|
||||||
- 🚀 **AI-Powered Commit Messages** - Automatically generate semantic commit messages from your staged changes using AI
|
## ⚡ Why DevGen?
|
||||||
- 📝 **Changelog Generation** - Create changelogs from git history using conventional commits
|
|
||||||
- 🚀 **Release Notes** - Generate clean, emoji-enhanced release notes for your releases
|
**🧠 AI Brain**
|
||||||
- 🙈 **Gitignore Templates** - Generate `.gitignore` files from GitHub's official templates
|
Semantic, context-aware commit messages powered by Gemini, OpenAI, Claude, HuggingFace, and OpenRouter. It reads your diffs, identifies project manifests, and understands your code.
|
||||||
- 📄 **License Generation** - Quickly generate open source licenses with interactive setup
|
|
||||||
- ⚙️ **Multi-Provider AI Support** - Works with Gemini, OpenAI, Anthropic, HuggingFace, and OpenRouter
|
**🚀 Battle Tested**
|
||||||
|
Generates **Conventional Commits** and **Semantic Versioning** compliant changelogs that actually make sense.
|
||||||
|
|
||||||
|
**⚡ Lightning Fast**
|
||||||
|
Smart caching and async operations mean you never wait longer than necessary.
|
||||||
|
|
||||||
|
**Project Essentials**
|
||||||
|
Quickly add `.gitignore` and license files to your existing projects. Access cached templates instantly, even without internet.
|
||||||
|
|
||||||
|
**🛠️ Zero Friction**
|
||||||
|
Interactive setup gets you running in seconds.
|
||||||
|
|
||||||
## 📦 Installation
|
## 📦 Installation
|
||||||
|
|
||||||
|
Get started in seconds.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Stable release from PyPI
|
# Recommended: Install via pipx for an isolated environment
|
||||||
pip install devgen
|
pipx install devgen-cli
|
||||||
|
|
||||||
# Using pipx (recommended for isolated CLI)
|
# Or use uv for blazing speed
|
||||||
pipx install devgen
|
uv tool install devgen-cli
|
||||||
|
|
||||||
# Using uv (fast Python package manager)
|
# Standard pip install
|
||||||
uv tool install devgen
|
pip install devgen-cli
|
||||||
|
|
||||||
|
# Enable Shell Completion (bash/zsh/fish)
|
||||||
|
devgen --install-completion
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
### 1. Initial Setup
|
**1. Initialize & Configure**
|
||||||
|
Tell DevGen which AI provider to use.
|
||||||
Configure your AI provider and API key:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
devgen setup config
|
devgen setup config
|
||||||
```
|
```
|
||||||
|
|
||||||
This will create a configuration file at `~/.devgen.yaml` with your preferred AI provider, model, and API key.
|
**2. Stage & Commit**
|
||||||
|
Stage your files and let AI write the message.
|
||||||
### 2. Generate Commit Messages
|
|
||||||
|
|
||||||
Stage your changes and let DevGen generate semantic commit messages:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Dry run to preview commit messages
|
git add .
|
||||||
|
devgen commit run
|
||||||
|
```
|
||||||
|
|
||||||
|
_Boom. Done._
|
||||||
|
|
||||||
|
## 💡 Feature Deep Dive
|
||||||
|
|
||||||
|
### 🤖 AI Powered Commits
|
||||||
|
|
||||||
|
Stop writing "fix bug" or "wip". DevGen analyzes your staged changes, groups them by component, and generates meaningful, semantic commit messages.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Preview what DevGen will generate
|
||||||
devgen commit run --dry-run
|
devgen commit run --dry-run
|
||||||
|
|
||||||
# Actually commit with AI-generated messages
|
# Commit and push in one go
|
||||||
devgen commit run
|
|
||||||
|
|
||||||
# Commit and automatically push
|
|
||||||
devgen commit run --push
|
devgen commit run --push
|
||||||
|
|
||||||
# Force regeneration of commit messages
|
# Review and edit AI messages before committing
|
||||||
devgen commit run --force-rebuild
|
devgen commit run --check
|
||||||
|
|
||||||
|
# Made a mistake? Undo the last AI commit and keep changes staged
|
||||||
|
devgen commit undo
|
||||||
```
|
```
|
||||||
|
|
||||||
**How it works:**
|
### 🧠 Context Awareness
|
||||||
- DevGen detects all staged and unstaged changes
|
DevGen isn't just looking at the code changes; it's looking at the big picture. It automatically detects and analyzes:
|
||||||
- Groups files by directory
|
- **Manifests**: `package.json`, `pyproject.toml`, `go.mod`, `Cargo.toml`, etc.
|
||||||
- Generates semantic commit messages for each group using AI
|
- **Lock Files**: `uv.lock`, `package-lock.json`, `poetry.lock`, etc.
|
||||||
- Follows [Conventional Commits](https://www.conventionalcommits.org/) format
|
|
||||||
|
|
||||||
**Additional commit commands:**
|
This allows the AI to understand *exactly* which library versions were updated, leading to hyper-accurate dependency commit messages.
|
||||||
```bash
|
|
||||||
# View cached commit messages
|
|
||||||
devgen commit list-cached
|
|
||||||
|
|
||||||
# Clear the commit cache
|
### 📝 Changelogs & Release Notes
|
||||||
devgen commit clear-cache
|
|
||||||
|
|
||||||
# Check what files are staged
|
Turn your git history into beautiful, readable documentation.
|
||||||
devgen commit validate
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Generate Changelogs
|
|
||||||
|
|
||||||
Create changelogs from your git history:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Generate changelog from last tag to HEAD
|
# Generate a changelog from the last tag
|
||||||
devgen changelog generate
|
devgen changelog generate
|
||||||
|
|
||||||
# Generate from a specific reference
|
# Create release notes for v2.0.0
|
||||||
devgen changelog generate --from v1.0.0
|
devgen release notes --version 2.0.0
|
||||||
|
|
||||||
# Output to a custom file
|
|
||||||
devgen changelog generate --output CHANGES.md
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The changelog follows Semantic Release conventions, categorizing commits into:
|
### 🛡️ Essential Files
|
||||||
- **BREAKING CHANGES**
|
|
||||||
- **Features**
|
|
||||||
- **Bug Fixes**
|
|
||||||
- **Documentation**
|
|
||||||
- **Other Changes**
|
|
||||||
|
|
||||||
### 4. Generate Release Notes
|
Don't waste time searching for templates. Generate the essential files for your project instantly.
|
||||||
|
|
||||||
Create release notes for your releases:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Generate release notes
|
# Interactive search for gitignore templates
|
||||||
devgen release notes --version 1.4.0
|
|
||||||
|
|
||||||
# Custom output file
|
|
||||||
devgen release notes --version 1.4.0 --output RELEASE.md
|
|
||||||
|
|
||||||
# From a specific reference
|
|
||||||
devgen release notes --version 1.4.0 --from v1.3.0
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Generate .gitignore Files
|
|
||||||
|
|
||||||
Create `.gitignore` files from GitHub templates:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Interactive mode (search and select templates)
|
|
||||||
devgen gitignore generate
|
devgen gitignore generate
|
||||||
|
|
||||||
# Specify templates directly
|
# Generate a license interactively
|
||||||
devgen gitignore generate Python Node Docker
|
|
||||||
|
|
||||||
# List available templates
|
|
||||||
devgen gitignore list
|
|
||||||
|
|
||||||
# Use cached templates (offline mode)
|
|
||||||
devgen gitignore generate --offline Python
|
|
||||||
```
|
|
||||||
|
|
||||||
**Options:**
|
|
||||||
- `--append` / `--overwrite` - Append to or overwrite existing `.gitignore`
|
|
||||||
- `--output` - Specify output file path (default: `.gitignore`)
|
|
||||||
- `--offline` - Use only cached templates
|
|
||||||
|
|
||||||
### 6. Generate Licenses
|
|
||||||
|
|
||||||
Generate open source licenses interactively:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
devgen license generate
|
devgen license generate
|
||||||
```
|
```
|
||||||
|
|
||||||
This will:
|
|
||||||
1. Show available licenses (MIT, Apache-2.0, GPL-3.0, etc.)
|
|
||||||
2. Prompt for author name
|
|
||||||
3. Prompt for year (defaults to current year)
|
|
||||||
4. Generate the license file
|
|
||||||
|
|
||||||
**Available licenses:**
|
|
||||||
- MIT
|
|
||||||
- Apache-2.0
|
|
||||||
- GPL-2.0, GPL-3.0
|
|
||||||
- LGPL-2.1
|
|
||||||
- AGPL-3.0
|
|
||||||
- BSD-2-Clause, BSD-3-Clause
|
|
||||||
- MPL-2.0
|
|
||||||
- EPL-2.0
|
|
||||||
- BSL-1.0
|
|
||||||
- CC0-1.0
|
|
||||||
- Unlicense
|
|
||||||
|
|
||||||
## ⚙️ Configuration
|
## ⚙️ Configuration
|
||||||
|
|
||||||
Configuration is stored in `~/.devgen.yaml`. You can edit it manually or use the interactive setup:
|
Your settings live in `~/.devgen.yaml`. You can tweak your AI provider, model, and preferences there.
|
||||||
|
|
||||||
```bash
|
| Option | Description |
|
||||||
devgen setup config
|
| :--------- | :----------------------------------------------------------- |
|
||||||
|
| `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`) |
|
||||||
|
| `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 }}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Configuration options:**
|
> [!TIP]
|
||||||
- `provider` - AI provider: `gemini`, `openai`, `anthropic`, `huggingface`, or `openrouter`
|
> Use `devgen config info` to see a full list of available variables and a template tip!
|
||||||
- `model` - Model name (e.g., `gemini-2.5-flash`, `gpt-4`, `claude-3-opus`)
|
|
||||||
- `api_key` - Your API key
|
|
||||||
- `emoji` - Enable/disable emojis in commit messages (default: `true`)
|
|
||||||
|
|
||||||
## 📋 Command Reference
|
|
||||||
|
|
||||||
### Commit Commands
|
|
||||||
```bash
|
|
||||||
devgen commit run [--dry-run] [--push] [--debug] [--force-rebuild]
|
|
||||||
devgen commit validate
|
|
||||||
devgen commit list-cached
|
|
||||||
devgen commit clear-cache
|
|
||||||
```
|
|
||||||
|
|
||||||
### Changelog Commands
|
|
||||||
```bash
|
|
||||||
devgen changelog generate [--output FILE] [--from REF]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Release Commands
|
|
||||||
```bash
|
|
||||||
devgen release notes [--version VERSION] [--output FILE] [--from REF]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Gitignore Commands
|
|
||||||
```bash
|
|
||||||
devgen gitignore generate [TEMPLATES...] [--output FILE] [--append/--overwrite] [--offline]
|
|
||||||
devgen gitignore list [--cached]
|
|
||||||
```
|
|
||||||
|
|
||||||
### License Commands
|
|
||||||
```bash
|
|
||||||
devgen license generate [--output FILE]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Setup Commands
|
|
||||||
```bash
|
|
||||||
devgen setup config
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎯 Use Cases
|
|
||||||
|
|
||||||
- **Automated Commit Workflow**: Let AI analyze your changes and generate meaningful commit messages
|
|
||||||
- **Release Preparation**: Automatically generate changelogs and release notes from git history
|
|
||||||
- **Project Setup**: Quickly add `.gitignore` and license files to new projects
|
|
||||||
- **CI/CD Integration**: Use in scripts to automate documentation generation
|
|
||||||
|
|
||||||
## 🔧 Requirements
|
|
||||||
|
|
||||||
- Python 3.10 or higher
|
|
||||||
- Git repository (for commit/changelog/release features)
|
|
||||||
- API key for your chosen AI provider
|
|
||||||
|
|
||||||
## 📝 License
|
|
||||||
|
|
||||||
This project is licensed under the GPL-3.0-or-later License - see the [LICENSE](LICENSE) file for details.
|
|
||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 Contributing
|
||||||
|
|
||||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
We love contributions! Found a bug? Want a new feature? Open an issue or submit a PR.
|
||||||
|
|
||||||
## 📚 Links
|
## � Acknowledgments
|
||||||
|
|
||||||
- **Homepage**: [https://github.com/S4NKALP/devgen](https://github.com/S4NKALP/devgen)
|
DevGen wouldn't be possible without these amazing open-source projects and AI providers:
|
||||||
- **PyPI**: [https://pypi.org/project/devgen](https://pypi.org/project/devgen)
|
|
||||||
|
|
||||||
## 🙏 Acknowledgments
|
- **[Typer](https://typer.tiangolo.com/)** & **[Rich](https://rich.readthedocs.io/)** for building the beautiful, intuitive, and responsive CLI interface.
|
||||||
|
- **[Questionary](https://github.com/tmbo/questionary)** for creating interactive, user-friendly prompts and selection menus.
|
||||||
|
- **[Jinja2](https://jinja.palletsprojects.com/)** for the powerful template engine used to generate files and messages.
|
||||||
|
- **[Google Gemini](https://deepmind.google/technologies/gemini/)**, **[OpenAI](https://openai.com/)**, **[Anthropic](https://www.anthropic.com/)**, **[HuggingFace](https://huggingface.co/)**, and **[OpenRouter](https://openrouter.ai/)** for providing the advanced AI models that power the semantic generation features.
|
||||||
|
|
||||||
- Uses GitHub's official [gitignore templates](https://github.com/github/gitignore)
|
## �📝 License
|
||||||
- Follows [Conventional Commits](https://www.conventionalcommits.org/) specification
|
|
||||||
|
Proudly open source under the [GPL-3.0-or-later](LICENSE) License.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
Made with ❤️ by <a href="https://github.com/S4NKALP">Sankalp</a>
|
||||||
|
</div>
|
||||||
@@ -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.
|
||||||
+36
-2
@@ -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)
|
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",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from datetime import datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from devgen.utils import configure_logger
|
from devgen.utils import configure_logger, run_git_command
|
||||||
|
|
||||||
|
|
||||||
class ChangelogGenerator:
|
class ChangelogGenerator:
|
||||||
@@ -14,22 +14,6 @@ class ChangelogGenerator:
|
|||||||
def __init__(self, logger=None):
|
def __init__(self, logger=None):
|
||||||
self.logger = logger or configure_logger("devgen.changelog")
|
self.logger = logger or configure_logger("devgen.changelog")
|
||||||
|
|
||||||
def _exec_git(self, args: List[str]) -> str:
|
|
||||||
"""Executes a git command."""
|
|
||||||
try:
|
|
||||||
res = subprocess.run(
|
|
||||||
args,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
encoding="utf-8",
|
|
||||||
errors="replace",
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
return res.stdout.strip()
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
self.logger.error(f"Git command failed: {e}")
|
|
||||||
raise RuntimeError(f"Git command failed: {e}")
|
|
||||||
|
|
||||||
def get_commits(self, from_ref: str = "", to_ref: str = "HEAD") -> List[str]:
|
def get_commits(self, from_ref: str = "", to_ref: str = "HEAD") -> List[str]:
|
||||||
"""Fetches commit messages in the specified range."""
|
"""Fetches commit messages in the specified range."""
|
||||||
range_spec = f"{from_ref}..{to_ref}" if from_ref else to_ref
|
range_spec = f"{from_ref}..{to_ref}" if from_ref else to_ref
|
||||||
@@ -40,7 +24,7 @@ class ChangelogGenerator:
|
|||||||
if not from_ref:
|
if not from_ref:
|
||||||
# If no start ref, try to find the last tag
|
# If no start ref, try to find the last tag
|
||||||
try:
|
try:
|
||||||
last_tag = self._exec_git(["git", "describe", "--tags", "--abbrev=0"])
|
last_tag = run_git_command(["git", "describe", "--tags", "--abbrev=0"])
|
||||||
cmd = [
|
cmd = [
|
||||||
"git",
|
"git",
|
||||||
"log",
|
"log",
|
||||||
@@ -49,11 +33,15 @@ class ChangelogGenerator:
|
|||||||
f"{last_tag}..HEAD",
|
f"{last_tag}..HEAD",
|
||||||
]
|
]
|
||||||
self.logger.info(f"Generating changelog from last tag: {last_tag}")
|
self.logger.info(f"Generating changelog from last tag: {last_tag}")
|
||||||
except RuntimeError:
|
except (RuntimeError, subprocess.CalledProcessError):
|
||||||
self.logger.info("No tags found, generating for all commits.")
|
self.logger.info("No tags found, generating for all commits.")
|
||||||
cmd = ["git", "log", f"--format={fmt}", "--date=short"]
|
cmd = ["git", "log", f"--format={fmt}", "--date=short"]
|
||||||
|
|
||||||
return self._exec_git(cmd).split("\n")
|
try:
|
||||||
|
return run_git_command(cmd).split("\n")
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
self.logger.error(f"Git command failed: {e}")
|
||||||
|
raise RuntimeError(f"Git command failed: {e}")
|
||||||
|
|
||||||
def parse_commits(self, raw_commits: List[str]) -> Dict[str, List[Dict]]:
|
def parse_commits(self, raw_commits: List[str]) -> Dict[str, List[Dict]]:
|
||||||
"""Parses raw commit strings into structured data."""
|
"""Parses raw commit strings into structured data."""
|
||||||
@@ -132,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 = [
|
||||||
@@ -175,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
|
||||||
@@ -11,8 +13,14 @@ from devgen.utils import (
|
|||||||
get_commit_dry_run_path,
|
get_commit_dry_run_path,
|
||||||
is_file_recent,
|
is_file_recent,
|
||||||
load_template_env,
|
load_template_env,
|
||||||
|
run_git_command,
|
||||||
sanitize_ai_commit_message,
|
sanitize_ai_commit_message,
|
||||||
|
render_custom_template,
|
||||||
)
|
)
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.panel import Panel
|
||||||
|
from rich.markdown import Markdown
|
||||||
|
from rich.theme import Theme
|
||||||
|
|
||||||
|
|
||||||
class CommitEngineError(Exception):
|
class CommitEngineError(Exception):
|
||||||
@@ -21,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.
|
||||||
@@ -33,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,
|
||||||
@@ -42,63 +64,134 @@ 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(
|
||||||
"devgen.commit", Path.home() / ".cache" / "devgen" / "commit.log"
|
"devgen.commit",
|
||||||
|
Path.home() / ".cache" / "devgen" / "commit.log",
|
||||||
|
console=debug,
|
||||||
)
|
)
|
||||||
self.kwargs = kwargs
|
self.kwargs = kwargs
|
||||||
self.dry_run_path = get_commit_dry_run_path()
|
self.dry_run_path = get_commit_dry_run_path()
|
||||||
self.template_env = load_template_env("commit")
|
self.template_env = load_template_env("commit")
|
||||||
|
|
||||||
|
self.console = Console(
|
||||||
|
theme=Theme(
|
||||||
|
{"info": "dim cyan", "warning": "magenta", "danger": "bold red"}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Load config from ~/.devgen.yaml
|
# Load config from ~/.devgen.yaml
|
||||||
from devgen.utils import load_config
|
from devgen.utils import load_config
|
||||||
|
|
||||||
self.config = load_config()
|
self.config = load_config()
|
||||||
|
|
||||||
def _exec_git(self, args: List[str], allow_error: bool = False) -> str:
|
def detect_changes(self) -> List[str]:
|
||||||
"""Executes a git command."""
|
"""Detects changed, deleted, or untracked files."""
|
||||||
try:
|
try:
|
||||||
res = subprocess.run(
|
# Get modified, deleted and untracked files
|
||||||
args,
|
out = run_git_command(
|
||||||
capture_output=True,
|
[
|
||||||
text=True,
|
"git",
|
||||||
encoding="utf-8",
|
"ls-files",
|
||||||
errors="replace",
|
"--deleted",
|
||||||
check=not allow_error,
|
"--modified",
|
||||||
|
"--others",
|
||||||
|
"--exclude-standard",
|
||||||
|
]
|
||||||
)
|
)
|
||||||
return res.stdout.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 detect_changes(self) -> List[str]:
|
|
||||||
"""Detects changed, deleted, or untracked files."""
|
|
||||||
out = self._exec_git(
|
|
||||||
[
|
|
||||||
"git",
|
|
||||||
"ls-files",
|
|
||||||
"--deleted",
|
|
||||||
"--modified",
|
|
||||||
"--others",
|
|
||||||
"--exclude-standard",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return [f.strip() for f in out.split("\n") if f.strip()]
|
|
||||||
|
|
||||||
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."""
|
||||||
return self._exec_git(["git", "--no-pager", "diff", "--staged", "--", *files])
|
try:
|
||||||
|
# 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)
|
||||||
|
raise CommitEngineError(msg) from e
|
||||||
|
|
||||||
def _init_dry_run(self):
|
def _init_dry_run(self):
|
||||||
"""Initializes the dry-run file."""
|
"""Initializes the dry-run file."""
|
||||||
@@ -108,7 +201,15 @@ class CommitEngine:
|
|||||||
f.write(f"# Dry Run: Commit Messages\n_Generated: {ts}_\n\n")
|
f.write(f"# Dry Run: Commit Messages\n_Generated: {ts}_\n\n")
|
||||||
|
|
||||||
def _log_dry_run(self, group: str, msg: str):
|
def _log_dry_run(self, group: str, msg: str):
|
||||||
"""Appends a dry-run entry."""
|
"""Appends a dry-run entry and prints to console."""
|
||||||
|
self.console.print(
|
||||||
|
Panel(
|
||||||
|
Markdown(msg),
|
||||||
|
title=f"Dry Run: {group}",
|
||||||
|
border_style="yellow",
|
||||||
|
expand=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
with self.dry_run_path.open("a", encoding="utf-8") as f:
|
with self.dry_run_path.open("a", encoding="utf-8") as f:
|
||||||
f.write(f"## Group: `{group}`\n\n```md\n{msg}\n```\n\n---\n\n")
|
f.write(f"## Group: `{group}`\n\n```md\n{msg}\n```\n\n---\n\n")
|
||||||
|
|
||||||
@@ -117,18 +218,83 @@ class CommitEngine:
|
|||||||
if not files:
|
if not files:
|
||||||
return
|
return
|
||||||
self.logger.info(f"Staging: {files}")
|
self.logger.info(f"Staging: {files}")
|
||||||
self._exec_git(["git", "add", *files])
|
self.console.print(f"[info]Staging {len(files)} files...[/info]")
|
||||||
|
try:
|
||||||
|
run_git_command(["git", "add", *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 commit_staged(self, msg: str):
|
def commit_staged(self, msg: str):
|
||||||
"""Commits staged changes."""
|
"""Commits staged changes."""
|
||||||
self.logger.info(f"Committing:\n{msg}")
|
self.logger.info(f"Committing:\n{msg}")
|
||||||
self._exec_git(["git", "commit", "-m", msg])
|
self.console.print(
|
||||||
|
Panel(Markdown(msg), title="Commit Message", border_style="green")
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
run_git_command(["git", "commit", "-m", msg])
|
||||||
|
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 push_commits(self):
|
def push_commits(self):
|
||||||
"""Pushes commits to remote."""
|
"""Pushes commits to remote."""
|
||||||
self.logger.info("Pushing to remote...")
|
self.logger.info("Pushing to remote...")
|
||||||
self._exec_git(["git", "push"])
|
with self.console.status("[bold green]Pushing to remote...[/bold green]"):
|
||||||
self.logger.info("Push successful.")
|
try:
|
||||||
|
run_git_command(["git", "push"])
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
msg = (
|
||||||
|
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:
|
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."""
|
||||||
@@ -143,32 +309,60 @@ 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")
|
||||||
|
|
||||||
raw = generate_with_ai(
|
if custom_template:
|
||||||
prompt,
|
self.logger.info("Using custom template from config")
|
||||||
provider=provider,
|
prompt = render_custom_template(
|
||||||
model=model,
|
custom_template,
|
||||||
api_key=api_key,
|
group_name=group,
|
||||||
debug=self.debug,
|
diff_text=diff,
|
||||||
**self.kwargs,
|
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(
|
||||||
|
prompt,
|
||||||
|
provider=provider,
|
||||||
|
model=model,
|
||||||
|
api_key=api_key,
|
||||||
|
debug=self.debug,
|
||||||
|
**self.kwargs,
|
||||||
|
)
|
||||||
return sanitize_ai_commit_message(raw)
|
return sanitize_ai_commit_message(raw)
|
||||||
|
|
||||||
def is_ahead_of_remote(self) -> bool:
|
def is_ahead_of_remote(self) -> bool:
|
||||||
"""Checks if local branch has unpushed commits."""
|
"""Checks if local branch has unpushed commits."""
|
||||||
try:
|
try:
|
||||||
self._exec_git(["git", "fetch", "origin"])
|
run_git_command(["git", "fetch", "origin"])
|
||||||
count = self._exec_git(
|
count = run_git_command(
|
||||||
["git", "rev-list", "--count", "@{u}..HEAD"], allow_error=True
|
["git", "rev-list", "--count", "@{u}..HEAD"], check=False
|
||||||
)
|
)
|
||||||
if count and int(count) > 0:
|
if count and int(count) > 0:
|
||||||
return True
|
return True
|
||||||
except CommitEngineError:
|
except (subprocess.CalledProcessError, CommitEngineError):
|
||||||
# Maybe no upstream
|
# Maybe no upstream
|
||||||
return bool(self._exec_git(["git", "rev-parse", "HEAD"], allow_error=True))
|
try:
|
||||||
|
return bool(run_git_command(["git", "rev-parse", "HEAD"], check=False))
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return False
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def load_cache(self) -> Dict[str, str]:
|
def load_cache(self) -> Dict[str, str]:
|
||||||
@@ -190,21 +384,73 @@ class CommitEngine:
|
|||||||
|
|
||||||
if not diff.strip():
|
if not diff.strip():
|
||||||
self.logger.info(f"Skipping empty diff for {group}")
|
self.logger.info(f"Skipping empty diff for {group}")
|
||||||
self._exec_git(["git", "reset", "HEAD", "--", *files])
|
try:
|
||||||
|
run_git_command(["git", "reset", "HEAD", "--", *files])
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
pass # Ignore reset errors
|
||||||
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."""
|
||||||
self._exec_git(["git", "reset", "HEAD", "--", *files])
|
try:
|
||||||
else:
|
run_git_command(["git", "reset", "HEAD", "--", *files])
|
||||||
self.commit_staged(msg)
|
except subprocess.CalledProcessError:
|
||||||
|
pass # Ignore reset errors
|
||||||
return True
|
|
||||||
|
|
||||||
def execute(self):
|
def execute(self):
|
||||||
"""Main execution method."""
|
"""Main execution method."""
|
||||||
@@ -237,19 +483,25 @@ class CommitEngine:
|
|||||||
self.logger.error("Push aborted due to failed commits.")
|
self.logger.error("Push aborted due to failed commits.")
|
||||||
|
|
||||||
if self.dry_run:
|
if self.dry_run:
|
||||||
self.logger.info(f"Dry run done. See {self.dry_run_path}")
|
self.console.print(
|
||||||
|
f"[bold green]Dry run done.[/bold green] See {self.dry_run_path}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self.logger.info("Done.")
|
self.console.print("[bold green]Done.[/bold green]")
|
||||||
if failed:
|
if failed:
|
||||||
self.logger.warning(f"Failed groups: {failed}")
|
self.console.print(f"[bold red]Failed groups: {failed}[/bold red]")
|
||||||
|
|
||||||
|
|
||||||
def run_commit_engine(**kwargs):
|
def run_commit_engine(**kwargs):
|
||||||
"""Entry point for the commit engine."""
|
"""Entry point for the commit engine."""
|
||||||
logger = configure_logger("devgen.commit")
|
debug = kwargs.get("debug", False)
|
||||||
|
logger = configure_logger("devgen.commit", console=debug)
|
||||||
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,21 +1,24 @@
|
|||||||
from devgen.providers.anthropic import AnthropicProvider
|
|
||||||
from devgen.providers.gemini import GeminiProvider
|
|
||||||
from devgen.providers.huggingface import HuggingfaceProvider
|
|
||||||
from devgen.providers.openai import OpenaiProvider
|
|
||||||
from devgen.providers.openrouter import OpenrouterProvider
|
|
||||||
|
|
||||||
|
|
||||||
def get_provider(name):
|
def get_provider(name):
|
||||||
name_lower = name.lower()
|
name_lower = name.lower()
|
||||||
if name_lower == "gemini":
|
if name_lower == "gemini":
|
||||||
|
from devgen.providers.gemini import GeminiProvider
|
||||||
|
|
||||||
return GeminiProvider()
|
return GeminiProvider()
|
||||||
elif name_lower == "openai":
|
elif name_lower == "openai":
|
||||||
|
from devgen.providers.openai import OpenaiProvider
|
||||||
|
|
||||||
return OpenaiProvider()
|
return OpenaiProvider()
|
||||||
elif name_lower == "huggingface":
|
elif name_lower == "huggingface":
|
||||||
|
from devgen.providers.huggingface import HuggingfaceProvider
|
||||||
|
|
||||||
return HuggingfaceProvider()
|
return HuggingfaceProvider()
|
||||||
elif name_lower == "openrouter":
|
elif name_lower == "openrouter":
|
||||||
|
from devgen.providers.openrouter import OpenrouterProvider
|
||||||
|
|
||||||
return OpenrouterProvider()
|
return OpenrouterProvider()
|
||||||
elif name_lower == "anthropic":
|
elif name_lower == "anthropic":
|
||||||
|
from devgen.providers.anthropic import AnthropicProvider
|
||||||
|
|
||||||
return AnthropicProvider()
|
return AnthropicProvider()
|
||||||
|
|
||||||
raise NotImplementedError(f"Provider '{name}' is not implemented yet.")
|
raise NotImplementedError(f"Provider '{name}' is not implemented yet.")
|
||||||
@@ -39,6 +39,9 @@ class OpenaiProvider:
|
|||||||
# Add error handling if the client fails to initialize
|
# Add error handling if the client fails to initialize
|
||||||
raise RuntimeError(f"Failed to initialize OpenAI client: {e}")
|
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
|
# 2. Use the modern API syntax: client.chat.completions.create
|
||||||
response = client.chat.completions.create(
|
response = client.chat.completions.create(
|
||||||
model=model or self.DEFAULT_MODEL,
|
model=model or self.DEFAULT_MODEL,
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ class OpenrouterProvider:
|
|||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Remove debug from kwargs if present
|
||||||
|
kwargs.pop("debug", None)
|
||||||
|
|
||||||
response = client.chat.completions.create(
|
response = client.chat.completions.create(
|
||||||
model=model,
|
model=model,
|
||||||
messages=[{"role": "user", "content": prompt}],
|
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:
|
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 }}
|
||||||
+104
-19
@@ -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(
|
||||||
@@ -75,7 +113,7 @@ def extract_commit_messages(filepath: Path | str) -> dict[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
def configure_logger(
|
def configure_logger(
|
||||||
name: str = "devgen", log_file: Optional[Path | str] = None
|
name: str = "devgen", log_file: Optional[Path | str] = None, console: bool = True
|
||||||
) -> logging.Logger:
|
) -> logging.Logger:
|
||||||
logger = logging.getLogger(name)
|
logger = logging.getLogger(name)
|
||||||
logger.setLevel(logging.INFO)
|
logger.setLevel(logging.INFO)
|
||||||
@@ -88,9 +126,10 @@ def configure_logger(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Console handler
|
# Console handler
|
||||||
ch = logging.StreamHandler()
|
if console:
|
||||||
ch.setFormatter(formatter)
|
ch = logging.StreamHandler()
|
||||||
logger.addHandler(ch)
|
ch.setFormatter(formatter)
|
||||||
|
logger.addHandler(ch)
|
||||||
|
|
||||||
# File handler
|
# File handler
|
||||||
if log_file:
|
if log_file:
|
||||||
@@ -103,15 +142,52 @@ def configure_logger(
|
|||||||
return logger
|
return logger
|
||||||
|
|
||||||
|
|
||||||
def get_git_staged_files() -> list[str]:
|
def run_git_command(
|
||||||
|
args: list[str],
|
||||||
|
check: bool = True,
|
||||||
|
cwd: Optional[Path] = None,
|
||||||
|
encoding: str = "utf-8",
|
||||||
|
errors: str = "replace",
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Executes a git command and returns the output.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
args: List of command arguments (e.g., ["git", "status"]).
|
||||||
|
check: Whether to raise an exception on non-zero exit code.
|
||||||
|
cwd: Current working directory for the command.
|
||||||
|
encoding: Output encoding.
|
||||||
|
errors: Error handling strategy for encoding.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The standard output of the command, stripped of leading/trailing whitespace.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
subprocess.CalledProcessError: If the command fails and check is True.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
res = subprocess.run(
|
res = subprocess.run(
|
||||||
["git", "diff", "--name-only", "--cached"],
|
args,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
check=True,
|
encoding=encoding,
|
||||||
|
errors=errors,
|
||||||
|
check=check,
|
||||||
|
cwd=cwd,
|
||||||
)
|
)
|
||||||
return [f for f in res.stdout.splitlines() if f.strip()]
|
return res.stdout.strip()
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
# Log the error if a logger is configured, but re-raise
|
||||||
|
# We don't have access to a specific logger here easily without passing it in,
|
||||||
|
# so we rely on the caller to handle logging if needed, or we could log to a default one.
|
||||||
|
# For now, just re-raise as the caller expects.
|
||||||
|
raise e
|
||||||
|
|
||||||
|
|
||||||
|
def get_git_staged_files() -> list[str]:
|
||||||
|
try:
|
||||||
|
output = run_git_command(["git", "diff", "--name-only", "--cached"])
|
||||||
|
return [f for f in output.splitlines() if f.strip()]
|
||||||
except subprocess.CalledProcessError:
|
except subprocess.CalledProcessError:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -132,10 +208,17 @@ def delete_file(filepath: Path | str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def load_template_env(sub_dir: str) -> Environment:
|
def load_template_env(sub_dir: str) -> Environment:
|
||||||
template_dir = Path(__file__).parent / "prompt_templates" / sub_dir
|
template_dir = Path(__file__).parent / "templates" / sub_dir
|
||||||
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"
|
||||||
|
|
||||||
@@ -145,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:
|
||||||
@@ -170,6 +254,7 @@ __all__ = [
|
|||||||
"sanitize_ai_commit_message",
|
"sanitize_ai_commit_message",
|
||||||
"extract_commit_messages",
|
"extract_commit_messages",
|
||||||
"configure_logger",
|
"configure_logger",
|
||||||
|
"run_git_command",
|
||||||
"get_git_staged_files",
|
"get_git_staged_files",
|
||||||
"read_file_content",
|
"read_file_content",
|
||||||
"delete_file",
|
"delete_file",
|
||||||
|
|||||||
+2
-2
@@ -3,8 +3,8 @@ requires = ["setuptools>=61", "wheel"]
|
|||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "devgen"
|
name = "devgen-cli"
|
||||||
version = "0.2.0"
|
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"
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ Setup script for devgen package.
|
|||||||
from setuptools import find_packages, setup
|
from setuptools import find_packages, setup
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name="devgen",
|
name="devgen-cli",
|
||||||
version="0.2.0",
|
version="0.2.4",
|
||||||
packages=find_packages(),
|
packages=find_packages(),
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
install_requires=[
|
install_requires=[
|
||||||
|
|||||||
@@ -188,8 +188,8 @@ wheels = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "devgen"
|
name = "devgen-cli"
|
||||||
version = "0.1.0"
|
version = "0.2.4"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anthropic" },
|
{ name = "anthropic" },
|
||||||
@@ -262,7 +262,7 @@ name = "exceptiongroup"
|
|||||||
version = "1.3.1"
|
version = "1.3.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
|
|||||||
Reference in new issue
Block a user