Skip to content

feat: make git commits optional with no_commit config setting #304

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
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
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,15 +110,27 @@ 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:

- [Implement a new feature](https://claude.ai/share/a229d291-6800-4cb8-a0df-896a47602ca0)
- [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

Expand Down
3 changes: 3 additions & 0 deletions codemcp.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
20 changes: 17 additions & 3 deletions codemcp/git_commit.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import logging
import os
import re
from typing import Optional, Tuple

from .git_message import (
update_commit_message_with_description,
Expand All @@ -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"]
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand Down
79 changes: 79 additions & 0 deletions codemcp/git_config.py
Original file line number Diff line number Diff line change
@@ -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
9 changes: 6 additions & 3 deletions codemcp/tools/chmod.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
17 changes: 10 additions & 7 deletions codemcp/tools/edit_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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}"
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion codemcp/tools/mv.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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 = ""
Expand Down
7 changes: 5 additions & 2 deletions codemcp/tools/rm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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 = ""
Expand Down
Loading
Loading