diff --git a/README.md b/README.md index 911704f..b7923a3 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,19 @@ Initialize codemcp with $PROJECT_DIR Where `$PROJECT_DIR` is the path to the project you want to work on. Then chat with Claude about what changes you want to make to the project. -Every time codemcp makes a change to your code, it will generate a commit. + +### Git Commit Behavior + +By default, codemcp will now skip creating git commits when making changes to your code. This allows for faster iterations during development while still maintaining the ability to use git when needed. + +You can control this behavior in your `codemcp.toml` file: + +```toml +[git] +no_commit = true # Set to false to enable automatic git commits +``` + +If you prefer the original behavior where every change generates a commit, set `no_commit = false` in your config. To see some sample transcripts using this tool, check out: @@ -118,7 +130,7 @@ To see some sample transcripts using this tool, check out: - [Fix failing tests](https://claude.ai/share/2b7161ef-5683-4261-ad45-fabc3708f950) - [Do a refactor](https://claude.ai/share/f005b43c-a657-43e5-ad9f-4714a5cd746f) -codemcp will generate a commit per chat and amend it as it is working on your feature. +When git commits are enabled, codemcp will generate a commit per chat and amend it as it is working on your feature. ## Philosophy diff --git a/codemcp.toml b/codemcp.toml index 1081c48..a3ac41c 100644 --- a/codemcp.toml +++ b/codemcp.toml @@ -34,3 +34,6 @@ doc = "Accepts a pytest-style test selector as an argument to run a specific tes [commands.accept] command = ["env", "EXPECTTEST_ACCEPT=1", "./run_test.sh"] doc = "Updates expecttest failing tests with their new values, akin to running with EXPECTTEST_ACCEPT=1. Accepts a pytest-style test selector as an argument to run a specific test." + +[git] +no_commit = true # Set to false to enable automatic git commits diff --git a/codemcp/git_commit.py b/codemcp/git_commit.py index e4b7e19..79e48d6 100644 --- a/codemcp/git_commit.py +++ b/codemcp/git_commit.py @@ -3,6 +3,7 @@ import logging import os import re +from typing import Optional, Tuple from .git_message import ( update_commit_message_with_description, @@ -13,6 +14,7 @@ get_head_commit_message, is_git_repository, ) +from .git_config import get_git_config_no_commit from .shell import run_command __all__ = ["commit_changes", "create_commit_reference"] @@ -24,7 +26,7 @@ async def create_commit_reference( path: str, chat_id: str, commit_msg: str, -) -> tuple[str, str]: +) -> Tuple[str, str]: # (message, commit_hash) """Create a Git commit without advancing HEAD and store it in a reference. This function creates a commit using Git plumbing commands and stores it @@ -159,7 +161,8 @@ async def commit_changes( description: str, chat_id: str, commit_all: bool = False, -) -> tuple[bool, str]: + no_commit: Optional[bool] = None, +) -> Tuple[bool, str]: # (success, message) """Commit changes to a file, directory, or all files in Git. This function is a slight misnomer, as we may not actually create a new @@ -178,18 +181,29 @@ async def commit_changes( description: Commit message describing the change chat_id: The unique ID of the current chat session commit_all: Whether to commit all changes in the repository + no_commit: Whether to skip creating a git commit Returns: A tuple of (success, message) """ + # If no_commit is None, get the default value from config + if no_commit is None: + no_commit = get_git_config_no_commit(path) + log.debug( - "commit_changes(%s, %s, %s, commit_all=%s)", + "commit_changes(%s, %s, %s, commit_all=%s, no_commit=%s)", path, description, chat_id, commit_all, + no_commit, ) + + # If no_commit is True, skip git operations + if no_commit: + return True, "Changes saved without git commit (no_commit=True)" + # First, check if this is a git repository if not await is_git_repository(path): return False, f"Path '{path}' is not in a Git repository" diff --git a/codemcp/git_config.py b/codemcp/git_config.py new file mode 100644 index 0000000..38c4d5b --- /dev/null +++ b/codemcp/git_config.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 + +import logging +import os +from pathlib import Path +from typing import Optional + +# Set up logger first +log = logging.getLogger(__name__) + +# Use tomllib for Python 3.11+, fall back to tomli +tomllib = None +try: + import tomllib +except ImportError: + try: + import tomli as tomllib + except ImportError: + # If neither is available, we'll handle this gracefully + log.warning("Neither tomllib nor tomli is available, will use default config values") + +def get_git_config_no_commit(path: Optional[str] = None) -> bool: + """Get the no_commit configuration value from codemcp.toml. + + Args: + path: Optional path to use for finding the config file. + If not provided, will search from current directory upwards. + + Returns: + True if no_commit is enabled (default), False otherwise + """ + try: + config_path = find_config_file(path) + if not config_path: + # Default to True if no config file found + return True + + with open(config_path, "rb") as f: + if tomllib is None: + # If tomllib is not available, return default + log.warning("Could not load TOML library, using default no_commit=True") + return True + + # Load the config file + config = tomllib.load(f) + + # Check if git.no_commit is specified in the config + if "git" in config and "no_commit" in config["git"]: + return bool(config["git"]["no_commit"]) + + # Default to True if not specified + return True + except Exception as e: + log.warning(f"Error reading git.no_commit from config: {e}") + # Default to True on error + return True + +def find_config_file(start_path: Optional[str] = None) -> Optional[str]: + """Find the codemcp.toml file by searching upwards from the given path. + + Args: + start_path: Path to start searching from. If None, uses current directory. + + Returns: + Path to the config file, or None if not found + """ + if start_path is None: + start_path = os.getcwd() + + current_dir = Path(start_path).resolve() + + # Search up to the root directory + while current_dir != current_dir.parent: + config_path = current_dir / "codemcp.toml" + if config_path.exists(): + return str(config_path) + current_dir = current_dir.parent + + return None diff --git a/codemcp/tools/chmod.py b/codemcp/tools/chmod.py index 5a3a561..d464798 100644 --- a/codemcp/tools/chmod.py +++ b/codemcp/tools/chmod.py @@ -6,7 +6,7 @@ from ..common import normalize_file_path from ..git import commit_changes -from ..mcp import mcp +from ..mcp import mcp # type: ignore # FastMCP instance from ..shell import run_command from .commit_utils import append_commit_hash @@ -29,12 +29,13 @@ """ -@mcp.tool() +@mcp.tool() # type: ignore async def chmod( path: str, mode: str, chat_id: str | None = None, commit_hash: str | None = None, + no_commit: bool = True, ) -> str: """Changes file permissions using chmod. Unlike standard chmod, this tool only supports a+x (add executable permission) and a-x (remove executable permission), because these @@ -45,6 +46,7 @@ async def chmod( mode: The chmod mode to apply, only "a+x" and "a-x" are supported chat_id: The unique ID to identify the chat session commit_hash: Optional Git commit hash for version tracking + no_commit: Whether to skip creating a git commit (default: True) Example: chmod a+x path/to/file # Makes a file executable by all users @@ -110,11 +112,12 @@ async def chmod( ) action_msg = f"Removed executable permission from file '{path}'" - # Commit the changes + # Commit the changes (if no_commit is False) success, commit_message = await commit_changes( directory, description, chat_id, + no_commit=no_commit, ) if not success: diff --git a/codemcp/tools/edit_file.py b/codemcp/tools/edit_file.py index 66141fb..ca9f051 100644 --- a/codemcp/tools/edit_file.py +++ b/codemcp/tools/edit_file.py @@ -754,6 +754,7 @@ async def edit_file( description: str | None = None, chat_id: str | None = None, commit_hash: str | None = None, + no_commit: bool = True, ) -> str: """This is a tool for editing files. For larger edits, use the WriteFile tool to overwrite files. Provide a short description of the change. @@ -808,6 +809,7 @@ async def edit_file( description: Short description of the change chat_id: The unique ID of the current chat session commit_hash: Optional Git commit hash for version tracking + no_commit: Whether to skip creating a git commit (default: True) Returns: A success message @@ -840,11 +842,12 @@ async def edit_file( if not is_valid: raise ValueError(error_message) - # Handle creating a new file - skip commit_pending_changes for non-existent files + # Handle creating a new file creating_new_file = old_string == "" and not os.path.exists(full_file_path) - if not creating_new_file: - # Only check commit_pending_changes for existing files + # Skip git tracking check if no_commit is True + if not creating_new_file and not no_commit: + # Only check commit_pending_changes for existing files when we want to commit is_tracked, track_error = await check_git_tracking_for_existing_file( full_file_path, chat_id=chat_id, @@ -875,8 +878,8 @@ async def edit_file( os.makedirs(directory, exist_ok=True) await write_text_content(full_file_path, new_string) - # Commit the changes - success, message = await commit_changes(full_file_path, description, chat_id) + # Commit the changes (if no_commit is False) + success, message = await commit_changes(full_file_path, description, chat_id, no_commit=no_commit) git_message = "" if success: git_message = f"\nChanges committed to git: {description}" @@ -970,9 +973,9 @@ async def edit_file( # Generate a snippet of the edited file to show in the response snippet = get_edit_snippet(content, old_string, new_string) - # Commit the changes + # Commit the changes (if no_commit is False) git_message = "" - success, message = await commit_changes(full_file_path, description, chat_id) + success, message = await commit_changes(full_file_path, description, chat_id, no_commit=no_commit) if success: git_message = f"\n\nChanges committed to git: {description}" # Include any extra details like previous commit hash if present in the message diff --git a/codemcp/tools/mv.py b/codemcp/tools/mv.py index fef0297..1c16bcd 100644 --- a/codemcp/tools/mv.py +++ b/codemcp/tools/mv.py @@ -22,6 +22,7 @@ async def mv( description: str | None = None, chat_id: str | None = None, commit_hash: str | None = None, + no_commit: bool = True, ) -> str: """Moves a file using git mv and commits the change. Provide a short description of why the file is being moved. @@ -37,6 +38,7 @@ async def mv( description: Short description of why the file is being moved chat_id: The unique ID to identify the chat session commit_hash: Optional Git commit hash for version tracking + no_commit: Whether to skip creating a git commit (default: True) Returns: A string containing the result of the move operation @@ -135,13 +137,14 @@ async def mv( text=True, ) - # Commit the changes + # Commit the changes (if no_commit is False) logging.info(f"Committing move of file: {source_rel_path} -> {target_rel_path}") success, commit_message = await commit_changes( git_root_resolved, f"Move {source_rel_path} -> {target_rel_path}: {description}", chat_id, commit_all=False, # No need for commit_all since git mv already stages the change + no_commit=no_commit, ) result = "" diff --git a/codemcp/tools/rm.py b/codemcp/tools/rm.py index 147d187..1279e7f 100644 --- a/codemcp/tools/rm.py +++ b/codemcp/tools/rm.py @@ -19,7 +19,8 @@ @mcp.tool() async def rm( - path: str, description: str, chat_id: str, commit_hash: Optional[str] = None + path: str, description: str, chat_id: str, commit_hash: Optional[str] = None, + no_commit: bool = True, ) -> str: """Removes a file using git rm and commits the change. Provide a short description of why the file is being removed. @@ -33,6 +34,7 @@ async def rm( description: Short description of why the file is being removed chat_id: The unique ID to identify the chat session commit_hash: Optional Git commit hash for version tracking + no_commit: Whether to skip creating a git commit (default: True) Returns: A success message @@ -100,13 +102,14 @@ async def rm( text=True, ) - # Commit the changes + # Commit the changes (if no_commit is False) logging.info(f"Committing removal of file: {rel_path}") success, commit_message = await commit_changes( git_root_resolved, f"Remove {rel_path}: {description}", chat_id, commit_all=False, # No need for commit_all since git rm already stages the change + no_commit=no_commit, ) result = "" diff --git a/codemcp/tools/write_file.py b/codemcp/tools/write_file.py index 25e6725..7eade57 100644 --- a/codemcp/tools/write_file.py +++ b/codemcp/tools/write_file.py @@ -3,6 +3,7 @@ import json import logging import os +from typing import Any, Dict, List, Optional, Union from ..code_command import run_formatter_without_commit from ..common import normalize_file_path @@ -21,13 +22,15 @@ ] +# type: ignore @mcp.tool() async def write_file( path: str, - content: str | dict | list | None = None, - description: str | None = None, - chat_id: str | None = None, - commit_hash: str | None = None, + content: Union[str, Dict[str, Any], List[Any], None] = None, + description: Optional[str] = None, + chat_id: Optional[str] = None, + commit_hash: Optional[str] = None, + no_commit: bool = True, ) -> str: """Write a file to the local filesystem. Overwrites the existing file if there is one. Provide a short description of the change. @@ -45,6 +48,7 @@ async def write_file( description: Short description of the change chat_id: The unique ID of the current chat session commit_hash: Optional Git commit hash for version tracking + no_commit: Whether to skip creating a git commit (default: True) Returns: A success message @@ -80,10 +84,11 @@ async def write_file( if not is_valid: raise ValueError(error_message) - # Check git tracking for existing files - is_tracked, track_error = await check_git_tracking_for_existing_file(path, chat_id) - if not is_tracked: - raise ValueError(track_error) + # Check git tracking for existing files only if we're going to commit + if not no_commit and os.path.exists(path): + is_tracked, track_error = await check_git_tracking_for_existing_file(path, chat_id) + if not is_tracked: + raise ValueError(track_error) # Determine line endings old_file_exists = os.path.exists(path) @@ -111,9 +116,9 @@ async def write_file( if not "No format command configured" in formatter_output: logging.warning(f"Failed to auto-format {path}: {formatter_output}") - # Commit the changes + # Commit the changes (if no_commit is False) git_message = "" - success, message = await commit_changes(path, description, chat_id) + success, message = await commit_changes(path, description, chat_id, no_commit=no_commit) if success: git_message = f"\nChanges committed to git: {description}" else: