- 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
384 lines
12 KiB
Python
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"]
|