Skip to content

Commit e9fa1a0

Browse files
committed
feat: add Mv subcommand
Add a Mv subcommand, similar to Rm, at codemcp/tools/mv.py. Check codemcp/tools/rm.py for the pattern. Add an e2e test too. ```git-revs 20cbc73 (Base revision) bde8a52 Add new mv.py file with mv_file function for moving files with git mv 13eddd1 Update __init__.py to import and expose mv_file function 8297241 Add MV subcommand to system prompt in init_project.py 2f8716a Update Summary section to include MV subtool and its parameters 26aebd5 Add end-to-end test for MV subtool 86795bf Auto-commit lint changes a60fa1a Add import for mv_file function 163d54e Add MV subtool to expected parameters c1b50bf Add implementation for MV subtool in codemcp function 5ddfb49 Add source_path and target_path parameters to codemcp function signature 88d5f10 Add source_path and target_path to provided_params HEAD Auto-commit lint changes ``` codemcp-id: 291-feat-add-mv-subcommand ghstack-source-id: fd3e246 Pull-Request-resolved: #284
1 parent 8bada8b commit e9fa1a0

File tree

6 files changed

+378
-3
lines changed

6 files changed

+378
-3
lines changed

codemcp/code_command.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ async def run_formatter_without_commit(file_path: str) -> Tuple[bool, str]:
239239
return False, "No format command configured in codemcp.toml"
240240

241241
# Use relative path from project_dir for the formatting command
242-
rel_path = os.path.relpath(file_path, project_dir)
242+
os.path.relpath(file_path, project_dir)
243243

244244
# Run the formatter with the specific file path
245245
command = format_command

codemcp/main.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from .tools.grep import grep_files
2323
from .tools.init_project import init_project
2424
from .tools.ls import ls_directory
25+
from .tools.mv import mv_file
2526
from .tools.read_file import read_file_content
2627
from .tools.rm import rm_file
2728
from .tools.run_command import run_command
@@ -89,6 +90,8 @@ async def codemcp(
8990
| None = None, # Whether to reuse the chat ID from the HEAD commit
9091
thought: str | None = None, # Added for Think tool
9192
mode: str | None = None, # Added for Chmod tool
93+
source_path: str | None = None, # Added for MV tool
94+
target_path: str | None = None, # Added for MV tool
9295
commit_hash: str | None = None, # Added for Git commit hash tracking
9396
) -> str:
9497
# NOTE: Do NOT add more documentation to this docblock when you add a new
@@ -142,6 +145,13 @@ async def codemcp(
142145
"Grep": {"pattern", "path", "include", "chat_id", "commit_hash"},
143146
"Glob": {"pattern", "path", "limit", "offset", "chat_id", "commit_hash"},
144147
"RM": {"path", "description", "chat_id", "commit_hash"},
148+
"MV": {
149+
"source_path",
150+
"target_path",
151+
"description",
152+
"chat_id",
153+
"commit_hash",
154+
},
145155
"Think": {"thought", "chat_id", "commit_hash"},
146156
"Chmod": {"path", "mode", "chat_id", "commit_hash"},
147157
}
@@ -198,6 +208,9 @@ def normalize_newlines(s: object) -> object:
198208
"thought": thought,
199209
# Chmod tool parameter
200210
"mode": mode,
211+
# MV tool parameters
212+
"source_path": source_path,
213+
"target_path": target_path,
201214
# Git commit hash tracking
202215
"commit_hash": commit_hash,
203216
}.items()
@@ -427,6 +440,32 @@ def normalize_newlines(s: object) -> object:
427440
result, new_commit_hash = await append_commit_hash(result, normalized_path)
428441
return result
429442

