Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 87 additions & 40 deletions smart_commit/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,33 +157,11 @@ def status():
safe_echo("❌ Configuration file: Not found")
safe_echo(" Run 'smart-commit config' to set up your API key")

@cli.command()
@click.option('--no-confirm', is_flag=True, help="Skip confirmation prompt")
def commit(no_confirm):
"""Generate and make a commit"""
try:
config = load_config()
model = initialize(model_name=config.ai.model)

diff = get_git_diff()
if not diff:
safe_echo("No staged changes found. Stage your files with 'git add' first.")
sys.exit(1)

staged_files = get_staged_files()
rules = "\n".join(config.ai.rules)

prompt = f"""
You are an expert at generating Git commit messages that follow the Conventional Commits specification.

**1. Format**
Your output must be only the commit message, in this exact format:
<emoji> type(scope): subject

[optional body: explains the "what" and "why" of the change]

[optional footer: e.g., "BREAKING CHANGE: description"]

def _build_prompt(diff, staged_files, rules, use_emoji):
"""Build the AI prompt with or without emoji instructions."""
if use_emoji:
format_line = "<emoji> type(scope): subject"
types_section = """\
**2. Commit Types & Emojis**
Use exactly one of the following types, with its corresponding emoji:
- ✨ `feat`: A new feature for the user.
Expand All @@ -196,31 +174,79 @@ def commit(no_confirm):
- 🏗️ `build`: Changes that affect the build system or external dependencies.
- 👷 `ci`: Changes to our CI configuration files and scripts.
- 🔧 `chore`: Other changes that don't modify src or test files (routine maintenance).
- ⏪ `revert`: Reverts a previous commit.

**3. Guidelines**
- Subject line must be under 72 characters and use present tense (e.g., "add," not "added").
- The `scope` should be a noun identifying the part of the codebase affected (e.g., `api`, `auth`, `ui`).
- **A body is required if:** the change is complex, affects multiple areas, or introduces a breaking change. Use bullet points in the body to explain key changes.
- **A `BREAKING CHANGE:` footer is required if** the change is not backward-compatible.

**4. Examples**
- ⏪ `revert`: Reverts a previous commit."""
examples = """\
[EXAMPLE 1: Simple fix]
- ✨ feat(auth): add Google OAuth integration

[EXAMPLE 2: Complex refactor with a body]
- ♻️ refactor(api): restructure user authentication flow

Extract OAuth logic into a separate service and add proper error
handling for expired tokens. This improves modularity and testability.

[EXAMPLE 3: Feature with a breaking change]
- ✨ feat(api): implement v2 user management system

Complete rewrite of user handling with a new database schema.

BREAKING CHANGE: The `/api/user` endpoint now returns a different
response format and requires an API key for authentication.
response format and requires an API key for authentication."""
else:
format_line = "type(scope): subject"
types_section = """\
**2. Commit Types**
Use exactly one of the following types (no emoji):
- `feat`: A new feature for the user.
- `fix`: A bug fix for the user.
- `docs`: Documentation changes only.
- `style`: Code style changes (formatting, whitespace, etc; no logic change).
- `refactor`: A code change that neither fixes a bug nor adds a feature.
- `perf`: A code change that improves performance.
- `test`: Adding missing tests or correcting existing tests.
- `build`: Changes that affect the build system or external dependencies.
- `ci`: Changes to our CI configuration files and scripts.
- `chore`: Other changes that don't modify src or test files (routine maintenance).
- `revert`: Reverts a previous commit."""
examples = """\
[EXAMPLE 1: Simple fix]
- feat(auth): add Google OAuth integration

[EXAMPLE 2: Complex refactor with a body]
- refactor(api): restructure user authentication flow

Extract OAuth logic into a separate service and add proper error
handling for expired tokens. This improves modularity and testability.

[EXAMPLE 3: Feature with a breaking change]
- feat(api): implement v2 user management system

Complete rewrite of user handling with a new database schema.

BREAKING CHANGE: The `/api/user` endpoint now returns a different
response format and requires an API key for authentication."""

return f"""\
You are an expert at generating Git commit messages that follow the Conventional Commits specification.

