From 2089656b8e86db7229a936b9ae6ba6acf7bea39e Mon Sep 17 00:00:00 2001 From: camUrban Date: Wed, 4 Mar 2026 17:01:39 -0500 Subject: [PATCH] feat: add configurable commit message styles - Add style management subcommand (list, show, set, clear, create, delete) - Bundle conventional and simple built-in style templates - Support --style CLI flag for one-off style override - Allow user-defined custom styles in ~/.claude-commit/styles/ - Style instructions override auto-detection from git history --- pyproject.toml | 3 + src/claude_commit/config.py | 90 ++++++++++ src/claude_commit/main.py | 204 +++++++++++++++++++++- src/claude_commit/styles/conventional.txt | 40 +++++ src/claude_commit/styles/simple.txt | 24 +++ 5 files changed, 354 insertions(+), 7 deletions(-) create mode 100644 src/claude_commit/styles/conventional.txt create mode 100644 src/claude_commit/styles/simple.txt diff --git a/pyproject.toml b/pyproject.toml index e1dd7d3..f86779a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,9 @@ claude-commit = "claude_commit.main:main" [tool.setuptools.packages.find] where = ["src"] +[tool.setuptools.package-data] +claude_commit = ["styles/*.txt"] + [tool.black] line-length = 100 target-version = ['py39'] diff --git a/src/claude_commit/config.py b/src/claude_commit/config.py index dd459c0..04b4e87 100644 --- a/src/claude_commit/config.py +++ b/src/claude_commit/config.py @@ -106,6 +106,96 @@ def list_aliases(self) -> Dict[str, str]: """List all aliases""" return self.aliases.copy() + # --- Style management --- + + def get_style(self) -> Optional[str]: + """Returns the configured default style name, or None (auto-detect)""" + return self._config.get("style") + + def set_style(self, style: str): + """Validate that the style exists, then save to config""" + available = self.list_styles() + if style not in available: + raise ValueError( + f"Style '{style}' not found. Available: {', '.join(sorted(available))}" + ) + self._config["style"] = style + self._save_config() + + def clear_style(self): + """Remove style key, revert to auto-detect""" + self._config.pop("style", None) + self._save_config() + + def list_styles(self) -> Dict[str, str]: + """Return {name: path} for all available styles (user styles override bundled)""" + styles: Dict[str, str] = {} + + # Bundled styles first + bundled_dir = self.get_bundled_styles_dir() + if bundled_dir.is_dir(): + for f in sorted(bundled_dir.glob("*.txt")): + styles[f.stem] = str(f) + + # User styles override bundled + user_dir = self.get_user_styles_dir() + if user_dir.is_dir(): + for f in sorted(user_dir.glob("*.txt")): + styles[f.stem] = str(f) + + return styles + + def get_style_content(self, name: str) -> Optional[str]: + """Resolve style name to file, read and return its content""" + styles = self.list_styles() + path = styles.get(name) + if path is None: + return None + return Path(path).read_text(encoding="utf-8") + + def get_user_styles_dir(self) -> Path: + """Return ~/.claude-commit/styles/""" + return Path.home() / ".claude-commit" / "styles" + + def get_bundled_styles_dir(self) -> Path: + """Return the bundled styles directory""" + return Path(__file__).parent / "styles" + + def create_custom_style(self, name: str) -> Path: + """Create a template style file at ~/.claude-commit/styles/.txt""" + if not name or "/" in name or "\\" in name or ".." in name: + raise ValueError(f"Invalid style name: '{name}'") + dest = self.get_user_styles_dir() / f"{name}.txt" + dest.parent.mkdir(parents=True, exist_ok=True) + if dest.exists(): + raise FileExistsError(f"Style '{name}' already exists at {dest}") + template = ( + f"# Custom style: {name}\n" + "# Edit this file to define your commit message style.\n" + "# Do NOT check git history for style detection — use this style instead.\n" + "\n" + "# Describe the format, rules, and examples for your preferred commit messages.\n" + "# Everything in this file will be injected as style instructions.\n" + ) + dest.write_text(template, encoding="utf-8") + return dest + + def delete_custom_style(self, name: str) -> bool: + """Delete a user style file. Returns True if deleted.""" + user_dir = self.get_user_styles_dir() + target = user_dir / f"{name}.txt" + if target.is_file(): + target.unlink() + return True + return False + + def is_bundled_style(self, name: str) -> bool: + """Check if a style name is a bundled (non-deletable) style""" + bundled_dir = self.get_bundled_styles_dir() + return (bundled_dir / f"{name}.txt").is_file() + + # --- First-run helpers --- + def is_first_run(self) -> bool: """Check if this is the first run""" return not self.config_path.exists() diff --git a/src/claude_commit/main.py b/src/claude_commit/main.py index 77248f1..977cff1 100644 --- a/src/claude_commit/main.py +++ b/src/claude_commit/main.py @@ -205,6 +205,7 @@ async def generate_commit_message( staged_only: bool = True, verbose: bool = False, max_diff_lines: int = 5000, + style_prompt: Optional[str] = None, ) -> Optional[str]: """ Generate a commit message based on current git changes. @@ -214,6 +215,7 @@ async def generate_commit_message( staged_only: Only analyze staged changes (git diff --cached) verbose: Print detailed information max_diff_lines: Maximum number of diff lines to analyze + style_prompt: Optional style instructions to override auto-detect Returns: Generated commit message or None if failed @@ -226,7 +228,34 @@ async def generate_commit_message( f"[blue]📝 Mode:[/blue] {'staged changes only' if staged_only else 'all changes'}" ) + # Build system prompt, optionally with style override + effective_system_prompt = SYSTEM_PROMPT + if style_prompt: + effective_system_prompt += f""" + + +The user has configured an explicit commit message style. Follow these style instructions EXACTLY and do NOT check git history for style detection. + +{style_prompt} + +""" + # Build the analysis prompt - give AI freedom to explore + if style_prompt: + step1 = ( + "1. **Use the configured style** — an explicit style has been provided in " + "the system prompt. Do NOT check git history for style. Follow the style " + "instructions exactly." + ) + else: + step1 = ( + "1. **Check commit history style** (choose ONE approach):\n" + " - Run `git log -3 --oneline` to see recent commits\n" + " - This shows you: gitmoji usage, language (Chinese/English), " + "format (conventional commits, etc.)\n" + " - **MUST follow the same style/format/language as existing commits**" + ) + prompt = f"""Analyze the git repository changes and generate an excellent commit message. @@ -239,10 +268,7 @@ async def generate_commit_message( Follow these steps to generate an excellent commit message: -1. **Check commit history style** (choose ONE approach): - - Run `git log -3 --oneline` to see recent commits - - This shows you: gitmoji usage, language (Chinese/English), format (conventional commits, etc.) - - **MUST follow the same style/format/language as existing commits** +{step1} 2. **Analyze the changes**: - Run `git status` to see which files changed @@ -292,7 +318,7 @@ async def generate_commit_message( """ try: options = ClaudeAgentOptions( - system_prompt=SYSTEM_PROMPT, + system_prompt=effective_system_prompt, allowed_tools=[ "Bash", # Run shell commands "Read", # Read file contents @@ -810,6 +836,125 @@ def handle_alias_command(args): sys.exit(1) +def handle_style_command(args): + """Handle style management subcommands""" + config = Config() + + if len(args) == 0 or args[0] == "list": + styles = config.list_styles() + current = config.get_style() + bundled_dir = config.get_bundled_styles_dir() + + if not styles: + print("📋 No styles available") + return + + print("📋 Available commit message styles:") + print() + for name, path in sorted(styles.items()): + source = "(built-in)" if Path(path).is_relative_to(bundled_dir) else "(custom)" + marker = " ← default" if name == current else "" + print(f" {name:<20} {source}{marker}") + + print() + if current: + print(f"💡 Current default: {current}") + else: + print("💡 No default style set (auto-detect from git history)") + print() + print(" Set default: claude-commit style set ") + print(" Override once: claude-commit --style ") + print(" Create custom: claude-commit style create ") + + elif args[0] == "show": + if len(args) < 2: + print("❌ Error: Please provide a style name", file=sys.stderr) + print(" Usage: claude-commit style show ", file=sys.stderr) + sys.exit(1) + + name = args[1] + content = config.get_style_content(name) + if content is None: + print(f"❌ Style '{name}' not found", file=sys.stderr) + print(" Run 'claude-commit style list' to see available styles", file=sys.stderr) + sys.exit(1) + + styles = config.list_styles() + path = styles[name] + print(f"📄 Style: {name} ({path})") + print() + print(content) + + elif args[0] == "set": + if len(args) < 2: + print("❌ Error: Please provide a style name", file=sys.stderr) + print(" Usage: claude-commit style set ", file=sys.stderr) + sys.exit(1) + + name = args[1] + try: + config.set_style(name) + print(f"✅ Default style set to '{name}'") + except ValueError as e: + print(f"❌ {e}", file=sys.stderr) + sys.exit(1) + + elif args[0] == "clear": + config.clear_style() + print("✅ Default style cleared (reverted to auto-detect)") + + elif args[0] == "create": + if len(args) < 2: + print("❌ Error: Please provide a style name", file=sys.stderr) + print(" Usage: claude-commit style create ", file=sys.stderr) + sys.exit(1) + + name = args[1] + try: + path = config.create_custom_style(name) + print(f"✅ Created style template: {path}") + print() + print(" Edit the file to define your commit message style, then:") + print(f" claude-commit style set {name}") + except FileExistsError as e: + print(f"❌ {e}", file=sys.stderr) + sys.exit(1) + + elif args[0] == "delete": + if len(args) < 2: + print("❌ Error: Please provide a style name", file=sys.stderr) + print(" Usage: claude-commit style delete ", file=sys.stderr) + sys.exit(1) + + name = args[1] + if config.is_bundled_style(name): + # Check if there's also a user override + user_file = config.get_user_styles_dir() / f"{name}.txt" + if user_file.is_file(): + user_file.unlink() + print(f"✅ Deleted user override for '{name}' (built-in version remains)") + else: + print(f"❌ Cannot delete built-in style '{name}'", file=sys.stderr) + sys.exit(1) + elif config.delete_custom_style(name): + # Clear default if this was the default + if config.get_style() == name: + config.clear_style() + print(f"✅ Deleted style '{name}' and cleared default (reverted to auto-detect)") + else: + print(f"✅ Deleted style '{name}'") + else: + print(f"❌ Style '{name}' not found in user styles", file=sys.stderr) + sys.exit(1) + + else: + print(f"❌ Unknown style command: {args[0]}", file=sys.stderr) + print( + " Available commands: list, show, set, clear, create, delete", file=sys.stderr + ) + sys.exit(1) + + def show_first_run_tip(): """Show helpful tip on first run""" welcome_text = """[bold]👋 Welcome to claude-commit![/bold] @@ -833,15 +978,19 @@ def main(): """Main CLI entry point.""" # Check if this is the first run config = Config() - if config.is_first_run() and len(sys.argv) > 1 and sys.argv[1] not in ["alias", "-h", "--help"]: + if config.is_first_run() and len(sys.argv) > 1 and sys.argv[1] not in ["alias", "style", "-h", "--help"]: show_first_run_tip() config.mark_first_run_complete() - # Check if first argument is 'alias' command + # Check if first argument is 'alias' or 'style' command if len(sys.argv) > 1 and sys.argv[1] == "alias": handle_alias_command(sys.argv[2:]) return + if len(sys.argv) > 1 and sys.argv[1] == "style": + handle_style_command(sys.argv[2:]) + return + # Resolve any aliases in the arguments resolved_args = resolve_alias(sys.argv[1:]) @@ -889,6 +1038,22 @@ def main(): # Use an alias (after install) cca (expands to: claude-commit --all) ccc (expands to: claude-commit --commit) + +Style Management: + # List available styles + claude-commit style list + + # Set default commit message style + claude-commit style set conventional + + # Override style for a single run + claude-commit --style simple + + # Create a custom style + claude-commit style create my-team + + # Revert to auto-detect from git history + claude-commit style clear """, ) @@ -933,9 +1098,33 @@ def main(): action="store_true", help="Just preview the message without any action", ) + parser.add_argument( + "-s", + "--style", + type=str, + default=None, + help="Commit message style to use (overrides config default). See: claude-commit style list", + ) args = parser.parse_args(resolved_args) + # Resolve style: CLI --style > config default > None (auto-detect) + style_name = args.style if args.style is not None else config.get_style() + style_prompt = None + if style_name: + style_prompt = config.get_style_content(style_name) + if style_prompt is None: + console.print( + f"[red]❌ Style '{style_name}' not found.[/red]", file=sys.stderr + ) + console.print( + "[yellow] Run 'claude-commit style list' to see available styles.[/yellow]", + file=sys.stderr, + ) + sys.exit(1) + if args.verbose: + console.print(f"[blue]🎨 Using style:[/blue] {style_name}") + # Run async function try: commit_message = asyncio.run( @@ -944,6 +1133,7 @@ def main(): staged_only=not args.all, verbose=args.verbose, max_diff_lines=args.max_diff_lines, + style_prompt=style_prompt, ) ) except KeyboardInterrupt: diff --git a/src/claude_commit/styles/conventional.txt b/src/claude_commit/styles/conventional.txt new file mode 100644 index 0000000..1226285 --- /dev/null +++ b/src/claude_commit/styles/conventional.txt @@ -0,0 +1,40 @@ +Use conventional commits format for the commit message. Do NOT check git history for style detection — use this style instead. + +Format: + type: brief summary (< 50 chars) + + - Detail about change 1 + - Detail about change 2 + +Allowed types: + feat: New feature + fix: Bug fix + docs: Documentation only + refactor: Code refactoring (no feature/fix) + test: Adding or updating tests + chore: Maintenance, dependencies, tooling + style: Code style / formatting + perf: Performance improvement + build: Build system or external dependencies + ci: CI/CD configuration + revert: Reverting a previous commit + +For breaking changes, append ! after the type (e.g., feat!:, fix!:). + +Examples: + feat: add user authentication system + + - Implement JWT-based authentication with refresh tokens + - Add login and registration endpoints + + fix: prevent memory leak in connection pool + + - Close idle connections after timeout + - Add connection limit configuration + +Rules: +- First line must be < 50 characters +- Use imperative mood ("add", not "added" or "adds") +- Be specific and meaningful +- Focus on WHAT changed and WHY, not HOW +- Always include bullet-point details for non-trivial changes diff --git a/src/claude_commit/styles/simple.txt b/src/claude_commit/styles/simple.txt new file mode 100644 index 0000000..48e9891 --- /dev/null +++ b/src/claude_commit/styles/simple.txt @@ -0,0 +1,24 @@ +Use a simple, plain commit message style. Do NOT check git history for style detection — use this style instead. + +Format: + A single line summarizing the change, optionally followed by bullet points for details. + +Rules: +- No type prefix (no "feat:", "fix:", etc.) +- No gitmoji or emojis +- First line < 72 characters +- Use imperative mood ("Add", not "Added" or "Adds") +- Capitalize the first word +- No period at the end of the subject line +- Be specific and meaningful +- For non-trivial changes, add bullet-point details after a blank line + +Examples: + Add user authentication system + + - Implement JWT-based authentication with refresh tokens + - Add login and registration endpoints + + Fix memory leak in connection pool + + Update README with installation instructions