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,46 @@
|
|
|
1
|
+
"""uni-diff-patch: MCP server and CLI for generating valid unified diff patches."""
|
|
2
|
+
|
|
3
|
+
from uni_diff_patch.core.diff_engine import (
|
|
4
|
+
DiffGenerationError,
|
|
5
|
+
GenerateResult,
|
|
6
|
+
generate_patch,
|
|
7
|
+
)
|
|
8
|
+
from uni_diff_patch.core.search_replace import (
|
|
9
|
+
AmbiguousMatchError,
|
|
10
|
+
NoMatchError,
|
|
11
|
+
Replacement,
|
|
12
|
+
SearchReplaceError,
|
|
13
|
+
)
|
|
14
|
+
from uni_diff_patch.core.validator import ValidationResult, validate_patch
|
|
15
|
+
from uni_diff_patch.models import (
|
|
16
|
+
ChmodChange,
|
|
17
|
+
CreateFileChange,
|
|
18
|
+
DeleteFileChange,
|
|
19
|
+
FileChange,
|
|
20
|
+
FileContentChange,
|
|
21
|
+
RenameFileChange,
|
|
22
|
+
ReplacementModel,
|
|
23
|
+
SearchReplaceChange,
|
|
24
|
+
TextPairChange,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"ChmodChange",
|
|
29
|
+
"CreateFileChange",
|
|
30
|
+
"DeleteFileChange",
|
|
31
|
+
"DiffGenerationError",
|
|
32
|
+
"FileChange",
|
|
33
|
+
"FileContentChange",
|
|
34
|
+
"GenerateResult",
|
|
35
|
+
"RenameFileChange",
|
|
36
|
+
"ReplacementModel",
|
|
37
|
+
"SearchReplaceChange",
|
|
38
|
+
"TextPairChange",
|
|
39
|
+
"generate_patch",
|
|
40
|
+
"validate_patch",
|
|
41
|
+
"ValidationResult",
|
|
42
|
+
"Replacement",
|
|
43
|
+
"SearchReplaceError",
|
|
44
|
+
"NoMatchError",
|
|
45
|
+
"AmbiguousMatchError",
|
|
46
|
+
]
|
uni_diff_patch/cli.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""CLI entry point for uni-diff-patch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from uni_diff_patch.core.diff_engine import (
|
|
11
|
+
DiffGenerationError,
|
|
12
|
+
generate_patch,
|
|
13
|
+
)
|
|
14
|
+
from uni_diff_patch.core.search_replace import SearchReplaceError
|
|
15
|
+
from uni_diff_patch.core.validator import validate_patch
|
|
16
|
+
from uni_diff_patch.server import _parse_changes
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.group()
|
|
20
|
+
@click.version_option(package_name="uni-diff-patch")
|
|
21
|
+
def main() -> None:
|
|
22
|
+
"""uni-diff-patch: Generate valid unified diff patches deterministically."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@main.command()
|
|
26
|
+
@click.option(
|
|
27
|
+
"--spec",
|
|
28
|
+
"spec_source",
|
|
29
|
+
required=True,
|
|
30
|
+
help="JSON spec file path, or '-' for stdin.",
|
|
31
|
+
)
|
|
32
|
+
@click.option(
|
|
33
|
+
"-o",
|
|
34
|
+
"--output",
|
|
35
|
+
"output_override",
|
|
36
|
+
default=None,
|
|
37
|
+
help="Override output file path (takes precedence over spec's output_path).",
|
|
38
|
+
)
|
|
39
|
+
@click.option(
|
|
40
|
+
"--work-dir",
|
|
41
|
+
default=None,
|
|
42
|
+
help="Base directory for relativizing absolute paths in diff headers.",
|
|
43
|
+
)
|
|
44
|
+
@click.option(
|
|
45
|
+
"--overwrite",
|
|
46
|
+
is_flag=True,
|
|
47
|
+
help="Overwrite existing output file.",
|
|
48
|
+
)
|
|
49
|
+
def generate(
|
|
50
|
+
spec_source: str,
|
|
51
|
+
output_override: str | None,
|
|
52
|
+
work_dir: str | None,
|
|
53
|
+
overwrite: bool,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Generate a unified diff patch from a JSON spec.
|
|
56
|
+
|
|
57
|
+
The spec JSON must contain a "changes" array. Each change has:
|
|
58
|
+
file_path, mode, and mode-specific fields.
|
|
59
|
+
|
|
60
|
+
Modes: file_content, search_replace, create_file, delete_file,
|
|
61
|
+
text_pair, rename_file, chmod.
|
|
62
|
+
"""
|
|
63
|
+
# 读取 spec
|
|
64
|
+
try:
|
|
65
|
+
if spec_source == "-":
|
|
66
|
+
spec_data = json.load(sys.stdin)
|
|
67
|
+
else:
|
|
68
|
+
with open(spec_source, encoding="utf-8") as f:
|
|
69
|
+
spec_data = json.load(f)
|
|
70
|
+
except (json.JSONDecodeError, FileNotFoundError, OSError) as e:
|
|
71
|
+
raise click.ClickException(f"Failed to read spec: {e}")
|
|
72
|
+
|
|
73
|
+
if not isinstance(spec_data, dict):
|
|
74
|
+
raise click.ClickException("Spec must be a JSON object")
|
|
75
|
+
|
|
76
|
+
raw_changes = spec_data.get("changes")
|
|
77
|
+
if not isinstance(raw_changes, list) or not raw_changes:
|
|
78
|
+
raise click.ClickException("Spec must contain a non-empty 'changes' array")
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
file_changes = _parse_changes(raw_changes)
|
|
82
|
+
except ValueError as e:
|
|
83
|
+
raise click.ClickException(str(e))
|
|
84
|
+
|
|
85
|
+
# output 优先级:CLI flag > spec 中的 output_path
|
|
86
|
+
final_output = output_override or spec_data.get("output_path")
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
result = generate_patch(
|
|
90
|
+
file_changes,
|
|
91
|
+
output_path=final_output,
|
|
92
|
+
work_dir=work_dir,
|
|
93
|
+
overwrite=overwrite,
|
|
94
|
+
)
|
|
95
|
+
except (DiffGenerationError, SearchReplaceError) as e:
|
|
96
|
+
raise click.ClickException(str(e))
|
|
97
|
+
|
|
98
|
+
if not result.patch:
|
|
99
|
+
click.echo("No changes detected.", err=True)
|
|
100
|
+
sys.exit(0)
|
|
101
|
+
|
|
102
|
+
if result.skipped_files:
|
|
103
|
+
click.echo(
|
|
104
|
+
f"Skipped (no changes): {', '.join(result.skipped_files)}",
|
|
105
|
+
err=True,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
if not result.self_valid:
|
|
109
|
+
click.echo("WARNING: generated patch failed self-validation", err=True)
|
|
110
|
+
|
|
111
|
+
if final_output:
|
|
112
|
+
click.echo(
|
|
113
|
+
f"Patch written to {final_output} "
|
|
114
|
+
f"({result.files_changed} file(s) changed)",
|
|
115
|
+
err=True,
|
|
116
|
+
)
|
|
117
|
+
else:
|
|
118
|
+
click.echo(result.patch, nl=False)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@main.command()
|
|
122
|
+
@click.argument("patch_source", default="-")
|
|
123
|
+
@click.option(
|
|
124
|
+
"--git-check", is_flag=True, help="Also run git apply --check."
|
|
125
|
+
)
|
|
126
|
+
@click.option(
|
|
127
|
+
"--work-dir",
|
|
128
|
+
default=None,
|
|
129
|
+
help="Working directory for git apply --check.",
|
|
130
|
+
)
|
|
131
|
+
def validate(patch_source: str, git_check: bool, work_dir: str | None) -> None:
|
|
132
|
+
"""Validate a unified diff patch file.
|
|
133
|
+
|
|
134
|
+
Pass a file path or '-' (default) to read from stdin.
|
|
135
|
+
"""
|
|
136
|
+
patch_content: str | None = None
|
|
137
|
+
patch_path: str | None = None
|
|
138
|
+
|
|
139
|
+
if patch_source == "-":
|
|
140
|
+
raw = sys.stdin.read()
|
|
141
|
+
if not raw.strip():
|
|
142
|
+
raise click.ClickException("No patch content provided on stdin")
|
|
143
|
+
patch_content = raw
|
|
144
|
+
else:
|
|
145
|
+
patch_path = patch_source
|
|
146
|
+
|
|
147
|
+
result = validate_patch(
|
|
148
|
+
patch_content=patch_content,
|
|
149
|
+
patch_path=patch_path,
|
|
150
|
+
git_check=git_check,
|
|
151
|
+
work_dir=work_dir,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if result.valid:
|
|
155
|
+
click.echo(f"VALID — {len(result.files)} file(s), {result.total_hunks} hunk(s)")
|
|
156
|
+
for f in result.files:
|
|
157
|
+
status = " [new]" if f.is_creation else ""
|
|
158
|
+
click.echo(f" {f.file_path}: {f.hunk_count} hunk(s){status}")
|
|
159
|
+
else:
|
|
160
|
+
click.echo("INVALID", err=True)
|
|
161
|
+
if result.parse_error:
|
|
162
|
+
click.echo(f" Parse error: {result.parse_error}", err=True)
|
|
163
|
+
if result.git_apply_error:
|
|
164
|
+
click.echo(f" git apply: {result.git_apply_error}", err=True)
|
|
165
|
+
sys.exit(1)
|
|
166
|
+
|
|
167
|
+
if result.git_apply_ok is True:
|
|
168
|
+
click.echo("git apply --check: OK")
|
|
169
|
+
elif result.git_apply_ok is False:
|
|
170
|
+
click.echo(f"git apply --check: FAILED — {result.git_apply_error}", err=True)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@main.command()
|
|
174
|
+
def serve() -> None:
|
|
175
|
+
"""Start the MCP server (stdio transport)."""
|
|
176
|
+
from uni_diff_patch.server import run_server
|
|
177
|
+
|
|
178
|
+
run_server()
|
|
File without changes
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""Core diff generation engine backed by mpatch.
|
|
2
|
+
|
|
3
|
+
Uses mpatch (Rust) for normal diffs and falls back to difflib (stdlib)
|
|
4
|
+
when files lack trailing newlines, since difflib correctly preserves
|
|
5
|
+
line endings and allows proper '\\No newline at end of file' markers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import difflib
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import mpatch
|
|
16
|
+
|
|
17
|
+
from uni_diff_patch.core.search_replace import Replacement, apply_replacements
|
|
18
|
+
from uni_diff_patch.core.validator import validate_patch
|
|
19
|
+
from uni_diff_patch.models import (
|
|
20
|
+
ChmodChange,
|
|
21
|
+
CreateFileChange,
|
|
22
|
+
DeleteFileChange,
|
|
23
|
+
FileChange,
|
|
24
|
+
FileContentChange,
|
|
25
|
+
RenameFileChange,
|
|
26
|
+
SearchReplaceChange,
|
|
27
|
+
TextPairChange,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
_NO_NEWLINE_MARKER = "\"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DiffGenerationError(Exception):
|
|
34
|
+
"""Raised when diff generation fails."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class GenerateResult:
|
|
39
|
+
"""Result of a diff generation operation.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
patch: The generated unified diff string.
|
|
43
|
+
files_changed: Number of files with actual changes.
|
|
44
|
+
self_valid: Whether the generated patch passed self-validation.
|
|
45
|
+
skipped_files: Files skipped because old == new (no changes).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
patch: str
|
|
49
|
+
files_changed: int = 0
|
|
50
|
+
self_valid: bool = True
|
|
51
|
+
skipped_files: list[str] = field(default_factory=list)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# 二进制探测只读前 8KB,全文件读取有大小限制
|
|
55
|
+
_BINARY_PROBE_SIZE = 8192
|
|
56
|
+
_MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _read_file(file_path: str, encoding: str = "utf-8") -> str:
|
|
60
|
+
"""Read a file from disk with binary detection.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
file_path: Path to the file.
|
|
64
|
+
encoding: Text encoding.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
File content as string.
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
DiffGenerationError: If the file is binary, not found, or unreadable.
|
|
71
|
+
"""
|
|
72
|
+
path = Path(file_path)
|
|
73
|
+
if not path.is_file():
|
|
74
|
+
raise DiffGenerationError(f"File not found: {file_path}")
|
|
75
|
+
if path.is_symlink():
|
|
76
|
+
raise DiffGenerationError(f"Symlinks not supported: {file_path}")
|
|
77
|
+
|
|
78
|
+
size = path.stat().st_size
|
|
79
|
+
if size > _MAX_FILE_SIZE:
|
|
80
|
+
raise DiffGenerationError(
|
|
81
|
+
f"File too large ({size} bytes, max {_MAX_FILE_SIZE}): {file_path}"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
with open(path, "rb") as f:
|
|
85
|
+
probe = f.read(_BINARY_PROBE_SIZE)
|
|
86
|
+
if b"\x00" in probe:
|
|
87
|
+
raise DiffGenerationError(f"Binary file detected: {file_path}")
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
return path.read_text(encoding=encoding)
|
|
91
|
+
except (UnicodeDecodeError, LookupError) as e:
|
|
92
|
+
raise DiffGenerationError(
|
|
93
|
+
f"Cannot decode {file_path} with encoding {encoding}: {e}"
|
|
94
|
+
) from e
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _make_patch_path(file_path: str, work_dir: str | None) -> str:
|
|
98
|
+
"""Normalize a file path for diff headers, ensuring git apply compatibility.
|
|
99
|
+
|
|
100
|
+
Relative paths are preserved as-is. Absolute paths are relativized
|
|
101
|
+
against work_dir to avoid the 'a//abs/path' double-slash issue.
|
|
102
|
+
"""
|
|
103
|
+
path = Path(file_path)
|
|
104
|
+
if not path.is_absolute():
|
|
105
|
+
return file_path
|
|
106
|
+
|
|
107
|
+
resolved = path.resolve()
|
|
108
|
+
if work_dir:
|
|
109
|
+
base = Path(work_dir).resolve()
|
|
110
|
+
try:
|
|
111
|
+
return str(resolved.relative_to(base))
|
|
112
|
+
except ValueError:
|
|
113
|
+
raise DiffGenerationError(
|
|
114
|
+
f"Absolute path {file_path} is outside work_dir {work_dir}. "
|
|
115
|
+
f"Use a path within work_dir or switch to text_pair mode."
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return str(resolved).lstrip("/")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _create_unified_diff(
|
|
122
|
+
patch_path: str, old: str, new: str, context_lines: int
|
|
123
|
+
) -> str:
|
|
124
|
+
"""Generate a unified diff, choosing the best engine.
|
|
125
|
+
|
|
126
|
+
Uses mpatch (Rust, fast) for normal files. Falls back to difflib
|
|
127
|
+
when either side lacks a trailing newline (correct marker handling).
|
|
128
|
+
"""
|
|
129
|
+
old_has_newline = (not old) or old.endswith("\n")
|
|
130
|
+
new_has_newline = (not new) or new.endswith("\n")
|
|
131
|
+
|
|
132
|
+
if old_has_newline and new_has_newline:
|
|
133
|
+
return mpatch.create_unified_diff(
|
|
134
|
+
patch_path, old, new, context_len=context_lines
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# difflib 降级:正确处理 "" 标记
|
|
138
|
+
old_lines = old.splitlines(keepends=True)
|
|
139
|
+
new_lines = new.splitlines(keepends=True)
|
|
140
|
+
|
|
141
|
+
raw = difflib.unified_diff(
|
|
142
|
+
old_lines, new_lines,
|
|
143
|
+
fromfile=f"a/{patch_path}", tofile=f"b/{patch_path}",
|
|
144
|
+
n=context_lines,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
result: list[str] = []
|
|
148
|
+
for line in raw:
|
|
149
|
+
result.append(line)
|
|
150
|
+
if line and not line.endswith("\n") and line[0] in (" ", "-", "+"):
|
|
151
|
+
result.append(f"\n{_NO_NEWLINE_MARKER}\n")
|
|
152
|
+
|
|
153
|
+
return "".join(result)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# --- git 扩展 diff header 构建 ---
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _build_rename_diff(
|
|
160
|
+
old_path: str, new_path: str, old: str, new: str, context_lines: int
|
|
161
|
+
) -> str:
|
|
162
|
+
"""Build a git-compatible rename diff with extended headers.
|
|
163
|
+
|
|
164
|
+
Format:
|
|
165
|
+
diff --git a/old b/new
|
|
166
|
+
rename from old
|
|
167
|
+
rename to new
|
|
168
|
+
--- a/old
|
|
169
|
+
+++ b/new
|
|
170
|
+
@@ ... @@
|
|
171
|
+
...
|
|
172
|
+
"""
|
|
173
|
+
header = (
|
|
174
|
+
f"diff --git a/{old_path} b/{new_path}\n"
|
|
175
|
+
f"rename from {old_path}\n"
|
|
176
|
+
f"rename to {new_path}\n"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if old == new:
|
|
180
|
+
# 纯重命名,无内容变更
|
|
181
|
+
return header
|
|
182
|
+
|
|
183
|
+
# 有内容变更,生成 body
|
|
184
|
+
body = _create_unified_diff(old_path, old, new, context_lines)
|
|
185
|
+
# 替换 body 中的路径:--- a/old_path 保持, +++ b/new_path
|
|
186
|
+
body = body.replace(f"+++ b/{old_path}", f"+++ b/{new_path}", 1)
|
|
187
|
+
return header + body
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _build_chmod_diff(
|
|
191
|
+
patch_path: str, old_mode: str, new_mode: str,
|
|
192
|
+
old: str | None, new: str | None, context_lines: int
|
|
193
|
+
) -> str:
|
|
194
|
+
"""Build a git-compatible mode change diff with extended headers.
|
|
195
|
+
|
|
196
|
+
Format:
|
|
197
|
+
diff --git a/path b/path
|
|
198
|
+
old mode <old>
|
|
199
|
+
new mode <new>
|
|
200
|
+
--- a/path (only if content changed)
|
|
201
|
+
+++ b/path
|
|
202
|
+
@@ ... @@
|
|
203
|
+
...
|
|
204
|
+
"""
|
|
205
|
+
header = (
|
|
206
|
+
f"diff --git a/{patch_path} b/{patch_path}\n"
|
|
207
|
+
f"old mode {old_mode}\n"
|
|
208
|
+
f"new mode {new_mode}\n"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
if old is None or new is None or old == new:
|
|
212
|
+
# 纯权限变更
|
|
213
|
+
return header
|
|
214
|
+
|
|
215
|
+
# 有内容变更
|
|
216
|
+
body = _create_unified_diff(patch_path, old, new, context_lines)
|
|
217
|
+
return header + body
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# --- 主入口 ---
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _to_replacements(change: SearchReplaceChange) -> list[Replacement]:
|
|
224
|
+
"""Convert Pydantic ReplacementModel list to internal Replacement list."""
|
|
225
|
+
return [
|
|
226
|
+
Replacement(search=r.search, replace=r.replace, line_hint=r.line_hint)
|
|
227
|
+
for r in change.replacements
|
|
228
|
+
]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _generate_single_diff(
|
|
232
|
+
change: FileChange, work_dir: str | None
|
|
233
|
+
) -> str | None:
|
|
234
|
+
"""Generate a unified diff for a single file change.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
The diff string, or None if there are no changes.
|
|
238
|
+
|
|
239
|
+
Raises:
|
|
240
|
+
DiffGenerationError: On invalid input or file access errors.
|
|
241
|
+
"""
|
|
242
|
+
match change:
|
|
243
|
+
case FileContentChange():
|
|
244
|
+
old = _read_file(change.file_path, change.encoding)
|
|
245
|
+
new = change.new_content
|
|
246
|
+
if old == new:
|
|
247
|
+
return None
|
|
248
|
+
patch_path = _make_patch_path(change.file_path, work_dir)
|
|
249
|
+
return _create_unified_diff(patch_path, old, new, change.context_lines)
|
|
250
|
+
|
|
251
|
+
case SearchReplaceChange():
|
|
252
|
+
old = _read_file(change.file_path, change.encoding)
|
|
253
|
+
new = apply_replacements(old, _to_replacements(change), change.file_path)
|
|
254
|
+
if old == new:
|
|
255
|
+
return None
|
|
256
|
+
patch_path = _make_patch_path(change.file_path, work_dir)
|
|
257
|
+
return _create_unified_diff(patch_path, old, new, change.context_lines)
|
|
258
|
+
|
|
259
|
+
case CreateFileChange():
|
|
260
|
+
if Path(change.file_path).exists():
|
|
261
|
+
raise DiffGenerationError(
|
|
262
|
+
f"File already exists (use file_content mode to modify): "
|
|
263
|
+
f"{change.file_path}"
|
|
264
|
+
)
|
|
265
|
+
patch_path = _make_patch_path(change.file_path, work_dir)
|
|
266
|
+
return _create_unified_diff(patch_path, "", change.new_content, 3)
|
|
267
|
+
|
|
268
|
+
case DeleteFileChange():
|
|
269
|
+
old = _read_file(change.file_path, change.encoding)
|
|
270
|
+
patch_path = _make_patch_path(change.file_path, work_dir)
|
|
271
|
+
return _create_unified_diff(patch_path, old, "", 3)
|
|
272
|
+
|
|
273
|
+
case TextPairChange():
|
|
274
|
+
if change.old_text == change.new_text:
|
|
275
|
+
return None
|
|
276
|
+
patch_path = _make_patch_path(change.file_path, work_dir)
|
|
277
|
+
return _create_unified_diff(
|
|
278
|
+
patch_path, change.old_text, change.new_text, change.context_lines
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
case RenameFileChange():
|
|
282
|
+
old = _read_file(change.file_path, change.encoding)
|
|
283
|
+
# 检查目标文件是否已存在
|
|
284
|
+
if Path(change.new_path).exists():
|
|
285
|
+
raise DiffGenerationError(
|
|
286
|
+
f"Rename target already exists: {change.new_path}"
|
|
287
|
+
)
|
|
288
|
+
new = change.new_content if change.new_content is not None else old
|
|
289
|
+
old_patch = _make_patch_path(change.file_path, work_dir)
|
|
290
|
+
new_patch = _make_patch_path(change.new_path, work_dir)
|
|
291
|
+
return _build_rename_diff(old_patch, new_patch, old, new, change.context_lines)
|
|
292
|
+
|
|
293
|
+
case ChmodChange():
|
|
294
|
+
# 始终验证文件存在(即使纯 chmod 不读内容)
|
|
295
|
+
if not Path(change.file_path).is_file():
|
|
296
|
+
raise DiffGenerationError(f"File not found: {change.file_path}")
|
|
297
|
+
old_patch = _make_patch_path(change.file_path, work_dir)
|
|
298
|
+
old_content: str | None = None
|
|
299
|
+
new_content: str | None = None
|
|
300
|
+
if change.new_content is not None:
|
|
301
|
+
old_content = _read_file(change.file_path, change.encoding)
|
|
302
|
+
new_content = change.new_content
|
|
303
|
+
return _build_chmod_diff(
|
|
304
|
+
old_patch, change.old_mode, change.new_mode,
|
|
305
|
+
old_content, new_content, change.context_lines,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
case _:
|
|
309
|
+
raise DiffGenerationError(f"Unknown change type: {type(change)}")
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def generate_patch(
|
|
313
|
+
changes: Sequence[FileChange],
|
|
314
|
+
output_path: str | None = None,
|
|
315
|
+
work_dir: str | None = None,
|
|
316
|
+
self_validate: bool = True,
|
|
317
|
+
overwrite: bool = False,
|
|
318
|
+
) -> GenerateResult:
|
|
319
|
+
"""Generate a unified diff patch from one or more file changes.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
changes: List of file changes (Pydantic models) to include in the patch.
|
|
323
|
+
output_path: If provided, write the patch to this file path.
|
|
324
|
+
work_dir: Base directory for relativizing absolute paths in diff headers.
|
|
325
|
+
Defaults to current working directory.
|
|
326
|
+
self_validate: If True, validate the generated patch with mpatch.parse_auto.
|
|
327
|
+
overwrite: If True, overwrite existing output files. Default False.
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
GenerateResult with the patch string and metadata.
|
|
331
|
+
|
|
332
|
+
Raises:
|
|
333
|
+
DiffGenerationError: On input errors or generation failures.
|
|
334
|
+
"""
|
|
335
|
+
if not changes:
|
|
336
|
+
raise DiffGenerationError("No changes provided")
|
|
337
|
+
|
|
338
|
+
# 检查重复文件目标(含 rename 的 new_path)
|
|
339
|
+
seen_paths: set[str] = set()
|
|
340
|
+
for change in changes:
|
|
341
|
+
normalized = str(Path(change.file_path).resolve())
|
|
342
|
+
if normalized in seen_paths:
|
|
343
|
+
raise DiffGenerationError(
|
|
344
|
+
f"Duplicate file target: {change.file_path}. "
|
|
345
|
+
f"Combine changes into a single FileChange per file."
|
|
346
|
+
)
|
|
347
|
+
seen_paths.add(normalized)
|
|
348
|
+
# rename 的目标路径也需要检查冲突
|
|
349
|
+
if isinstance(change, RenameFileChange):
|
|
350
|
+
new_normalized = str(Path(change.new_path).resolve())
|
|
351
|
+
if new_normalized in seen_paths:
|
|
352
|
+
raise DiffGenerationError(
|
|
353
|
+
f"Duplicate file target: {change.new_path} (rename destination). "
|
|
354
|
+
)
|
|
355
|
+
seen_paths.add(new_normalized)
|
|
356
|
+
|
|
357
|
+
effective_work_dir = work_dir or str(Path.cwd())
|
|
358
|
+
|
|
359
|
+
diffs: list[str] = []
|
|
360
|
+
skipped: list[str] = []
|
|
361
|
+
|
|
362
|
+
for change in changes:
|
|
363
|
+
diff = _generate_single_diff(change, effective_work_dir)
|
|
364
|
+
if diff is None:
|
|
365
|
+
skipped.append(change.file_path)
|
|
366
|
+
else:
|
|
367
|
+
diffs.append(diff)
|
|
368
|
+
|
|
369
|
+
if not diffs:
|
|
370
|
+
return GenerateResult(
|
|
371
|
+
patch="", files_changed=0, self_valid=True, skipped_files=skipped,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
combined = "\n".join(diffs)
|
|
375
|
+
if not combined.endswith("\n"):
|
|
376
|
+
combined += "\n"
|
|
377
|
+
|
|
378
|
+
is_valid = True
|
|
379
|
+
if self_validate:
|
|
380
|
+
# 检查是否所有 diff 都是 header-only(纯 rename/chmod 无 hunk body)
|
|
381
|
+
# mpatch.parse_auto 无法解析这类 diff,跳过验证
|
|
382
|
+
has_hunk_body = any("@@" in d for d in diffs)
|
|
383
|
+
if has_hunk_body:
|
|
384
|
+
validation = validate_patch(patch_content=combined)
|
|
385
|
+
is_valid = validation.valid
|
|
386
|
+
|
|
387
|
+
result = GenerateResult(
|
|
388
|
+
patch=combined, files_changed=len(diffs),
|
|
389
|
+
self_valid=is_valid, skipped_files=skipped,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
if output_path is not None:
|
|
393
|
+
out = Path(output_path)
|
|
394
|
+
if out.exists() and not overwrite:
|
|
395
|
+
raise DiffGenerationError(
|
|
396
|
+
f"Output file already exists: {output_path}. "
|
|
397
|
+
f"Use overwrite=True to replace."
|
|
398
|
+
)
|
|
399
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
400
|
+
out.write_text(combined, encoding="utf-8")
|
|
401
|
+
|
|
402
|
+
return result
|