**1. Format**
Your output must be only the commit message, in this exact format:
{format_line}

[optional body: explains the "what" and "why" of the change]

[optional footer: e.g., "BREAKING CHANGE: description"]

{types_section}

**3. Guidelines**
- Subject line must be under 72 characters and use present tense (e.g., "add," not "added").
- The `scope` should be a noun identifying the part of the codebase affected (e.g., `api`, `auth`, `ui`).
- **A body is required if:** the change is complex, affects multiple areas, or introduces a breaking change. Use bullet points in the body to explain key changes.
- **A `BREAKING CHANGE:` footer is required if** the change is not backward-compatible.

**4. Examples**
{examples}

**5. Your Task**
Analyze the following files and diff, then generate the complete commit message.
Expand All @@ -232,6 +258,27 @@ def commit(no_confirm):

Files changed: {", ".join(staged_files)}
"""


@cli.command()
@click.option('--no-confirm', is_flag=True, help="Skip confirmation prompt")
@click.option('--no-emoji', 'no_emoji', is_flag=True, help="Generate commit message without emoji prefix")
def commit(no_confirm, no_emoji):
"""Generate and make a commit"""
try:
config = load_config()
model = initialize(model_name=config.ai.model)

diff = get_git_diff()
if not diff:
safe_echo("No staged changes found. Stage your files with 'git add' first.")
sys.exit(1)

staged_files = get_staged_files()
rules = "\n".join(config.ai.rules)

use_emoji = config.commit.auto_emoji and not no_emoji
prompt = _build_prompt(diff, staged_files, rules, use_emoji)
commit_message = model.generate_content(prompt).text.strip()
safe_echo(f"\nGenerated commit message:\n{commit_message}\n")

Expand Down
Empty file added tests/__init__.py
Empty file.
182 changes: 182 additions & 0 deletions tests/test_emoji_toggle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
"""Tests for the emoji toggle feature."""
import pytest
from unittest.mock import patch, MagicMock
from click.testing import CliRunner

from smart_commit.main import _build_prompt, cli


# ─────────────────────────────────────────────
# _build_prompt
# ─────────────────────────────────────────────

class TestBuildPrompt:
def _call(self, use_emoji):
return _build_prompt(
diff="diff --git a/foo.py",
staged_files=["foo.py"],
rules="- rule one",
use_emoji=use_emoji,
)

def test_emoji_on_format_line_includes_emoji_placeholder(self):
prompt = self._call(use_emoji=True)
assert "<emoji> type(scope): subject" in prompt

def test_emoji_off_format_line_has_no_emoji_placeholder(self):
prompt = self._call(use_emoji=False)
assert "<emoji>" not in prompt
assert "type(scope): subject" in prompt

def test_emoji_on_types_section_lists_emojis(self):
prompt = self._call(use_emoji=True)
assert "✨" in prompt
assert "🐛" in prompt

def test_emoji_off_types_section_has_no_emojis(self):
prompt = self._call(use_emoji=False)
# Commit type labels still present, but unicode emoji stripped
assert "`feat`" in prompt
assert "`fix`" in prompt
assert "✨" not in prompt
assert "🐛" not in prompt

def test_emoji_on_examples_contain_emojis(self):
prompt = self._call(use_emoji=True)
assert "✨ feat(auth)" in prompt

def test_emoji_off_examples_have_no_emojis(self):
prompt = self._call(use_emoji=False)
assert "feat(auth)" in prompt
assert "✨ feat(auth)" not in prompt

def test_diff_and_files_always_included(self):
for flag in (True, False):
prompt = self._call(use_emoji=flag)
assert "foo.py" in prompt
assert "diff --git a/foo.py" in prompt

def test_rules_always_included(self):
for flag in (True, False):
prompt = self._call(use_emoji=flag)
assert "rule one" in prompt


# ─────────────────────────────────────────────
# commit CLI --no-emoji flag
# ─────────────────────────────────────────────

def _make_config(auto_emoji=True):
cfg = MagicMock()
cfg.ai.model = "gemini-2.5-flash"
cfg.ai.rules = []
cfg.commit.auto_emoji = auto_emoji
return cfg


def _make_model(message="feat(ui): add button"):
model = MagicMock()
model.generate_content.return_value.text = message
return model


class TestCommitEmojiFlag:
def test_no_emoji_flag_disables_emoji_in_prompt(self):
captured = {}
runner = CliRunner()
mock_model = MagicMock()

def capture_prompt(prompt):
captured["prompt"] = prompt
result = MagicMock()
result.text = "feat(ui): add button"
return result

mock_model.generate_content.side_effect = capture_prompt

with patch("smart_commit.main.load_config", return_value=_make_config()), \
patch("smart_commit.main.initialize", return_value=mock_model), \
patch("smart_commit.main.get_git_diff", return_value="diff content"), \
patch("smart_commit.main.get_staged_files", return_value=["ui.py"]), \
patch("smart_commit.main.commit_with_message"):
runner.invoke(cli, ["commit", "--no-confirm", "--no-emoji"])

assert "<emoji>" not in captured.get("prompt", "")

def test_default_uses_emoji_in_prompt(self):
captured = {}
runner = CliRunner()
mock_model = MagicMock()

def capture_prompt(prompt):
captured["prompt"] = prompt
result = MagicMock()
result.text = "✨ feat(ui): add button"
return result

mock_model.generate_content.side_effect = capture_prompt

with patch("smart_commit.main.load_config", return_value=_make_config(auto_emoji=True)), \
patch("smart_commit.main.initialize", return_value=mock_model), \
patch("smart_commit.main.get_git_diff", return_value="diff content"), \
patch("smart_commit.main.get_staged_files", return_value=["ui.py"]), \
patch("smart_commit.main.commit_with_message"):
runner.invoke(cli, ["commit", "--no-confirm"])

assert "<emoji>" in captured.get("prompt", "")

def test_config_auto_emoji_false_disables_emoji(self):
captured = {}
runner = CliRunner()
mock_model = MagicMock()

def capture_prompt(prompt):
captured["prompt"] = prompt
result = MagicMock()
result.text = "feat(ui): add button"
return result

mock_model.generate_content.side_effect = capture_prompt

with patch("smart_commit.main.load_config", return_value=_make_config(auto_emoji=False)), \
patch("smart_commit.main.initialize", return_value=mock_model), \
patch("smart_commit.main.get_git_diff", return_value="diff content"), \
patch("smart_commit.main.get_staged_files", return_value=["ui.py"]), \
patch("smart_commit.main.commit_with_message"):
runner.invoke(cli, ["commit", "--no-confirm"])

assert "<emoji>" not in captured.get("prompt", "")

def test_no_emoji_flag_overrides_auto_emoji_true(self):
"""--no-emoji takes precedence even when config has auto_emoji=True."""
captured = {}
runner = CliRunner()
mock_model = MagicMock()

def capture_prompt(prompt):
captured["prompt"] = prompt
result = MagicMock()
result.text = "feat(ui): add button"
return result

mock_model.generate_content.side_effect = capture_prompt

with patch("smart_commit.main.load_config", return_value=_make_config(auto_emoji=True)), \
patch("smart_commit.main.initialize", return_value=mock_model), \
patch("smart_commit.main.get_git_diff", return_value="diff content"), \
patch("smart_commit.main.get_staged_files", return_value=["ui.py"]), \
patch("smart_commit.main.commit_with_message"):
runner.invoke(cli, ["commit", "--no-confirm", "--no-emoji"])

assert "<emoji>" not in captured.get("prompt", "")

def test_commit_still_succeeds_with_no_emoji(self):
runner = CliRunner()
with patch("smart_commit.main.load_config", return_value=_make_config()), \
patch("smart_commit.main.initialize", return_value=_make_model()), \
patch("smart_commit.main.get_git_diff", return_value="diff content"), \
patch("smart_commit.main.get_staged_files", return_value=["ui.py"]), \
patch("smart_commit.main.commit_with_message") as mock_commit:
result = runner.invoke(cli, ["commit", "--no-confirm", "--no-emoji"])
assert result.exit_code == 0
mock_commit.assert_called_once()