443+
if subtool == "MV":
444+
# Extract parameters specific to MV
445+
source_path = provided_params.get("source_path")
446+
target_path = provided_params.get("target_path")
447+
448+
if source_path is None:
449+
raise ValueError("source_path is required for MV subtool")
450+
if target_path is None:
451+
raise ValueError("target_path is required for MV subtool")
452+
if description is None:
453+
raise ValueError("description is required for MV subtool")
454+
455+
# Normalize the paths (expand tilde) before proceeding
456+
normalized_source_path = normalize_file_path(source_path)
457+
normalized_target_path = normalize_file_path(target_path)
458+
459+
if chat_id is None:
460+
raise ValueError("chat_id is required for MV subtool")
461+
result = await mv_file(
462+
normalized_source_path, normalized_target_path, description, chat_id
463+
)
464+
result, new_commit_hash = await append_commit_hash(
465+
result, normalized_source_path
466+
)
467+
return result
468+
430469
if subtool == "Think":
431470
if thought is None:
432471
raise ValueError("thought is required for Think subtool")

codemcp/tools/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from .git_diff import git_diff
77
from .git_log import git_log
88
from .git_show import git_show
9+
from .mv import mv_file
910
from .rm import rm_file
1011

1112
__all__ = [
@@ -14,5 +15,6 @@
1415
"git_diff",
1516
"git_log",
1617
"git_show",
18+
"mv_file",
1719
"rm_file",
1820
]

