Files
DevGen/devgen/modules/commit_generator.py
T
sankalp 957dda9d49 refactor: remove jinja2 dependency, use simple template engine
- Replace jinja2 with custom render_template() helper
- Support {{ var }} substitution and {% if %}...{% endif %} conditionals
- Remove jinja2 from pyproject.toml and requirements.txt
- Simplifies dependency tree and reduces install size
2026-06-16 14:58:25 +05:45

384 lines
12 KiB
Python

"""Orchestrates the AI-powered commit flow.
The :class:`CommitEngine` is a thin coordinator. The real work lives in:
* :class:`devgen.modules.git_ops.GitOperator` — all git side-effects
* :class:`devgen.modules.diff_builder.FileGrouper` — files → commit groups
* :class:`devgen.modules.diff_builder.DiffBuilder` — staged diff + truncation
* :class:`devgen.modules.diff_builder.ManifestInspector` — project context
* :class:`devgen.ai.generate_with_ai` — provider-agnostic AI call
"""
import sys
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
import questionary
from rich.console import Console
from rich.markdown import Markdown
from rich.panel import Panel
from rich.theme import Theme
from devgen.ai import generate_with_ai
from devgen.modules.diff_builder import (
DEFAULT_MAX_DIFF_SIZE,
DiffBuilder,
FileGrouper,
ManifestInspector,
)
from devgen.modules.git_ops import GitOperator
from devgen.utils import (
configure_logger,
extract_commit_messages,
get_commit_dry_run_path,
is_file_recent,
load_template,
render_template,
sanitize_ai_commit_message,
)
class CommitEngineError(Exception):
"""Raised when the commit flow cannot proceed."""
class CacheManager:
"""Manages the dry-run cache file at ``~/.cache/devgen/commit_dry_run.md``."""
def __init__(self, path: Path, max_age_minutes: int = 120) -> None:
self.path = path
self.max_age_minutes = max_age_minutes
def init_dry_run(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
ts = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S (%Z)")
with self.path.open("w", encoding="utf-8") as f:
f.write(f"# Dry Run: Commit Messages\n_Generated: {ts}_\n\n")
def load(self, *, dry_run: bool, force_rebuild: bool) -> Dict[str, str]:
if dry_run:
self.init_dry_run()
return {}
if not force_rebuild and is_file_recent(self.path, self.max_age_minutes):
return extract_commit_messages(self.path)
return {}
def append(self, group: str, message: str) -> None:
with self.path.open("a", encoding="utf-8") as f:
f.write(f"## Group: `{group}`\n\n```md\n{message}\n```\n\n---\n\n")
class CommitMessageBuilder:
"""Renders the prompt and calls the AI provider."""
def __init__(
self,
template_str: str,
config: Dict[str, Any],
provider: str,
model: str,
debug: bool = False,
) -> None:
self.template_str = template_str
self.config = config
self.provider = provider
self.model = model
self.debug = debug
def build_message(
self,
group: str,
diff: str,
manifest_context: str,
extra_kwargs: Dict[str, Any],
) -> str:
custom_template = self.config.get("custom_template")
use_emoji = self.config.get("emoji", True)
ollama_host = self.config.get("ollama_host")
template = custom_template or self.template_str
prompt = render_template(
template,
group_name=group,
diff_text=diff,
use_emoji=use_emoji,
context=manifest_context,
).strip()
provider = (
extra_kwargs.get("provider") or self.config.get("provider") or self.provider
)
model = extra_kwargs.get("model") or self.config.get("model") or self.model
api_key = extra_kwargs.get("api_key") or self.config.get("api_key")
call_kwargs = dict(extra_kwargs)
if ollama_host and provider == "ollama":
call_kwargs.setdefault("ollama_host", ollama_host)
return generate_with_ai(
prompt,
provider=provider,
model=model,
api_key=api_key,
debug=self.debug,
**call_kwargs,
)
class CommitEngine:
"""Coordinates the AI-powered commit flow.
The constructor signature is stable (used by ``cli/commit.py``) but
most logic now lives in composed objects. The public ``execute()``
method remains the single entry point.
"""
def __init__(
self,
dry_run: bool = False,
push: bool = False,
debug: bool = False,
force_rebuild: bool = False,
check: bool = False,
provider: str = "gemini",
model: str = "gemini-2.5-flash",
logger: Any | None = None,
max_groups: int | None = None,
max_diff_size: int | None = None,
**kwargs: Any,
) -> None:
self.dry_run = dry_run
self.push = push
self.debug = debug
self.force_rebuild = force_rebuild
self.check = check
self.provider = provider
self.model = model
self.logger = logger or configure_logger(
"devgen.commit",
Path.home() / ".cache" / "devgen" / "commit.log",
console=debug,
)
self.kwargs = kwargs
# CLI flags win over config; config wins over default.
from devgen.utils import load_config
self.config = load_config()
self.console = Console(
theme=Theme(
{"info": "dim cyan", "warning": "magenta", "danger": "bold red"}
)
)
self.git = GitOperator(logger=self.logger)
self.grouper = FileGrouper(
max_groups=(
max_groups
if max_groups is not None
else self.config.get("max_groups", 5)
)
)
self.diff_builder = DiffBuilder(
max_size=(
max_diff_size
if max_diff_size is not None
else self.config.get("max_diff_size", DEFAULT_MAX_DIFF_SIZE)
),
logger=self.logger,
)
self.template_str = load_template("commit", "commit_message.tpl")
self.cache = CacheManager(get_commit_dry_run_path())
self.message_builder = CommitMessageBuilder(
self.template_str,
self.config,
provider=self.provider,
model=self.model,
debug=self.debug,
)
# ------------------------------------------------------------------ public
def execute(self) -> None:
files = self.git.detect_changed()
# Only consult the remote state if the user wants to push —
# avoids a network round-trip on every run.
ahead = self.git.is_ahead_of_remote(fetch=self.push) if self.push else False
if not files and not ahead:
self.logger.info("Nothing to commit or push.")
return
failed: list[str] = []
if files:
failed = self._process_groups(files)
if self.push and not self.dry_run:
if not failed:
self.git.push()
self.console.print("[bold green]Push successful.[/bold green]")
else:
self.logger.error("Push aborted due to failed commits.")
if self.dry_run:
self.console.print(
f"[bold green]Dry run done.[/bold green] See {self.cache.path}"
)
else:
self.console.print("[bold green]Done.[/bold green]")
if failed:
self.console.print(f"[bold red]Failed groups: {failed}[/bold red]")
# ------------------------------------------------------------------ groups
def _process_groups(self, files: List[str]) -> list[str]:
groups = self.grouper.group(files)
cache_map = self.cache.load(
dry_run=self.dry_run, force_rebuild=self.force_rebuild
)
failed: list[str] = []
for i, (group, group_files) in enumerate(groups.items()):
if not self._process_group(group, group_files, cache_map):
failed.append(group)
if len(groups) > 1 and i < len(groups) - 1:
import time
time.sleep(15) # Conservative 4 RPM guard for free-tier
return failed
def _process_group(
self,
group: str,
files: List[str],
cache: Dict[str, str],
) -> bool:
self.git.stage(files)
diff = self.diff_builder.build(files)
if not diff.strip():
self.logger.info(f"Skipping empty diff for {group}")
self.git.reset(files)
return True
if not self.force_rebuild and group in cache:
self.logger.info(f"Using cached message for {group}")
return self._apply_message(group, cache[group], files)
try:
manifest_context = ManifestInspector.summary()
if manifest_context:
self.logger.info("Including manifest context in prompt")
with self.console.status(
"[bold blue]Generating commit message...[/bold blue]"
):
raw = self.message_builder.build_message(
group=group,
diff=diff,
manifest_context=manifest_context,
extra_kwargs=self.kwargs,
)
message = sanitize_ai_commit_message(raw)
except Exception as e:
self.logger.error(
f"Failed to generate message for group {group!r}: {e}",
exc_info=True,
)
self.git.reset(files)
return False
if not message:
self.logger.error(f"Empty message for {group}")
self.git.reset(files)
return False
return self._apply_message(group, message, files)
def _apply_message(
self,
group: str,
message: str,
files: List[str],
) -> bool:
if self.dry_run:
self.console.print(
Panel(
Markdown(message),
title=f"Dry Run: {group}",
border_style="yellow",
expand=False,
)
)
self.cache.append(group, message)
self.git.reset(files)
return True
if self.check:
message = self._review_with_user(group, message, files)
if message is None:
return False
try:
self.console.print(
Panel(Markdown(message), title="Commit Message", border_style="green")
)
self.git.commit(message)
except Exception as e:
self.logger.error(f"Failed to process group {group}: {e}", exc_info=True)
self.git.reset(files)
return False
return True
def _review_with_user(
self,
group: str,
message: str,
files: List[str],
) -> Optional[str]:
"""Present the message; return the final message, or None to abort."""
self.console.print(
Panel(
Markdown(message),
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.git.reset(files)
return None
if choice == "Edit":
edited = questionary.text(
"Edit commit message:",
multiline=True,
default=message,
).ask()
if not edited:
self.logger.info(f"Empty edit, commit cancelled for {group}")
self.git.reset(files)
return None
return edited
return message
def run_commit_engine(**kwargs: Any) -> None:
"""Entry point used by the CLI."""
debug = kwargs.get("debug", False)
logger = configure_logger("devgen.cli.commit", console=debug)
try:
CommitEngine(**kwargs).execute()
except KeyboardInterrupt:
logger.warning("Interrupted by user. Exiting...")
sys.exit(0)
except Exception as e:
logger.error(f"Commit engine failed: {e}", exc_info=True)
sys.exit(1)
__all__ = ["CommitEngine", "CommitEngineError", "run_commit_engine"]