-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathoperations_extended.py
More file actions
348 lines (277 loc) · 11 KB
/
Copy pathoperations_extended.py
File metadata and controls
348 lines (277 loc) · 11 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
"""Extended git operations for MCP Git Server.
New tools added to avoid bloating operations.py (2284 lines, tracked in #139/#140).
"""
import logging
import os
import re
from ..utils.git_import import GitCommandError, Repo
logger = logging.getLogger(__name__)
DANGEROUS_CHARS = re.compile(r"[;&|`$()]")
__all__ = [
"git_restore",
"git_branch_update",
"git_branch_delete",
"git_worktree_list",
"git_worktree_remove",
"git_worktree_add",
"git_merge_tree",
"git_rm",
]
def _validate_ref(ref: str, param_name: str) -> str | None:
"""Validate a git ref for dangerous characters. Returns error string or None."""
if DANGEROUS_CHARS.search(ref):
return f"❌ Invalid characters detected in {param_name}: '{ref}'"
return None
def git_restore(
repo: Repo,
files: list[str],
staged: bool = False,
source: str | None = None,
) -> str:
"""Restore working tree files or unstage files."""
if not files:
return "❌ No files specified to restore"
if source:
error = _validate_ref(source, "source")
if error:
return error
try:
args = []
if staged:
args.append("--staged")
if source:
args.extend(["--source", source])
args.extend(files)
repo.git.restore(*args)
mode = "unstaged" if staged else "restored"
file_list = ", ".join(files[:5])
suffix = f" (+{len(files) - 5} more)" if len(files) > 5 else ""
return f"✅ Restored {len(files)} file(s): {file_list}{suffix} ({mode})"
except GitCommandError as e:
return f"❌ Restore failed: {e.stderr.decode() if isinstance(e.stderr, bytes) else e.stderr}"
except Exception as e:
return f"❌ Error during restore: {str(e)}"
def git_branch_update(
repo: Repo,
branch_name: str,
target: str | None = None,
delete: bool = False,
force: bool = False,
) -> str:
"""Force-update a branch ref or delete a branch."""
error = _validate_ref(branch_name, "branch_name")
if error:
return error
if target and delete:
return "❌ Cannot specify both target and delete"
if not target and not delete:
return "❌ Must specify either target (force-update) or delete"
if target:
error = _validate_ref(target, "target")
if error:
return error
try:
if delete:
flag = "-D" if force else "-d"
repo.git.branch(flag, branch_name)
return f"✅ Deleted branch '{branch_name}'"
else:
repo.git.branch("-f", branch_name, target)
return f"✅ Updated branch '{branch_name}' → {target}"
except GitCommandError as e:
stderr = e.stderr.decode() if isinstance(e.stderr, bytes) else e.stderr
return f"❌ Branch update failed: {stderr}"
except Exception as e:
return f"❌ Error updating branch: {str(e)}"
def git_branch_delete(
repo: Repo,
branch_name: str,
force: bool = False,
) -> str:
"""Delete a local git branch by delegating to git_branch_update.
force=True uses -D (delete even if unmerged), else -d (safe delete).
"""
return git_branch_update(repo, branch_name, delete=True, force=force)
def git_worktree_list(repo: Repo) -> str:
"""List all worktrees in the repository."""
try:
output = repo.git.worktree("list", "--porcelain")
if not output.strip():
return "No worktrees found"
worktrees = []
current = {}
for line in output.split("\n"):
if line.startswith("worktree "):
if current:
worktrees.append(current)
current = {"path": line[9:]}
elif line.startswith("HEAD "):
current["head"] = line[5:13] # Short SHA (8 chars)
elif line.startswith("branch "):
current["branch"] = line[7:].replace("refs/heads/", "")
elif line == "bare":
current["bare"] = True
elif line == "detached":
current["detached"] = True
if current:
worktrees.append(current)
lines = [f"Worktrees ({len(worktrees)}):"]
for wt in worktrees:
branch = wt.get("branch", "detached" if wt.get("detached") else "bare")
head = wt.get("head", "???")
lines.append(f" {wt['path']} [{branch}] ({head})")
return "\n".join(lines)
except GitCommandError as e:
stderr = e.stderr.decode() if isinstance(e.stderr, bytes) else e.stderr
return f"❌ Failed to list worktrees: {stderr}"
except Exception as e:
return f"❌ Error listing worktrees: {str(e)}"
def git_worktree_remove(
repo: Repo,
worktree_path: str,
force: bool = False,
) -> str:
"""Remove a worktree."""
if DANGEROUS_CHARS.search(worktree_path):
return f"❌ Invalid characters in worktree path: '{worktree_path}'"
try:
args = ["remove"]
if force:
args.append("--force")
args.append(worktree_path)
repo.git.worktree(*args)
return f"✅ Removed worktree: {worktree_path}"
except GitCommandError as e:
stderr = e.stderr.decode() if isinstance(e.stderr, bytes) else e.stderr
return f"❌ Failed to remove worktree: {stderr}"
except Exception as e:
return f"❌ Error removing worktree: {str(e)}"
def git_worktree_add(
repo: Repo,
worktree_path: str,
branch: str | None = None,
new_branch: str | None = None,
force: bool = False,
) -> str:
"""Create a new linked worktree at `worktree_path`.
Wraps `git worktree add`. Supports four modes (see issue #167):
- No branch + no new_branch: detached HEAD at current HEAD
- branch only: check out the existing branch
- new_branch only: create new_branch from HEAD (uses -b, or -B with force)
- new_branch + branch: create new_branch from the given start-point
"""
if DANGEROUS_CHARS.search(worktree_path):
return f"❌ Invalid characters in worktree path: '{worktree_path}'"
if branch and DANGEROUS_CHARS.search(branch):
return f"❌ Invalid characters in branch: '{branch}'"
if new_branch and DANGEROUS_CHARS.search(new_branch):
return f"❌ Invalid characters in new_branch: '{new_branch}'"
try:
args = ["add"]
if force:
args.append("--force")
if new_branch:
args.append("-B" if force else "-b")
args.append(new_branch)
args.append(worktree_path)
if branch:
args.append(branch)
repo.git.worktree(*args)
suffix = (
f" (new branch {new_branch})"
if new_branch
else f" (branch {branch})"
if branch
else " (detached HEAD)"
)
return f"✅ Worktree added at {worktree_path}{suffix}"
except GitCommandError as e:
stderr = e.stderr.decode() if isinstance(e.stderr, bytes) else e.stderr
return f"❌ Failed to add worktree: {stderr}"
except Exception as e:
return f"❌ Error adding worktree: {str(e)}"
def git_merge_tree(
repo: Repo,
branch1: str,
branch2: str,
) -> str:
"""Simulate a merge without modifying working tree (dry-run conflict detection).
Uses `git merge-tree --write-tree` (Git 2.38+) for three-way merge simulation.
"""
error = _validate_ref(branch1, "branch1")
if error:
return error
error = _validate_ref(branch2, "branch2")
if error:
return error
try:
output = repo.git.merge_tree("--write-tree", branch1, branch2)
return f"✅ Clean merge: {branch1} + {branch2} → no conflicts\n{output}"
except GitCommandError as e:
stdout = e.stdout.decode() if isinstance(e.stdout, bytes) else (e.stdout or "")
stderr = e.stderr.decode() if isinstance(e.stderr, bytes) else (e.stderr or "")
# Exit code 1 = conflicts detected (expected behavior, not an error)
if e.status == 1:
conflicts = [line for line in stdout.split("\n") if "CONFLICT" in line]
if conflicts:
conflict_list = "\n".join(f" - {c}" for c in conflicts)
return f"⚠️ Conflicts detected merging {branch1} + {branch2}:\n{conflict_list}"
return f"⚠️ Conflicts detected merging {branch1} + {branch2}\n{stdout}"
return f"❌ Merge-tree failed: {stderr or stdout}"
except Exception as e:
return f"❌ Error during merge-tree: {str(e)}"
# Dangerous-path blocklist for git_rm: reject wildcards, directories, bare '.'
_UNSAFE_PATH = re.compile(r"[*?\[\]{}]")
def git_rm(
repo: Repo,
file: str,
cached: bool = False,
dry_run: bool = False,
) -> str:
"""Remove a single, explicitly-named file from the working tree and index.
Safety: rejects wildcards, '.', '..', absolute paths, and trailing '/'.
Examples::
git_rm(repo, "old_module.py") # remove from tree + index
git_rm(repo, "old_module.py", cached=True) # untrack, keep on disk
git_rm(repo, "old_module.py", dry_run=True) # preview only
"""
if not file or not file.strip():
return "❌ No file specified"
file = file.strip()
# Check trailing slash before normpath strips it
if file.endswith("/"):
return "❌ Directory removal not supported. Specify an explicit file path."
# Normalize path to collapse ./, ../, and redundant separators
file = os.path.normpath(file)
# Block current/parent dir (also catches paths that normalize to . or ..)
if file in (".", ".."):
return "❌ Refusing to remove '.' or '..'. Specify an explicit file path."
# Block absolute paths to prevent accidental system file removal
if os.path.isabs(file):
return "❌ Absolute paths not allowed. Use a path relative to the repository root."
if _UNSAFE_PATH.search(file):
return "❌ Wildcards/globs not allowed. Specify an explicit file path."
if DANGEROUS_CHARS.search(file):
return f"❌ Invalid characters detected in file path: '{file}'"
try:
args = []
if cached:
args.append("--cached")
if dry_run:
args.append("--dry-run")
args.append("--")
args.append(file)
output = repo.git.rm(*args)
if dry_run:
return f"🔍 Dry-run: would remove '{file}'\n{output}"
mode = "from index (kept on disk)" if cached else "from working tree and index"
return f"✅ Removed '{file}' {mode}"
except GitCommandError as e:
stderr = e.stderr.decode() if isinstance(e.stderr, bytes) else e.stderr
if "did not match any files" in stderr:
return f"❌ File not found: '{file}' is not tracked by git"
if "has local modifications" in stderr or "has changes staged" in stderr:
return f"❌ File '{file}' has uncommitted changes. Stage or stash first, or use force."
return f"❌ git rm failed: {stderr}"
except Exception as e:
return f"❌ Error during git rm: {str(e)}"