codemcp/tools/init_project.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,22 @@ async def init_project(
449449
description: Short description of why the file is being removed
450450
chat_id: The unique ID to identify the chat session
451451
452+
## MV chat_id source_path target_path description
453+
454+
Moves a file using git mv and commits the change.
455+
Provide a short description of why the file is being moved.
456+
457+
Before using this tool:
458+
1. Ensure the source file exists and is tracked by git
459+
2. Ensure the target directory exists within the git repository
460+
3. Provide a meaningful description of why the file is being moved
461+
462+
Args:
463+
source_path: The path to the file to move (can be relative to the project root or absolute)
464+
target_path: The destination path where the file should be moved to (can be relative to the project root or absolute)
465+
description: Short description of why the file is being moved
466+
chat_id: The unique ID to identify the chat session
467+
452468
## Chmod chat_id path mode
453469
454470
Changes file permissions using chmod. Unlike standard chmod, this tool only supports
@@ -467,14 +483,16 @@ async def init_project(
467483
## Summary
468484
469485
Args:
470-
subtool: The subtool to execute (ReadFile, WriteFile, EditFile, LS, InitProject, UserPrompt, RunCommand, RM, Think, Chmod)
486+
subtool: The subtool to execute (ReadFile, WriteFile, EditFile, LS, InitProject, UserPrompt, RunCommand, RM, MV, Think, Chmod)
471487
path: The path to the file or directory to operate on
472488
content: Content for WriteFile subtool (any type will be serialized to string if needed)
473489
old_string: String to replace for EditFile subtool
474490
new_string: Replacement string for EditFile subtool
475491
offset: Line offset for ReadFile subtool
476492
limit: Line limit for ReadFile subtool
477-
description: Short description of the change (for WriteFile/EditFile/RM)
493+
description: Short description of the change (for WriteFile/EditFile/RM/MV)
494+
source_path: The path to the source file for MV subtool
495+
target_path: The destination path for MV subtool
478496
arguments: A string containing space-separated arguments for RunCommand subtool
479497
user_prompt: The user's verbatim text (for UserPrompt subtool)
480498
thought: The thought content (for Think subtool)

codemcp/tools/mv.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
#!/usr/bin/env python3
2+
3+
import logging
4+
import os
5+
import pathlib
6+
7+
from ..common import normalize_file_path
8+
from ..git import commit_changes, get_repository_root
9+
from ..shell import run_command
10+
11+
__all__ = [
12+
"mv_file",
13+
]
14+
15+
16+
async def mv_file(
17+
source_path: str,
18+
target_path: str,
19+
description: str,
20+
chat_id: str = "",
21+
) -> str:
22+
"""Move a file using git mv.
23+
24+
Args:
25+
source_path: The path to the source file to move (can be absolute or relative to repository root)
26+
target_path: The path to the target location (can be absolute or relative to repository root)
27+
description: Short description of why the file is being moved
28+
chat_id: The unique ID of the current chat session
29+
30+
Returns:
31+
A string containing the result of the move operation
32+
"""
33+
# Use the directory from the path as our starting point for source
34+
source_path = normalize_file_path(source_path)
35+
source_dir_path = (
36+
os.path.dirname(source_path) if os.path.dirname(source_path) else "."
37+
)
38+
39+
# Normalize target path as well
40+
target_path = normalize_file_path(target_path)
41+
42+
# Validations for source file
43+
if not os.path.exists(source_path):
44+
raise FileNotFoundError(f"Source file does not exist: {source_path}")
45+
46+
if not os.path.isfile(source_path):
47+
raise ValueError(f"Source path is not a file: {source_path}")
48+
49+
# Get git repository root
50+
git_root = await get_repository_root(source_dir_path)
51+
# Ensure paths are absolute and resolve any symlinks
52+
source_path_resolved = os.path.realpath(source_path)
53+
git_root_resolved = os.path.realpath(git_root)
54+
target_path_resolved = (
55+
os.path.realpath(target_path)
56+
if os.path.exists(os.path.dirname(target_path))
57+
else target_path
58+
)
59+
60+
# Use pathlib to check if the source file is within the git repo
61+
# This handles path traversal correctly on all platforms
62+
try:
63+
# Convert to Path objects
64+
source_path_obj = pathlib.Path(source_path_resolved)
65+
git_root_obj = pathlib.Path(git_root_resolved)
66+
67+
# Check if file is inside the git repo using Path.relative_to
68+
# This will raise ValueError if source_path is not inside git_root
69+
source_path_obj.relative_to(git_root_obj)
70+
except ValueError:
71+
msg = (
72+
f"Source path {source_path} is not within the git repository at {git_root}"
73+
)
74+
logging.error(msg)
75+
raise ValueError(msg)
76+
77+
# Check if target directory exists and is within the git repo
78+
target_dir = os.path.dirname(target_path)
79+
if target_dir and not os.path.exists(target_dir):
80+
raise FileNotFoundError(f"Target directory does not exist: {target_dir}")
81+
82+
try:
83+
# Convert to Path objects
84+
target_dir_obj = pathlib.Path(
85+
os.path.realpath(target_dir) if target_dir else git_root_resolved
86+
)
87+
# Check if target directory is inside the git repo
88+
target_dir_obj.relative_to(git_root_obj)
89+
except ValueError:
90+
msg = f"Target directory {target_dir} is not within the git repository at {git_root}"
91+
logging.error(msg)
92+
raise ValueError(msg)
93+
94+
# Get the relative paths using pathlib
95+
source_rel_path = os.path.relpath(source_path_resolved, git_root_resolved)
96+
target_rel_path = os.path.relpath(
97+
target_path_resolved
98+
if os.path.exists(os.path.dirname(target_path))
99+
else os.path.join(git_root_resolved, os.path.basename(target_path)),
100+
git_root_resolved,
101+
)
102+
103+
logging.info(f"Using relative paths: {source_rel_path} -> {target_rel_path}")
104+
105+
# Check if the source file is tracked by git from the git root
106+
await run_command(
107+
["git", "ls-files", "--error-unmatch", source_rel_path],
108+
cwd=git_root_resolved,
109+
check=True,
110+
capture_output=True,
111+
text=True,
112+
)
113+
114+
# If we get here, the file is tracked by git, so we can move it
115+
await run_command(
116+
["git", "mv", source_rel_path, target_rel_path],
117+
cwd=git_root_resolved,
118+
check=True,
119+
capture_output=True,
120+
text=True,
121+
)
122+
123+
# Commit the changes
124+
logging.info(f"Committing move of file: {source_rel_path} -> {target_rel_path}")
125+
success, commit_message = await commit_changes(
126+
git_root_resolved,
127+
f"Move {source_rel_path} -> {target_rel_path}: {description}",
128+
chat_id,
129+
commit_all=False, # No need for commit_all since git mv already stages the change
130+
)
131+
132+
if success:
133+
return f"Successfully moved file from {source_rel_path} to {target_rel_path}."
134+
else:
135+
return f"File was moved from {source_rel_path} to {target_rel_path} but failed to commit: {commit_message}"

0 commit comments

Comments
 (0)