uni-diff-patch 0.0.1__tar.gz
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-0.0.1/PKG-INFO +194 -0
- uni_diff_patch-0.0.1/README.md +181 -0
- uni_diff_patch-0.0.1/pyproject.toml +27 -0
- uni_diff_patch-0.0.1/src/uni_diff_patch/__init__.py +46 -0
- uni_diff_patch-0.0.1/src/uni_diff_patch/cli.py +178 -0
- uni_diff_patch-0.0.1/src/uni_diff_patch/core/__init__.py +0 -0
- uni_diff_patch-0.0.1/src/uni_diff_patch/core/diff_engine.py +402 -0
- uni_diff_patch-0.0.1/src/uni_diff_patch/core/search_replace.py +140 -0
- uni_diff_patch-0.0.1/src/uni_diff_patch/core/validator.py +214 -0
- uni_diff_patch-0.0.1/src/uni_diff_patch/models.py +166 -0
- uni_diff_patch-0.0.1/src/uni_diff_patch/py.typed +0 -0
- uni_diff_patch-0.0.1/src/uni_diff_patch/server.py +147 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: uni-diff-patch
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: MCP server and CLI tool for generating valid unified diff patches deterministically
|
|
5
|
+
Author: LimLLL
|
|
6
|
+
Author-email: LimLLL <github@awebapp.useforall.com>
|
|
7
|
+
Requires-Dist: mpatch
|
|
8
|
+
Requires-Dist: mcp[cli]
|
|
9
|
+
Requires-Dist: click
|
|
10
|
+
Requires-Dist: pydantic>=2.13.4
|
|
11
|
+
Requires-Python: >=3.12
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# uni-diff-patch
|
|
15
|
+
|
|
16
|
+
MCP server + CLI tool for generating valid unified diff patches deterministically.
|
|
17
|
+
|
|
18
|
+
LLM provides content or search/replace pairs, the tool computes correct diffs via [mpatch](https://github.com/Romelium/mpatch) (Rust) — no more broken hunks, no more `git apply` failures.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
MCP 服务器 + CLI 工具,确定性生成合法的 unified diff patch。
|
|
23
|
+
|
|
24
|
+
LLM 只需提供文件内容或搜索替换对,工具通过 [mpatch](https://github.com/Romelium/mpatch) (Rust) 计算正确的 diff — 不再有坏掉的 hunk,`git apply` 不再失败。
|
|
25
|
+
|
|
26
|
+
## Install / 安装
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
uv add uni-diff-patch # as dependency / 作为依赖
|
|
30
|
+
uvx uni-diff-patch --help # run directly / 直接运行
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## CLI / 命令行
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# Generate patch from JSON spec / 从 JSON spec 生成 patch
|
|
37
|
+
uni-diff-patch generate --spec changes.json -o output.diff
|
|
38
|
+
|
|
39
|
+
# Generate with work_dir (for absolute paths) / 指定工作目录(用于绝对路径)
|
|
40
|
+
uni-diff-patch generate --spec changes.json --work-dir /path/to/project
|
|
41
|
+
|
|
42
|
+
# Validate a patch / 验证 patch
|
|
43
|
+
uni-diff-patch validate output.diff
|
|
44
|
+
uni-diff-patch validate output.diff --git-check --work-dir /path/to/repo
|
|
45
|
+
|
|
46
|
+
# Start MCP server / 启动 MCP 服务器
|
|
47
|
+
uni-diff-patch serve
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## MCP Configuration / MCP 配置
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"mcpServers": {
|
|
55
|
+
"uni-diff-patch": {
|
|
56
|
+
"command": "uvx",
|
|
57
|
+
"args": ["uni-diff-patch", "serve"]
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## JSON Spec Format / JSON Spec 格式
|
|
64
|
+
|
|
65
|
+
All modes are specified in a `changes` array. Each change requires `file_path` and `mode`.
|
|
66
|
+
|
|
67
|
+
所有模式通过 `changes` 数组指定,每个变更需要 `file_path` 和 `mode`。
|
|
68
|
+
|
|
69
|
+
### file_content — Full new content / 完整新内容
|
|
70
|
+
|
|
71
|
+
Tool reads original from disk and diffs. / 工具从磁盘读取原文件并生成 diff。
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"changes": [{
|
|
76
|
+
"file_path": "src/main.py",
|
|
77
|
+
"mode": "file_content",
|
|
78
|
+
"new_content": "def main():\n print('hello')\n"
|
|
79
|
+
}]
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### search_replace — Targeted replacements / 精准替换(最省 token)
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{
|
|
87
|
+
"changes": [{
|
|
88
|
+
"file_path": "src/main.py",
|
|
89
|
+
"mode": "search_replace",
|
|
90
|
+
"replacements": [
|
|
91
|
+
{"search": "print('hello')", "replace": "print('world')"},
|
|
92
|
+
{"search": "old_func()", "replace": "new_func()", "line_hint": 42}
|
|
93
|
+
]
|
|
94
|
+
}]
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`line_hint`: approximate line number for disambiguation when `search` matches multiple locations.
|
|
99
|
+
|
|
100
|
+
`line_hint`:当 `search` 匹配多处时,用大致行号消歧(选最近的匹配)。
|
|
101
|
+
|
|
102
|
+
### create_file — New file / 新建文件
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"changes": [{
|
|
107
|
+
"file_path": "src/new_module.py",
|
|
108
|
+
"mode": "create_file",
|
|
109
|
+
"new_content": "# New module\ndef init():\n pass\n"
|
|
110
|
+
}]
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### delete_file — Remove file / 删除文件
|
|
115
|
+
|
|
116
|
+
```json
|
|
117
|
+
{
|
|
118
|
+
"changes": [{
|
|
119
|
+
"file_path": "src/deprecated.py",
|
|
120
|
+
"mode": "delete_file"
|
|
121
|
+
}]
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### text_pair — Both old and new text / 直接传入新旧文本(不读磁盘)
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{
|
|
129
|
+
"changes": [{
|
|
130
|
+
"file_path": "virtual/path.py",
|
|
131
|
+
"mode": "text_pair",
|
|
132
|
+
"old_text": "x = 1\n",
|
|
133
|
+
"new_text": "x = 2\n"
|
|
134
|
+
}]
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### rename_file — Rename/move / 重命名/移动文件
|
|
139
|
+
|
|
140
|
+
Optionally with content change. / 可同时修改内容。
|
|
141
|
+
|
|
142
|
+
```json
|
|
143
|
+
{
|
|
144
|
+
"changes": [{
|
|
145
|
+
"file_path": "src/old_name.py",
|
|
146
|
+
"mode": "rename_file",
|
|
147
|
+
"new_path": "src/new_name.py",
|
|
148
|
+
"new_content": "# Optional modified content\n"
|
|
149
|
+
}]
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### chmod — Permission change / 权限变更
|
|
154
|
+
|
|
155
|
+
Optionally with content change. / 可同时修改内容。
|
|
156
|
+
|
|
157
|
+
```json
|
|
158
|
+
{
|
|
159
|
+
"changes": [{
|
|
160
|
+
"file_path": "scripts/deploy.sh",
|
|
161
|
+
"mode": "chmod",
|
|
162
|
+
"old_mode": "100644",
|
|
163
|
+
"new_mode": "100755"
|
|
164
|
+
}]
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Multi-file patch / 多文件合并 patch
|
|
169
|
+
|
|
170
|
+
```json
|
|
171
|
+
{
|
|
172
|
+
"changes": [
|
|
173
|
+
{"file_path": "src/a.py", "mode": "file_content", "new_content": "..."},
|
|
174
|
+
{"file_path": "src/b.py", "mode": "search_replace", "replacements": [{"search": "old", "replace": "new"}]},
|
|
175
|
+
{"file_path": "src/c.py", "mode": "create_file", "new_content": "..."}
|
|
176
|
+
],
|
|
177
|
+
"output_path": "changes.diff"
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Optional Fields / 可选字段
|
|
182
|
+
|
|
183
|
+
| Field / 字段 | Default / 默认值 | Description / 说明 |
|
|
184
|
+
|-------|---------|------------|
|
|
185
|
+
| `encoding` | `"utf-8"` | File encoding for disk-read modes / 磁盘读取模式的文件编码 |
|
|
186
|
+
| `context_lines` | `3` | Context lines in diff / diff 中的上下文行数 |
|
|
187
|
+
| `output_path` | — | Top-level, writes patch to file / 顶层字段,将 patch 写入文件 |
|
|
188
|
+
|
|
189
|
+
## Development / 开发
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
uv sync
|
|
193
|
+
uv run pytest tests/ -v
|
|
194
|
+
```
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# uni-diff-patch
|
|
2
|
+
|
|
3
|
+
MCP server + CLI tool for generating valid unified diff patches deterministically.
|
|
4
|
+
|
|
5
|
+
LLM provides content or search/replace pairs, the tool computes correct diffs via [mpatch](https://github.com/Romelium/mpatch) (Rust) — no more broken hunks, no more `git apply` failures.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
MCP 服务器 + CLI 工具,确定性生成合法的 unified diff patch。
|
|
10
|
+
|
|
11
|
+
LLM 只需提供文件内容或搜索替换对,工具通过 [mpatch](https://github.com/Romelium/mpatch) (Rust) 计算正确的 diff — 不再有坏掉的 hunk,`git apply` 不再失败。
|
|
12
|
+
|
|
13
|
+
## Install / 安装
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
uv add uni-diff-patch # as dependency / 作为依赖
|
|
17
|
+
uvx uni-diff-patch --help # run directly / 直接运行
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## CLI / 命令行
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# Generate patch from JSON spec / 从 JSON spec 生成 patch
|
|
24
|
+
uni-diff-patch generate --spec changes.json -o output.diff
|
|
25
|
+
|
|
26
|
+
# Generate with work_dir (for absolute paths) / 指定工作目录(用于绝对路径)
|
|
27
|
+
uni-diff-patch generate --spec changes.json --work-dir /path/to/project
|
|
28
|
+
|
|
29
|
+
# Validate a patch / 验证 patch
|
|
30
|
+
uni-diff-patch validate output.diff
|
|
31
|
+
uni-diff-patch validate output.diff --git-check --work-dir /path/to/repo
|
|
32
|
+
|
|
33
|
+
# Start MCP server / 启动 MCP 服务器
|
|
34
|
+
uni-diff-patch serve
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## MCP Configuration / MCP 配置
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"mcpServers": {
|
|
42
|
+
"uni-diff-patch": {
|
|
43
|
+
"command": "uvx",
|
|
44
|
+
"args": ["uni-diff-patch", "serve"]
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## JSON Spec Format / JSON Spec 格式
|
|
51
|
+
|
|
52
|
+
All modes are specified in a `changes` array. Each change requires `file_path` and `mode`.
|
|
53
|
+
|
|
54
|
+
所有模式通过 `changes` 数组指定,每个变更需要 `file_path` 和 `mode`。
|
|
55
|
+
|
|
56
|
+
### file_content — Full new content / 完整新内容
|
|
57
|
+
|
|
58
|
+
Tool reads original from disk and diffs. / 工具从磁盘读取原文件并生成 diff。
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"changes": [{
|
|
63
|
+
"file_path": "src/main.py",
|
|
64
|
+
"mode": "file_content",
|
|
65
|
+
"new_content": "def main():\n print('hello')\n"
|
|
66
|
+
}]
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### search_replace — Targeted replacements / 精准替换(最省 token)
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"changes": [{
|
|
75
|
+
"file_path": "src/main.py",
|
|
76
|
+
"mode": "search_replace",
|
|
77
|
+
"replacements": [
|
|
78
|
+
{"search": "print('hello')", "replace": "print('world')"},
|
|
79
|
+
{"search": "old_func()", "replace": "new_func()", "line_hint": 42}
|
|
80
|
+
]
|
|
81
|
+
}]
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`line_hint`: approximate line number for disambiguation when `search` matches multiple locations.
|
|
86
|
+
|
|
87
|
+
`line_hint`:当 `search` 匹配多处时,用大致行号消歧(选最近的匹配)。
|
|
88
|
+
|
|
89
|
+
### create_file — New file / 新建文件
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"changes": [{
|
|
94
|
+
"file_path": "src/new_module.py",
|
|
95
|
+
"mode": "create_file",
|
|
96
|
+
"new_content": "# New module\ndef init():\n pass\n"
|
|
97
|
+
}]
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### delete_file — Remove file / 删除文件
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{
|
|
105
|
+
"changes": [{
|
|
106
|
+
"file_path": "src/deprecated.py",
|
|
107
|
+
"mode": "delete_file"
|
|
108
|
+
}]
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### text_pair — Both old and new text / 直接传入新旧文本(不读磁盘)
|
|
113
|
+
|
|
114
|
+
```json
|
|
115
|
+
{
|
|
116
|
+
"changes": [{
|
|
117
|
+
"file_path": "virtual/path.py",
|
|
118
|
+
"mode": "text_pair",
|
|
119
|
+
"old_text": "x = 1\n",
|
|
120
|
+
"new_text": "x = 2\n"
|
|
121
|
+
}]
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### rename_file — Rename/move / 重命名/移动文件
|
|
126
|
+
|
|
127
|
+
Optionally with content change. / 可同时修改内容。
|
|
128
|
+
|
|
129
|
+
```json
|
|
130
|
+
{
|
|
131
|
+
"changes": [{
|
|
132
|
+
"file_path": "src/old_name.py",
|
|
133
|
+
"mode": "rename_file",
|
|
134
|
+
"new_path": "src/new_name.py",
|
|
135
|
+
"new_content": "# Optional modified content\n"
|
|
136
|
+
}]
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### chmod — Permission change / 权限变更
|
|
141
|
+
|
|
142
|
+
Optionally with content change. / 可同时修改内容。
|
|
143
|
+
|
|
144
|
+
```json
|
|
145
|
+
{
|
|
146
|
+
"changes": [{
|
|
147
|
+
"file_path": "scripts/deploy.sh",
|
|
148
|
+
"mode": "chmod",
|
|
149
|
+
"old_mode": "100644",
|
|
150
|
+
"new_mode": "100755"
|
|
151
|
+
}]
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Multi-file patch / 多文件合并 patch
|
|
156
|
+
|
|
157
|
+
```json
|
|
158
|
+
{
|
|
159
|
+
"changes": [
|
|
160
|
+
{"file_path": "src/a.py", "mode": "file_content", "new_content": "..."},
|
|
161
|
+
{"file_path": "src/b.py", "mode": "search_replace", "replacements": [{"search": "old", "replace": "new"}]},
|
|
162
|
+
{"file_path": "src/c.py", "mode": "create_file", "new_content": "..."}
|
|
163
|
+
],
|
|
164
|
+
"output_path": "changes.diff"
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Optional Fields / 可选字段
|
|
169
|
+
|
|
170
|
+
| Field / 字段 | Default / 默认值 | Description / 说明 |
|
|
171
|
+
|-------|---------|------------|
|
|
172
|
+
| `encoding` | `"utf-8"` | File encoding for disk-read modes / 磁盘读取模式的文件编码 |
|
|
173
|
+
| `context_lines` | `3` | Context lines in diff / diff 中的上下文行数 |
|
|
174
|
+
| `output_path` | — | Top-level, writes patch to file / 顶层字段,将 patch 写入文件 |
|
|
175
|
+
|
|
176
|
+
## Development / 开发
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
uv sync
|
|
180
|
+
uv run pytest tests/ -v
|
|
181
|
+
```
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "uni-diff-patch"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "MCP server and CLI tool for generating valid unified diff patches deterministically"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "LimLLL", email = "github@awebapp.useforall.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"mpatch",
|
|
12
|
+
"mcp[cli]",
|
|
13
|
+
"click",
|
|
14
|
+
"pydantic>=2.13.4",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
uni-diff-patch = "uni_diff_patch.cli:main"
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["uv_build>=0.9.27,<0.10.0"]
|
|
22
|
+
build-backend = "uv_build"
|
|
23
|
+
|
|
24
|
+
[dependency-groups]
|
|
25
|
+
dev = [
|
|
26
|
+
"pytest>=9.1.1",
|
|
27
|
+
]
|
|
@@ -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
|
+
]
|
|
@@ -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
|