diff --git a/smart_commit/main.py b/smart_commit/main.py index 97fc3df..5c8a6d8 100644 --- a/smart_commit/main.py +++ b/smart_commit/main.py @@ -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: - 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 = " 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. @@ -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. @@ -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") diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_emoji_toggle.py b/tests/test_emoji_toggle.py new file mode 100644 index 0000000..66c71ff --- /dev/null +++ b/tests/test_emoji_toggle.py @@ -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 " type(scope): subject" in prompt + + def test_emoji_off_format_line_has_no_emoji_placeholder(self): + prompt = self._call(use_emoji=False) + assert "" 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 "" 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 "" 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 "" 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 "" 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()