uni-diff-patch 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- uni_diff_patch/__init__.py +46 -0
- uni_diff_patch/cli.py +178 -0
- uni_diff_patch/core/__init__.py +0 -0
- uni_diff_patch/core/diff_engine.py +402 -0
- uni_diff_patch/core/search_replace.py +140 -0
- uni_diff_patch/core/validator.py +214 -0
- uni_diff_patch/models.py +166 -0
- uni_diff_patch/py.typed +0 -0
- uni_diff_patch/server.py +147 -0
- uni_diff_patch-0.0.1.dist-info/METADATA +194 -0
- uni_diff_patch-0.0.1.dist-info/RECORD +13 -0
- uni_diff_patch-0.0.1.dist-info/WHEEL +4 -0
- uni_diff_patch-0.0.1.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Search-replace engine with line_hint disambiguation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class Replacement:
|
|
10
|
+
"""A single search-replace operation.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
search: Text to find in the file (must be non-empty).
|
|
14
|
+
replace: Text to substitute in place of the search text.
|
|
15
|
+
line_hint: Approximate 1-based line number to disambiguate
|
|
16
|
+
when *search* matches more than once. The match closest
|
|
17
|
+
to this line is chosen.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
search: str
|
|
21
|
+
replace: str
|
|
22
|
+
line_hint: int | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SearchReplaceError(Exception):
|
|
26
|
+
"""Raised when a search-replace operation cannot be completed."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NoMatchError(SearchReplaceError):
|
|
30
|
+
"""Search text was not found in the file content."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, search: str, file_path: str) -> None:
|
|
33
|
+
preview = search[:80] + ("..." if len(search) > 80 else "")
|
|
34
|
+
super().__init__(
|
|
35
|
+
f"Search text not found in {file_path}: {preview!r}"
|
|
36
|
+
)
|
|
37
|
+
self.search = search
|
|
38
|
+
self.file_path = file_path
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AmbiguousMatchError(SearchReplaceError):
|
|
42
|
+
"""Search text matched multiple locations without a disambiguator."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self, search: str, file_path: str, positions: list[int]
|
|
46
|
+
) -> None:
|
|
47
|
+
preview = search[:80] + ("..." if len(search) > 80 else "")
|
|
48
|
+
lines = ", ".join(str(p) for p in positions[:5])
|
|
49
|
+
super().__init__(
|
|
50
|
+
f"Search text matches {len(positions)} locations in {file_path} "
|
|
51
|
+
f"(lines: {lines}): {preview!r}. "
|
|
52
|
+
f"Provide line_hint to disambiguate."
|
|
53
|
+
)
|
|
54
|
+
self.search = search
|
|
55
|
+
self.file_path = file_path
|
|
56
|
+
self.positions = positions
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _find_line_number(content: str, char_offset: int) -> int:
|
|
60
|
+
"""Convert a character offset to a 1-based line number."""
|
|
61
|
+
return content[:char_offset].count("\n") + 1
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _find_all_occurrences(content: str, search: str) -> list[int]:
|
|
65
|
+
"""Find all character offsets where *search* appears in *content*."""
|
|
66
|
+
offsets: list[int] = []
|
|
67
|
+
start = 0
|
|
68
|
+
while True:
|
|
69
|
+
idx = content.find(search, start)
|
|
70
|
+
if idx == -1:
|
|
71
|
+
break
|
|
72
|
+
offsets.append(idx)
|
|
73
|
+
start = idx + 1
|
|
74
|
+
return offsets
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def apply_replacements(
|
|
78
|
+
content: str,
|
|
79
|
+
replacements: list[Replacement],
|
|
80
|
+
file_path: str = "<unknown>",
|
|
81
|
+
) -> str:
|
|
82
|
+
"""Apply a sequence of search-replace operations to *content*.
|
|
83
|
+
|
|
84
|
+
Replacements are applied sequentially in order. Each replacement
|
|
85
|
+
operates on the result of the previous one.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
content: Original file content.
|
|
89
|
+
replacements: Ordered list of replacements to apply.
|
|
90
|
+
file_path: File path for error messages.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Modified content after all replacements.
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
NoMatchError: If a search text is not found.
|
|
97
|
+
AmbiguousMatchError: If a search text matches multiple locations
|
|
98
|
+
and no line_hint is provided.
|
|
99
|
+
"""
|
|
100
|
+
result = content
|
|
101
|
+
|
|
102
|
+
for rep in replacements:
|
|
103
|
+
if not rep.search:
|
|
104
|
+
raise SearchReplaceError("Search text must not be empty")
|
|
105
|
+
|
|
106
|
+
offsets = _find_all_occurrences(result, rep.search)
|
|
107
|
+
|
|
108
|
+
if not offsets:
|
|
109
|
+
raise NoMatchError(rep.search, file_path)
|
|
110
|
+
|
|
111
|
+
if len(offsets) == 1:
|
|
112
|
+
# 唯一匹配,直接替换
|
|
113
|
+
offset = offsets[0]
|
|
114
|
+
elif rep.line_hint is not None:
|
|
115
|
+
# 验证 line_hint 合法性
|
|
116
|
+
if rep.line_hint < 1:
|
|
117
|
+
raise SearchReplaceError(
|
|
118
|
+
f"line_hint must be a positive integer, got {rep.line_hint}"
|
|
119
|
+
)
|
|
120
|
+
# 多个匹配,用 line_hint 选最近的
|
|
121
|
+
hint = rep.line_hint
|
|
122
|
+
lines = [_find_line_number(result, o) for o in offsets]
|
|
123
|
+
distances = [abs(line - hint) for line in lines]
|
|
124
|
+
min_dist = min(distances)
|
|
125
|
+
# 等距时不猜测,要求调用方提供更精确的 hint
|
|
126
|
+
closest = [i for i, d in enumerate(distances) if d == min_dist]
|
|
127
|
+
if len(closest) > 1:
|
|
128
|
+
positions = [lines[i] for i in closest]
|
|
129
|
+
raise AmbiguousMatchError(rep.search, file_path, positions)
|
|
130
|
+
offset = offsets[closest[0]]
|
|
131
|
+
else:
|
|
132
|
+
# 多个匹配且无消歧信息
|
|
133
|
+
positions = [_find_line_number(result, o) for o in offsets]
|
|
134
|
+
raise AmbiguousMatchError(rep.search, file_path, positions)
|
|
135
|
+
|
|
136
|
+
result = (
|
|
137
|
+
result[:offset] + rep.replace + result[offset + len(rep.search) :]
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
return result
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Patch validation via mpatch parsing and optional git apply --check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import mpatch
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class FileInfo:
|
|
15
|
+
"""Per-file statistics extracted from a parsed patch."""
|
|
16
|
+
|
|
17
|
+
file_path: str
|
|
18
|
+
hunk_count: int
|
|
19
|
+
is_creation: bool
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ValidationResult:
|
|
24
|
+
"""Result of patch validation.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
valid: Whether the patch is structurally valid.
|
|
28
|
+
files: Per-file statistics.
|
|
29
|
+
total_hunks: Total number of hunks across all files.
|
|
30
|
+
parse_error: Error message if parsing failed.
|
|
31
|
+
git_apply_ok: Result of git apply --check (None if not run).
|
|
32
|
+
git_apply_error: Error output from git apply --check.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
valid: bool
|
|
36
|
+
files: list[FileInfo] = field(default_factory=list)
|
|
37
|
+
total_hunks: int = 0
|
|
38
|
+
parse_error: str | None = None
|
|
39
|
+
git_apply_ok: bool | None = None
|
|
40
|
+
git_apply_error: str | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _parse_header_only_diffs(content: str) -> list[FileInfo]:
|
|
44
|
+
"""Parse git extended header-only diffs (rename, chmod without content changes).
|
|
45
|
+
|
|
46
|
+
mpatch.parse_auto cannot parse these since they have no hunk body.
|
|
47
|
+
We detect them by looking for 'diff --git' lines followed by
|
|
48
|
+
rename/mode headers without any @@ hunk markers.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
List of FileInfo for recognized header-only diffs, empty if none found.
|
|
52
|
+
"""
|
|
53
|
+
files: list[FileInfo] = []
|
|
54
|
+
# 匹配 "diff --git a/X b/Y" 块
|
|
55
|
+
diff_blocks = re.split(r"(?=^diff --git )", content, flags=re.MULTILINE)
|
|
56
|
+
for block in diff_blocks:
|
|
57
|
+
block = block.strip()
|
|
58
|
+
if not block.startswith("diff --git "):
|
|
59
|
+
continue
|
|
60
|
+
# 如果有 @@ hunk,mpatch 应该已经处理了,这里只处理无 hunk 的
|
|
61
|
+
if "@@" in block:
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
# 提取文件路径
|
|
65
|
+
first_line = block.split("\n")[0]
|
|
66
|
+
m = re.match(r"diff --git a/(.+?) b/(.+)", first_line)
|
|
67
|
+
if not m:
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
file_path = m.group(2)
|
|
71
|
+
is_rename = "rename from" in block
|
|
72
|
+
is_chmod = "old mode" in block or "new mode" in block
|
|
73
|
+
|
|
74
|
+
if is_rename or is_chmod:
|
|
75
|
+
files.append(FileInfo(
|
|
76
|
+
file_path=file_path,
|
|
77
|
+
hunk_count=0,
|
|
78
|
+
is_creation=False,
|
|
79
|
+
))
|
|
80
|
+
|
|
81
|
+
return files
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def validate_patch(
|
|
85
|
+
patch_content: str | None = None,
|
|
86
|
+
patch_path: str | None = None,
|
|
87
|
+
git_check: bool = False,
|
|
88
|
+
work_dir: str | None = None,
|
|
89
|
+
) -> ValidationResult:
|
|
90
|
+
"""Validate a unified diff patch.
|
|
91
|
+
|
|
92
|
+
Parses the patch with mpatch and optionally runs git apply --check.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
patch_content: The patch text to validate (mutually exclusive with patch_path).
|
|
96
|
+
patch_path: Path to a patch file (mutually exclusive with patch_content).
|
|
97
|
+
git_check: If True, run git apply --check against work_dir.
|
|
98
|
+
work_dir: Working directory for git apply --check.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
ValidationResult with parse status and optional git check result.
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
ValueError: If neither or both of patch_content/patch_path are provided.
|
|
105
|
+
"""
|
|
106
|
+
if (patch_content is None) == (patch_path is None):
|
|
107
|
+
raise ValueError(
|
|
108
|
+
"Provide exactly one of patch_content or patch_path"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if patch_path is not None:
|
|
112
|
+
path = Path(patch_path)
|
|
113
|
+
if not path.is_file():
|
|
114
|
+
return ValidationResult(
|
|
115
|
+
valid=False,
|
|
116
|
+
parse_error=f"Patch file not found: {patch_path}",
|
|
117
|
+
)
|
|
118
|
+
try:
|
|
119
|
+
patch_content = path.read_text(encoding="utf-8")
|
|
120
|
+
except OSError as e:
|
|
121
|
+
return ValidationResult(
|
|
122
|
+
valid=False,
|
|
123
|
+
parse_error=f"Cannot read patch file {patch_path}: {e}",
|
|
124
|
+
)
|
|
125
|
+
except UnicodeDecodeError as e:
|
|
126
|
+
return ValidationResult(
|
|
127
|
+
valid=False,
|
|
128
|
+
parse_error=f"Patch file encoding error {patch_path}: {e}",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
assert patch_content is not None
|
|
132
|
+
|
|
133
|
+
if not patch_content.strip():
|
|
134
|
+
return ValidationResult(
|
|
135
|
+
valid=False, parse_error="Patch content is empty"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# 用 mpatch 解析
|
|
139
|
+
try:
|
|
140
|
+
patches = mpatch.parse_auto(patch_content)
|
|
141
|
+
except Exception as e:
|
|
142
|
+
return ValidationResult(valid=False, parse_error=str(e))
|
|
143
|
+
|
|
144
|
+
# mpatch.parse_auto 无法解析 header-only diff(纯 rename/chmod)
|
|
145
|
+
# 如果 parse 结果为空,检查是否包含 git 扩展 header
|
|
146
|
+
if not patches:
|
|
147
|
+
header_files = _parse_header_only_diffs(patch_content)
|
|
148
|
+
if header_files:
|
|
149
|
+
return ValidationResult(
|
|
150
|
+
valid=True, files=header_files, total_hunks=0,
|
|
151
|
+
)
|
|
152
|
+
return ValidationResult(
|
|
153
|
+
valid=False, parse_error="No patches found in content"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
files: list[FileInfo] = []
|
|
157
|
+
total_hunks = 0
|
|
158
|
+
for p in patches:
|
|
159
|
+
hunk_count = len(p)
|
|
160
|
+
total_hunks += hunk_count
|
|
161
|
+
files.append(
|
|
162
|
+
FileInfo(
|
|
163
|
+
file_path=str(p.file_path),
|
|
164
|
+
hunk_count=hunk_count,
|
|
165
|
+
is_creation=p.is_creation,
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
result = ValidationResult(
|
|
170
|
+
valid=True,
|
|
171
|
+
files=files,
|
|
172
|
+
total_hunks=total_hunks,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# 可选 git apply --check
|
|
176
|
+
if git_check:
|
|
177
|
+
result.git_apply_ok, result.git_apply_error = _run_git_apply_check(
|
|
178
|
+
patch_content, work_dir
|
|
179
|
+
)
|
|
180
|
+
if not result.git_apply_ok:
|
|
181
|
+
result.valid = False
|
|
182
|
+
|
|
183
|
+
return result
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _run_git_apply_check(
|
|
187
|
+
patch_content: str, work_dir: str | None
|
|
188
|
+
) -> tuple[bool, str | None]:
|
|
189
|
+
"""Run git apply --check and return (success, error_message)."""
|
|
190
|
+
# 先验证 work_dir 存在
|
|
191
|
+
if work_dir and not Path(work_dir).is_dir():
|
|
192
|
+
return False, f"work_dir does not exist or is not a directory: {work_dir}"
|
|
193
|
+
|
|
194
|
+
cmd = ["git", "apply", "--check"]
|
|
195
|
+
try:
|
|
196
|
+
proc = subprocess.run(
|
|
197
|
+
cmd,
|
|
198
|
+
input=patch_content,
|
|
199
|
+
capture_output=True,
|
|
200
|
+
text=True,
|
|
201
|
+
cwd=work_dir,
|
|
202
|
+
timeout=30,
|
|
203
|
+
)
|
|
204
|
+
if proc.returncode == 0:
|
|
205
|
+
return True, None
|
|
206
|
+
return False, (proc.stderr or proc.stdout).strip()
|
|
207
|
+
except FileNotFoundError:
|
|
208
|
+
return False, "git executable not found in PATH"
|
|
209
|
+
except NotADirectoryError:
|
|
210
|
+
return False, f"work_dir is not a valid directory: {work_dir}"
|
|
211
|
+
except PermissionError:
|
|
212
|
+
return False, f"Permission denied accessing work_dir: {work_dir}"
|
|
213
|
+
except subprocess.TimeoutExpired:
|
|
214
|
+
return False, "git apply --check timed out"
|
uni_diff_patch/models.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Pydantic models for MCP/CLI input validation and schema generation.
|
|
2
|
+
|
|
3
|
+
These models are the single source of truth for the generate_patch API.
|
|
4
|
+
FastMCP auto-generates JSON Schema from these for LLM tool discovery.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Annotated, Literal, Union
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ReplacementModel(BaseModel):
|
|
15
|
+
"""A single search-replace operation.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
search: Text to find in the file (must be non-empty).
|
|
19
|
+
replace: Text to substitute in place of the search text.
|
|
20
|
+
line_hint: Approximate 1-based line number to disambiguate
|
|
21
|
+
when search matches more than once. Closest match wins.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
25
|
+
|
|
26
|
+
search: str = Field(..., min_length=1, description="Text to find in the file")
|
|
27
|
+
replace: str = Field(..., description="Replacement text")
|
|
28
|
+
line_hint: int | None = Field(
|
|
29
|
+
default=None, ge=1, description="Approximate line number to disambiguate multiple matches"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _ChangeBase(BaseModel):
|
|
34
|
+
"""Common fields for all change types."""
|
|
35
|
+
|
|
36
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
37
|
+
|
|
38
|
+
file_path: str = Field(..., min_length=1, description="Target file path (absolute or relative)")
|
|
39
|
+
|
|
40
|
+
@field_validator("file_path")
|
|
41
|
+
@classmethod
|
|
42
|
+
def _no_control_chars(cls, v: str) -> str:
|
|
43
|
+
if any(c in v for c in ("\n", "\r", "\x00")):
|
|
44
|
+
raise ValueError("file_path contains invalid control characters")
|
|
45
|
+
return v
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class FileContentChange(_ChangeBase):
|
|
49
|
+
"""Provide the full new file content. Tool reads original from disk and diffs.
|
|
50
|
+
|
|
51
|
+
Best for: complete rewrites or when you already have the full new content.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
mode: Literal["file_content"] = "file_content"
|
|
55
|
+
new_content: str = Field(..., description="Complete new file content")
|
|
56
|
+
encoding: str = Field(default="utf-8", description="File encoding for reading the original")
|
|
57
|
+
context_lines: int = Field(default=3, ge=0, description="Number of context lines in the diff")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SearchReplaceChange(_ChangeBase):
|
|
61
|
+
"""Provide search/replace pairs. Tool reads file, applies replacements, and diffs.
|
|
62
|
+
|
|
63
|
+
Best for: small targeted changes in large files (most token-efficient).
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
mode: Literal["search_replace"] = "search_replace"
|
|
67
|
+
replacements: list[ReplacementModel] = Field(
|
|
68
|
+
..., min_length=1, description="Ordered list of search-replace operations"
|
|
69
|
+
)
|
|
70
|
+
encoding: str = Field(default="utf-8", description="File encoding for reading the original")
|
|
71
|
+
context_lines: int = Field(default=3, ge=0, description="Number of context lines in the diff")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class CreateFileChange(_ChangeBase):
|
|
75
|
+
"""Create a new file. Fails if the file already exists.
|
|
76
|
+
|
|
77
|
+
Best for: adding new files to the project.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
mode: Literal["create_file"] = "create_file"
|
|
81
|
+
new_content: str = Field(..., description="Content for the new file")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class DeleteFileChange(_ChangeBase):
|
|
85
|
+
"""Delete an existing file. Tool reads current content and generates deletion diff.
|
|
86
|
+
|
|
87
|
+
Best for: removing files from the project.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
mode: Literal["delete_file"] = "delete_file"
|
|
91
|
+
encoding: str = Field(default="utf-8", description="File encoding for reading the file")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class TextPairChange(_ChangeBase):
|
|
95
|
+
"""Provide both old and new text directly. No disk read needed.
|
|
96
|
+
|
|
97
|
+
Best for: virtual files, or when you already have both versions in memory.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
mode: Literal["text_pair"] = "text_pair"
|
|
101
|
+
old_text: str = Field(..., description="Original text content")
|
|
102
|
+
new_text: str = Field(..., description="New text content")
|
|
103
|
+
context_lines: int = Field(default=3, ge=0, description="Number of context lines in the diff")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class RenameFileChange(_ChangeBase):
|
|
107
|
+
"""Rename/move a file, optionally with content changes.
|
|
108
|
+
|
|
109
|
+
Generates git-compatible rename diff with extended headers.
|
|
110
|
+
file_path is the old (source) path, new_path is the destination.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
mode: Literal["rename_file"] = "rename_file"
|
|
114
|
+
new_path: str = Field(..., min_length=1, description="New file path after rename")
|
|
115
|
+
new_content: str | None = Field(
|
|
116
|
+
default=None, description="Optional new content (if also modifying the file)"
|
|
117
|
+
)
|
|
118
|
+
encoding: str = Field(default="utf-8", description="File encoding for reading the original")
|
|
119
|
+
context_lines: int = Field(default=3, ge=0, description="Number of context lines in the diff")
|
|
120
|
+
|
|
121
|
+
@field_validator("new_path")
|
|
122
|
+
@classmethod
|
|
123
|
+
def _no_control_chars_new(cls, v: str) -> str:
|
|
124
|
+
if any(c in v for c in ("\n", "\r", "\x00")):
|
|
125
|
+
raise ValueError("new_path contains invalid control characters")
|
|
126
|
+
return v
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class ChmodChange(_ChangeBase):
|
|
130
|
+
"""Change file permissions, optionally with content changes.
|
|
131
|
+
|
|
132
|
+
Generates git-compatible mode change diff with extended headers.
|
|
133
|
+
Mode values use git's octal format (e.g. "100644", "100755").
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
mode: Literal["chmod"] = "chmod"
|
|
137
|
+
old_mode: str = Field(..., pattern=r"^1[0-7]{5}$", description='Old file mode (e.g. "100644")')
|
|
138
|
+
new_mode: str = Field(..., pattern=r"^1[0-7]{5}$", description='New file mode (e.g. "100755")')
|
|
139
|
+
new_content: str | None = Field(
|
|
140
|
+
default=None, description="Optional new content (if also modifying the file)"
|
|
141
|
+
)
|
|
142
|
+
encoding: str = Field(default="utf-8", description="File encoding for reading the original")
|
|
143
|
+
context_lines: int = Field(default=3, ge=0, description="Number of context lines in the diff")
|
|
144
|
+
|
|
145
|
+
@field_validator("new_mode")
|
|
146
|
+
@classmethod
|
|
147
|
+
def _modes_must_differ(cls, v: str, info) -> str:
|
|
148
|
+
if info.data.get("old_mode") == v:
|
|
149
|
+
raise ValueError("old_mode and new_mode must be different")
|
|
150
|
+
return v
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# Discriminated union — FastMCP uses this to generate a precise JSON Schema
|
|
154
|
+
# with oneOf and "mode" as discriminator
|
|
155
|
+
FileChange = Annotated[
|
|
156
|
+
Union[
|
|
157
|
+
FileContentChange,
|
|
158
|
+
SearchReplaceChange,
|
|
159
|
+
CreateFileChange,
|
|
160
|
+
DeleteFileChange,
|
|
161
|
+
TextPairChange,
|
|
162
|
+
RenameFileChange,
|
|
163
|
+
ChmodChange,
|
|
164
|
+
],
|
|
165
|
+
Field(discriminator="mode"),
|
|
166
|
+
]
|
uni_diff_patch/py.typed
ADDED
|
File without changes
|
uni_diff_patch/server.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""MCP server exposing generate_patch and validate_patch tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from mcp.server.fastmcp import FastMCP
|
|
6
|
+
from pydantic import ValidationError
|
|
7
|
+
|
|
8
|
+
from uni_diff_patch.core.diff_engine import (
|
|
9
|
+
DiffGenerationError,
|
|
10
|
+
GenerateResult,
|
|
11
|
+
generate_patch,
|
|
12
|
+
)
|
|
13
|
+
from uni_diff_patch.core.search_replace import SearchReplaceError
|
|
14
|
+
from uni_diff_patch.core.validator import ValidationResult
|
|
15
|
+
from uni_diff_patch.core.validator import validate_patch as _validate_patch
|
|
16
|
+
from uni_diff_patch.models import FileChange
|
|
17
|
+
|
|
18
|
+
mcp = FastMCP(
|
|
19
|
+
"uni-diff-patch",
|
|
20
|
+
instructions=(
|
|
21
|
+
"Generate valid unified diff patches deterministically. "
|
|
22
|
+
"LLMs provide file content or search/replace pairs, "
|
|
23
|
+
"the tool computes correct diffs that git apply accepts."
|
|
24
|
+
),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _parse_changes(raw_changes: list) -> list[FileChange]:
|
|
29
|
+
"""Parse and validate a list of raw change dicts into Pydantic models.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
raw_changes: List of dicts from MCP/CLI input.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
List of validated FileChange models (discriminated union).
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
ValueError: On validation failure with clear error message.
|
|
39
|
+
"""
|
|
40
|
+
results: list[FileChange] = []
|
|
41
|
+
from pydantic import TypeAdapter
|
|
42
|
+
adapter = TypeAdapter(FileChange)
|
|
43
|
+
|
|
44
|
+
for i, raw in enumerate(raw_changes):
|
|
45
|
+
if not isinstance(raw, dict):
|
|
46
|
+
raise ValueError(f"changes[{i}] must be an object, got {type(raw).__name__}")
|
|
47
|
+
try:
|
|
48
|
+
results.append(adapter.validate_python(raw))
|
|
49
|
+
except ValidationError as e:
|
|
50
|
+
# 提取 Pydantic 的错误信息,格式化为人类可读
|
|
51
|
+
errors = "; ".join(
|
|
52
|
+
f"{'.'.join(str(loc) for loc in err['loc'])}: {err['msg']}"
|
|
53
|
+
for err in e.errors()
|
|
54
|
+
)
|
|
55
|
+
raise ValueError(f"changes[{i}] validation failed: {errors}")
|
|
56
|
+
return results
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@mcp.tool()
|
|
60
|
+
def generate_patch_tool(
|
|
61
|
+
changes: list[FileChange],
|
|
62
|
+
output_path: str | None = None,
|
|
63
|
+
work_dir: str | None = None,
|
|
64
|
+
overwrite: bool = False,
|
|
65
|
+
) -> dict:
|
|
66
|
+
"""Generate a valid unified diff patch from structured file changes.
|
|
67
|
+
|
|
68
|
+
Each change object must have a "mode" field to select the change type,
|
|
69
|
+
plus mode-specific fields. The mode determines which fields are required.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
changes: Array of change objects (discriminated by "mode" field).
|
|
73
|
+
output_path: Optional file path to write the patch to.
|
|
74
|
+
work_dir: Base directory for relativizing absolute paths in diff headers.
|
|
75
|
+
overwrite: If true, overwrite existing output file. Default false.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Dict with patch, files_changed, self_valid, skipped_files, or error.
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
result: GenerateResult = generate_patch(
|
|
82
|
+
list(changes),
|
|
83
|
+
output_path=output_path,
|
|
84
|
+
work_dir=work_dir,
|
|
85
|
+
overwrite=overwrite,
|
|
86
|
+
)
|
|
87
|
+
return {
|
|
88
|
+
"patch": result.patch,
|
|
89
|
+
"files_changed": result.files_changed,
|
|
90
|
+
"self_valid": result.self_valid,
|
|
91
|
+
"skipped_files": result.skipped_files,
|
|
92
|
+
}
|
|
93
|
+
except (DiffGenerationError, SearchReplaceError, ValueError) as e:
|
|
94
|
+
return {"error": str(e)}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@mcp.tool()
|
|
98
|
+
def validate_patch_tool(
|
|
99
|
+
patch_content: str | None = None,
|
|
100
|
+
patch_path: str | None = None,
|
|
101
|
+
git_check: bool = False,
|
|
102
|
+
work_dir: str | None = None,
|
|
103
|
+
) -> dict:
|
|
104
|
+
"""Validate a unified diff patch.
|
|
105
|
+
|
|
106
|
+
Provide either patch_content (the diff string) or patch_path
|
|
107
|
+
(path to a .diff file). Parses the patch structure and reports
|
|
108
|
+
file count, hunk count, and validity.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
patch_content: The patch text to validate.
|
|
112
|
+
patch_path: Path to a patch file to validate.
|
|
113
|
+
git_check: If true, also run git apply --check.
|
|
114
|
+
work_dir: Working directory for git apply --check.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Dict with valid, files, total_hunks, and optional error details.
|
|
118
|
+
"""
|
|
119
|
+
try:
|
|
120
|
+
result: ValidationResult = _validate_patch(
|
|
121
|
+
patch_content=patch_content,
|
|
122
|
+
patch_path=patch_path,
|
|
123
|
+
git_check=git_check,
|
|
124
|
+
work_dir=work_dir,
|
|
125
|
+
)
|
|
126
|
+
return {
|
|
127
|
+
"valid": result.valid,
|
|
128
|
+
"files": [
|
|
129
|
+
{
|
|
130
|
+
"file_path": f.file_path,
|
|
131
|
+
"hunk_count": f.hunk_count,
|
|
132
|
+
"is_creation": f.is_creation,
|
|
133
|
+
}
|
|
134
|
+
for f in result.files
|
|
135
|
+
],
|
|
136
|
+
"total_hunks": result.total_hunks,
|
|
137
|
+
"parse_error": result.parse_error,
|
|
138
|
+
"git_apply_ok": result.git_apply_ok,
|
|
139
|
+
"git_apply_error": result.git_apply_error,
|
|
140
|
+
}
|
|
141
|
+
except ValueError as e:
|
|
142
|
+
return {"valid": False, "error": str(e)}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def run_server() -> None:
|
|
146
|
+
"""Start the MCP server with stdio transport."""
|
|
147
|
+
mcp.run(transport="stdio")
|