gptdiff 0.1.22__tar.gz → 0.1.24__tar.gz
Sign up to get free protection for your applications and to get access to all the features.
- {gptdiff-0.1.22 → gptdiff-0.1.24}/PKG-INFO +1 -1
- gptdiff-0.1.24/gptdiff/applydiff.py +264 -0
- {gptdiff-0.1.22 → gptdiff-0.1.24}/gptdiff/gptdiff.py +74 -294
- {gptdiff-0.1.22 → gptdiff-0.1.24}/gptdiff/gptpatch.py +9 -7
- {gptdiff-0.1.22 → gptdiff-0.1.24}/gptdiff.egg-info/PKG-INFO +1 -1
- {gptdiff-0.1.22 → gptdiff-0.1.24}/gptdiff.egg-info/SOURCES.txt +1 -0
- {gptdiff-0.1.22 → gptdiff-0.1.24}/setup.py +1 -1
- gptdiff-0.1.24/tests/test_applydiff.py +143 -0
- {gptdiff-0.1.22 → gptdiff-0.1.24}/tests/test_smartapply.py +4 -5
- gptdiff-0.1.22/tests/test_applydiff.py +0 -80
- {gptdiff-0.1.22 → gptdiff-0.1.24}/LICENSE.txt +0 -0
- {gptdiff-0.1.22 → gptdiff-0.1.24}/README.md +0 -0
- {gptdiff-0.1.22 → gptdiff-0.1.24}/gptdiff/__init__.py +0 -0
- {gptdiff-0.1.22 → gptdiff-0.1.24}/gptdiff.egg-info/dependency_links.txt +0 -0
- {gptdiff-0.1.22 → gptdiff-0.1.24}/gptdiff.egg-info/entry_points.txt +0 -0
- {gptdiff-0.1.22 → gptdiff-0.1.24}/gptdiff.egg-info/requires.txt +0 -0
- {gptdiff-0.1.22 → gptdiff-0.1.24}/gptdiff.egg-info/top_level.txt +0 -0
- {gptdiff-0.1.22 → gptdiff-0.1.24}/setup.cfg +0 -0
- {gptdiff-0.1.22 → gptdiff-0.1.24}/tests/test_applydiff_edgecases.py +0 -0
- {gptdiff-0.1.22 → gptdiff-0.1.24}/tests/test_diff_parse.py +0 -0
- {gptdiff-0.1.22 → gptdiff-0.1.24}/tests/test_failing_case.py +0 -0
- {gptdiff-0.1.22 → gptdiff-0.1.24}/tests/test_parse_diff_per_file.py +0 -0
- {gptdiff-0.1.22 → gptdiff-0.1.24}/tests/test_strip_bad_ouput.py +0 -0
- {gptdiff-0.1.22 → gptdiff-0.1.24}/tests/test_swallow_reasoning.py +0 -0
@@ -0,0 +1,264 @@
|
|
1
|
+
"""
|
2
|
+
Module: applydiff
|
3
|
+
|
4
|
+
Contains the function to apply unified git diffs to files on disk.
|
5
|
+
"""
|
6
|
+
|
7
|
+
from pathlib import Path
|
8
|
+
import re
|
9
|
+
import hashlib
|
10
|
+
|
11
|
+
def apply_diff(project_dir, diff_text):
|
12
|
+
"""
|
13
|
+
Applies a unified diff (as generated by git diff) to the files in project_dir
|
14
|
+
using pure Python (without calling the external 'patch' command).
|
15
|
+
|
16
|
+
Handles file modifications, new file creation, and file deletions.
|
17
|
+
|
18
|
+
Returns:
|
19
|
+
True if at least one file was modified (or deleted/created) as a result of the patch,
|
20
|
+
False otherwise.
|
21
|
+
"""
|
22
|
+
from pathlib import Path
|
23
|
+
import re, hashlib
|
24
|
+
|
25
|
+
def file_hash(filepath):
|
26
|
+
h = hashlib.sha256()
|
27
|
+
with open(filepath, "rb") as f:
|
28
|
+
h.update(f.read())
|
29
|
+
return h.hexdigest()
|
30
|
+
|
31
|
+
def apply_patch_to_file(file_path, patch):
|
32
|
+
"""
|
33
|
+
Applies a unified diff patch (for a single file) to file_path.
|
34
|
+
|
35
|
+
Returns True if the patch was applied successfully, False otherwise.
|
36
|
+
"""
|
37
|
+
# Read the original file lines; if the file doesn't exist, treat it as empty.
|
38
|
+
if file_path.exists():
|
39
|
+
original_lines = file_path.read_text(encoding="utf8").splitlines(keepends=True)
|
40
|
+
else:
|
41
|
+
original_lines = []
|
42
|
+
new_lines = []
|
43
|
+
current_index = 0
|
44
|
+
|
45
|
+
patch_lines = patch.splitlines()
|
46
|
+
# Regex for a hunk header, e.g., @@ -3,7 +3,6 @@
|
47
|
+
hunk_header_re = re.compile(r"^@@(?: -(\d+)(?:,(\d+))?)?(?: \+(\d+)(?:,(\d+))?)? @@")
|
48
|
+
i = 0
|
49
|
+
while i < len(patch_lines):
|
50
|
+
line = patch_lines[i]
|
51
|
+
if line.lstrip().startswith("@@"):
|
52
|
+
if line.strip() == "@@":
|
53
|
+
# Handle minimal hunk header without line numbers.
|
54
|
+
orig_start = 1
|
55
|
+
else:
|
56
|
+
m = hunk_header_re.match(line.strip())
|
57
|
+
if not m:
|
58
|
+
print("Invalid hunk header:", line)
|
59
|
+
return False
|
60
|
+
orig_start = int(m.group(1)) if m.group(1) is not None else 1
|
61
|
+
hunk_start_index = orig_start - 1 # diff headers are 1-indexed
|
62
|
+
if hunk_start_index > len(original_lines):
|
63
|
+
print("Hunk start index beyond file length")
|
64
|
+
return False
|
65
|
+
new_lines.extend(original_lines[current_index:hunk_start_index])
|
66
|
+
current_index = hunk_start_index
|
67
|
+
i += 1
|
68
|
+
# Process the hunk lines until the next hunk header.
|
69
|
+
while i < len(patch_lines) and not patch_lines[i].startswith("@@"):
|
70
|
+
pline = patch_lines[i]
|
71
|
+
if pline.startswith(" "):
|
72
|
+
# Context line must match exactly.
|
73
|
+
expected = pline[1:]
|
74
|
+
if current_index >= len(original_lines):
|
75
|
+
print("Context line expected but file ended")
|
76
|
+
return False
|
77
|
+
orig_line = original_lines[current_index].rstrip("\n")
|
78
|
+
if orig_line != expected:
|
79
|
+
print("Context line mismatch. Expected:", expected, "Got:", orig_line)
|
80
|
+
return False
|
81
|
+
new_lines.append(original_lines[current_index])
|
82
|
+
current_index += 1
|
83
|
+
elif pline.startswith("-"):
|
84
|
+
# Removal line: verify and skip from original.
|
85
|
+
expected = pline[1:]
|
86
|
+
if current_index >= len(original_lines):
|
87
|
+
print("Removal line expected but file ended")
|
88
|
+
return False
|
89
|
+
orig_line = original_lines[current_index].rstrip("\n")
|
90
|
+
if orig_line != expected:
|
91
|
+
print("Removal line mismatch. Expected:", expected, "Got:", orig_line)
|
92
|
+
return False
|
93
|
+
current_index += 1
|
94
|
+
elif pline.startswith("+"):
|
95
|
+
# Addition line: add to new_lines.
|
96
|
+
new_lines.append(pline[1:] + "\n")
|
97
|
+
else:
|
98
|
+
print("Unexpected line in hunk:", pline)
|
99
|
+
return False
|
100
|
+
i += 1
|
101
|
+
else:
|
102
|
+
# Skip non-hunk header lines.
|
103
|
+
i += 1
|
104
|
+
|
105
|
+
# Append any remaining lines from the original file.
|
106
|
+
new_lines.extend(original_lines[current_index:])
|
107
|
+
# Ensure parent directories exist before writing the file.
|
108
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
109
|
+
# Write the new content back to the file.
|
110
|
+
file_path.write_text("".join(new_lines), encoding="utf8")
|
111
|
+
return True
|
112
|
+
|
113
|
+
# Parse the diff into per-file patches.
|
114
|
+
file_patches = parse_diff_per_file(diff_text)
|
115
|
+
if not file_patches:
|
116
|
+
print("No file patches found in diff.")
|
117
|
+
return False
|
118
|
+
|
119
|
+
# Record original file hashes.
|
120
|
+
original_hashes = {}
|
121
|
+
for file_path, _ in file_patches:
|
122
|
+
target_file = Path(project_dir) / file_path
|
123
|
+
if target_file.exists():
|
124
|
+
original_hashes[file_path] = file_hash(target_file)
|
125
|
+
else:
|
126
|
+
original_hashes[file_path] = None
|
127
|
+
|
128
|
+
any_change = False
|
129
|
+
# Process each file patch.
|
130
|
+
for file_path, patch in file_patches:
|
131
|
+
target_file = Path(project_dir) / file_path
|
132
|
+
if "+++ /dev/null" in patch:
|
133
|
+
# Deletion patch: delete the file if it exists.
|
134
|
+
if target_file.exists():
|
135
|
+
target_file.unlink()
|
136
|
+
if not target_file.exists():
|
137
|
+
any_change = True
|
138
|
+
else:
|
139
|
+
print(f"Failed to delete file: {target_file}")
|
140
|
+
return False
|
141
|
+
else:
|
142
|
+
# Modification or new file creation.
|
143
|
+
success = apply_patch_to_file(target_file, patch)
|
144
|
+
if not success:
|
145
|
+
print(f"Failed to apply patch to file: {target_file}")
|
146
|
+
return False
|
147
|
+
|
148
|
+
# Verify that at least one file was changed by comparing hashes.
|
149
|
+
for file_path, patch in file_patches:
|
150
|
+
target_file = Path(project_dir) / file_path
|
151
|
+
if "+++ /dev/null" in patch:
|
152
|
+
if not target_file.exists():
|
153
|
+
any_change = True
|
154
|
+
else:
|
155
|
+
print(f"Expected deletion but file still exists: {target_file}")
|
156
|
+
return False
|
157
|
+
else:
|
158
|
+
old_hash = original_hashes.get(file_path)
|
159
|
+
if target_file.exists():
|
160
|
+
new_hash = file_hash(target_file)
|
161
|
+
if old_hash != new_hash:
|
162
|
+
any_change = True
|
163
|
+
else:
|
164
|
+
print(f"No change detected in file: {target_file}")
|
165
|
+
else:
|
166
|
+
print(f"Expected modification or creation but file is missing: {target_file}")
|
167
|
+
return False
|
168
|
+
|
169
|
+
if not any_change:
|
170
|
+
print("Patch applied but no file modifications detected.")
|
171
|
+
return False
|
172
|
+
return True
|
173
|
+
|
174
|
+
def parse_diff_per_file(diff_text):
|
175
|
+
"""Parse unified diff text into individual file patches.
|
176
|
+
|
177
|
+
Splits a multi-file diff into per-file entries for processing. Handles:
|
178
|
+
- File creations (+++ /dev/null)
|
179
|
+
- File deletions (--- /dev/null)
|
180
|
+
- Standard modifications
|
181
|
+
|
182
|
+
Args:
|
183
|
+
diff_text: Unified diff string as generated by `git diff`
|
184
|
+
|
185
|
+
Returns:
|
186
|
+
List of tuples (file_path, patch) where:
|
187
|
+
- file_path: Relative path to modified file
|
188
|
+
- patch: Full diff fragment for this file
|
189
|
+
|
190
|
+
Note:
|
191
|
+
Uses 'b/' prefix detection from git diffs to determine target paths
|
192
|
+
"""
|
193
|
+
header_re = re.compile(r'^(?:diff --git\s+)?(a/[^ ]+)\s+(b/[^ ]+)\s*$', re.MULTILINE)
|
194
|
+
lines = diff_text.splitlines()
|
195
|
+
|
196
|
+
# Check if any header line exists.
|
197
|
+
if not any(header_re.match(line) for line in lines):
|
198
|
+
# Fallback strategy: detect file headers starting with '--- a/' or '-- a/'
|
199
|
+
diffs = []
|
200
|
+
current_lines = []
|
201
|
+
current_file = None
|
202
|
+
deletion_mode = False
|
203
|
+
header_line_re = re.compile(r'^-{2,3}\s+a/(.+)$')
|
204
|
+
|
205
|
+
for line in lines:
|
206
|
+
if header_line_re.match(line):
|
207
|
+
if current_file is not None and current_lines:
|
208
|
+
if deletion_mode and not any(l.startswith("+++ /dev/null") for l in current_lines):
|
209
|
+
current_lines.append("+++ /dev/null")
|
210
|
+
diffs.append((current_file, "\n".join(current_lines)))
|
211
|
+
current_lines = [line]
|
212
|
+
deletion_mode = False
|
213
|
+
file_from = header_line_re.match(line).group(1).strip()
|
214
|
+
current_file = file_from
|
215
|
+
else:
|
216
|
+
current_lines.append(line)
|
217
|
+
if "deleted file mode" in line:
|
218
|
+
deletion_mode = True
|
219
|
+
if line.startswith("+++ "):
|
220
|
+
parts = line.split()
|
221
|
+
if len(parts) >= 2:
|
222
|
+
file_to = parts[1].strip()
|
223
|
+
if file_to != "/dev/null":
|
224
|
+
current_file = file_to[2:] if (file_to.startswith("a/") or file_to.startswith("b/")) else file_to
|
225
|
+
if current_file is not None and current_lines:
|
226
|
+
if deletion_mode and not any(l.startswith("+++ ") for l in current_lines):
|
227
|
+
current_lines.append("+++ /dev/null")
|
228
|
+
diffs.append((current_file, "\n".join(current_lines)))
|
229
|
+
return diffs
|
230
|
+
else:
|
231
|
+
# Use header-based strategy.
|
232
|
+
diffs = []
|
233
|
+
current_lines = []
|
234
|
+
current_file = None
|
235
|
+
deletion_mode = False
|
236
|
+
for line in lines:
|
237
|
+
m = header_re.match(line)
|
238
|
+
if m:
|
239
|
+
if current_file is not None and current_lines:
|
240
|
+
if deletion_mode and not any(l.startswith("+++ ") for l in current_lines):
|
241
|
+
current_lines.append("+++ /dev/null")
|
242
|
+
diffs.append((current_file, "\n".join(current_lines)))
|
243
|
+
current_lines = [line]
|
244
|
+
deletion_mode = False
|
245
|
+
file_from = m.group(1) # e.g. "a/index.html"
|
246
|
+
file_to = m.group(2) # e.g. "b/index.html"
|
247
|
+
current_file = file_to[2:] if file_to.startswith("b/") else file_to
|
248
|
+
else:
|
249
|
+
current_lines.append(line)
|
250
|
+
if "deleted file mode" in line:
|
251
|
+
deletion_mode = True
|
252
|
+
if line.startswith("+++ "):
|
253
|
+
parts = line.split()
|
254
|
+
if len(parts) >= 2:
|
255
|
+
file_to = parts[1].strip()
|
256
|
+
if file_to != "/dev/null":
|
257
|
+
current_file = file_to[2:] if (file_to.startswith("a/") or file_to.startswith("b/")) else file_to
|
258
|
+
if current_file is not None and current_lines:
|
259
|
+
if deletion_mode and not any(l.startswith("+++ ") for l in current_lines):
|
260
|
+
current_lines.append("+++ /dev/null")
|
261
|
+
diffs.append((current_file, "\n".join(current_lines)))
|
262
|
+
return diffs
|
263
|
+
|
264
|
+
|
@@ -3,29 +3,38 @@ from pathlib import Path
|
|
3
3
|
import subprocess
|
4
4
|
import hashlib
|
5
5
|
import re
|
6
|
-
|
6
|
+
import time
|
7
|
+
import os
|
8
|
+
import json
|
9
|
+
import subprocess
|
10
|
+
import sys
|
11
|
+
import fnmatch
|
12
|
+
import argparse
|
13
|
+
import pkgutil
|
14
|
+
import contextvars
|
15
|
+
from pkgutil import get_data
|
16
|
+
import threading
|
7
17
|
|
8
18
|
import openai
|
9
19
|
from openai import OpenAI
|
10
|
-
|
11
20
|
import tiktoken
|
12
21
|
import time
|
13
|
-
|
14
22
|
import os
|
15
23
|
import json
|
16
24
|
import subprocess
|
17
|
-
from pathlib import Path
|
18
25
|
import sys
|
19
26
|
import fnmatch
|
20
27
|
import argparse
|
21
28
|
import pkgutil
|
22
|
-
import re
|
23
29
|
import contextvars
|
24
|
-
from ai_agent_toolbox import MarkdownParser, MarkdownPromptFormatter, Toolbox, FlatXMLParser, FlatXMLPromptFormatter
|
25
|
-
import threading
|
26
30
|
from pkgutil import get_data
|
31
|
+
import threading
|
32
|
+
from ai_agent_toolbox import MarkdownParser, MarkdownPromptFormatter, Toolbox, FlatXMLParser, FlatXMLPromptFormatter
|
33
|
+
from .applydiff import apply_diff, parse_diff_per_file
|
27
34
|
|
35
|
+
VERBOSE = False
|
28
36
|
diff_context = contextvars.ContextVar('diffcontent', default="")
|
37
|
+
|
29
38
|
def create_diff_toolbox():
|
30
39
|
toolbox = Toolbox()
|
31
40
|
|
@@ -97,7 +106,9 @@ def color_code_diff(diff_text: str) -> str:
|
|
97
106
|
|
98
107
|
def load_gitignore_patterns(gitignore_path):
|
99
108
|
with open(gitignore_path, 'r') as f:
|
100
|
-
patterns = [
|
109
|
+
patterns = [
|
110
|
+
line.strip() for line in f if line.strip() and not line.startswith('#')
|
111
|
+
]
|
101
112
|
return patterns
|
102
113
|
|
103
114
|
def is_ignored(filepath, gitignore_patterns):
|
@@ -165,7 +176,7 @@ def load_project_files(project_dir, cwd):
|
|
165
176
|
Prints skipped files to stdout for visibility
|
166
177
|
"""
|
167
178
|
ignore_paths = [Path(cwd) / ".gitignore", Path(cwd) / ".gptignore"]
|
168
|
-
gitignore_patterns = [".gitignore", "diff.patch", "prompt.txt", ".gptignore", "*.pdf", "*.docx", ".git", "*.orig", "*.rej", "*.diff"]
|
179
|
+
gitignore_patterns = [".gitignore", "diff.patch", "prompt.txt", ".*", ".gptignore", "*.pdf", "*.docx", ".git", "*.orig", "*.rej", "*.diff"]
|
169
180
|
|
170
181
|
for p in ignore_paths:
|
171
182
|
if p.exists():
|
@@ -175,14 +186,15 @@ def load_project_files(project_dir, cwd):
|
|
175
186
|
project_files = []
|
176
187
|
for file in list_files_and_dirs(project_dir, gitignore_patterns):
|
177
188
|
if os.path.isfile(file):
|
178
|
-
|
179
|
-
|
180
|
-
|
189
|
+
try:
|
190
|
+
with open(file, 'r') as f:
|
191
|
+
content = f.read()
|
192
|
+
if VERBOSE:
|
181
193
|
print(file)
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
194
|
+
project_files.append((file, content))
|
195
|
+
except UnicodeDecodeError:
|
196
|
+
print(f"Skipping file {file} due to UnicodeDecodeError")
|
197
|
+
continue
|
186
198
|
|
187
199
|
print("")
|
188
200
|
return project_files
|
@@ -194,37 +206,54 @@ def load_prepend_file(file):
|
|
194
206
|
# Function to call GPT-4 API and calculate the cost
|
195
207
|
def call_llm_for_diff(system_prompt, user_prompt, files_content, model, temperature=0.7, max_tokens=30000, api_key=None, base_url=None):
|
196
208
|
enc = tiktoken.get_encoding("o200k_base")
|
209
|
+
|
210
|
+
# Use colors in print statements
|
211
|
+
red = "\033[91m"
|
212
|
+
green = "\033[92m"
|
213
|
+
reset = "\033[0m"
|
197
214
|
start_time = time.time()
|
198
215
|
|
199
216
|
parser = MarkdownParser()
|
200
217
|
formatter = MarkdownPromptFormatter()
|
201
218
|
toolbox = create_diff_toolbox()
|
202
219
|
tool_prompt = formatter.usage_prompt(toolbox)
|
203
|
-
system_prompt += "\n"+tool_prompt
|
220
|
+
system_prompt += "\n" + tool_prompt
|
204
221
|
|
205
222
|
if 'gemini' in model:
|
206
|
-
user_prompt = system_prompt+"\n"+user_prompt
|
223
|
+
user_prompt = system_prompt + "\n" + user_prompt
|
207
224
|
|
208
225
|
messages = [
|
209
|
-
{"role": "system", "content": system_prompt},
|
210
|
-
{"role": "user", "content": user_prompt + "\n"+files_content},
|
226
|
+
{"role": "system", "content": f"{green}{system_prompt}{reset}"},
|
227
|
+
{"role": "user", "content": user_prompt + "\n" + files_content},
|
211
228
|
]
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
229
|
+
if VERBOSE:
|
230
|
+
print(f"{green}Using {model}{reset}")
|
231
|
+
print(f"{green}SYSTEM PROMPT{reset}")
|
232
|
+
print(system_prompt)
|
233
|
+
print(f"{green}USER PROMPT{reset}")
|
234
|
+
print(user_prompt, "+", len(enc.encode(files_content)), "tokens of file content")
|
235
|
+
else:
|
236
|
+
print("Generating diff...")
|
217
237
|
|
218
|
-
if api_key
|
238
|
+
if not api_key:
|
219
239
|
api_key = os.getenv('GPTDIFF_LLM_API_KEY')
|
220
|
-
if base_url
|
240
|
+
if not base_url:
|
221
241
|
base_url = os.getenv('GPTDIFF_LLM_BASE_URL', "https://nano-gpt.com/api/v1/")
|
242
|
+
base_url = base_url or "https://nano-gpt.com/api/v1/"
|
243
|
+
|
222
244
|
client = OpenAI(api_key=api_key, base_url=base_url)
|
223
245
|
response = client.chat.completions.create(model=model,
|
224
246
|
messages=messages,
|
225
247
|
max_tokens=max_tokens,
|
226
248
|
temperature=temperature)
|
227
249
|
|
250
|
+
if VERBOSE:
|
251
|
+
print("RESPONSE RAW-------------")
|
252
|
+
print(response.choices[0].message.content.strip())
|
253
|
+
print("/RESPONSE RAW-------------")
|
254
|
+
else:
|
255
|
+
print("Diff generated.")
|
256
|
+
|
228
257
|
prompt_tokens = response.usage.prompt_tokens
|
229
258
|
completion_tokens = response.usage.completion_tokens
|
230
259
|
total_tokens = response.usage.total_tokens
|
@@ -361,172 +390,6 @@ def smartapply(diff_text, files, model=None, api_key=None, base_url=None):
|
|
361
390
|
|
362
391
|
return files
|
363
392
|
|
364
|
-
def apply_diff(project_dir, diff_text):
|
365
|
-
"""
|
366
|
-
Applies a unified diff (as generated by git diff) to the files in project_dir
|
367
|
-
using pure Python (without calling the external 'patch' command).
|
368
|
-
|
369
|
-
Handles file modifications, new file creation, and file deletions.
|
370
|
-
|
371
|
-
Returns:
|
372
|
-
True if at least one file was modified (or deleted/created) as a result of the patch,
|
373
|
-
False otherwise.
|
374
|
-
"""
|
375
|
-
from pathlib import Path
|
376
|
-
import re, hashlib
|
377
|
-
|
378
|
-
def file_hash(filepath):
|
379
|
-
h = hashlib.sha256()
|
380
|
-
with open(filepath, "rb") as f:
|
381
|
-
h.update(f.read())
|
382
|
-
return h.hexdigest()
|
383
|
-
|
384
|
-
def apply_patch_to_file(file_path, patch):
|
385
|
-
"""
|
386
|
-
Applies a unified diff patch (for a single file) to file_path.
|
387
|
-
|
388
|
-
Returns True if the patch was applied successfully, False otherwise.
|
389
|
-
"""
|
390
|
-
# Read the original file lines; if the file doesn't exist, treat it as empty.
|
391
|
-
if file_path.exists():
|
392
|
-
original_lines = file_path.read_text(encoding="utf8").splitlines(keepends=True)
|
393
|
-
else:
|
394
|
-
original_lines = []
|
395
|
-
new_lines = []
|
396
|
-
current_index = 0
|
397
|
-
|
398
|
-
patch_lines = patch.splitlines()
|
399
|
-
# Regex for a hunk header, e.g., @@ -3,7 +3,6 @@
|
400
|
-
hunk_header_re = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
|
401
|
-
i = 0
|
402
|
-
while i < len(patch_lines):
|
403
|
-
line = patch_lines[i]
|
404
|
-
if line.startswith("@@"):
|
405
|
-
m = hunk_header_re.match(line)
|
406
|
-
if not m:
|
407
|
-
print("Invalid hunk header:", line)
|
408
|
-
return False
|
409
|
-
orig_start = int(m.group(1))
|
410
|
-
# orig_len = int(m.group(2)) if m.group(2) else 1 # not used explicitly
|
411
|
-
# new_start = int(m.group(3))
|
412
|
-
# new_len = int(m.group(4)) if m.group(4) else 1
|
413
|
-
|
414
|
-
# Copy unchanged lines before the hunk.
|
415
|
-
hunk_start_index = orig_start - 1 # diff headers are 1-indexed
|
416
|
-
if hunk_start_index > len(original_lines):
|
417
|
-
print("Hunk start index beyond file length")
|
418
|
-
return False
|
419
|
-
new_lines.extend(original_lines[current_index:hunk_start_index])
|
420
|
-
current_index = hunk_start_index
|
421
|
-
|
422
|
-
i += 1
|
423
|
-
# Process the hunk lines until the next hunk header.
|
424
|
-
while i < len(patch_lines) and not patch_lines[i].startswith("@@"):
|
425
|
-
pline = patch_lines[i]
|
426
|
-
if pline.startswith(" "):
|
427
|
-
# Context line must match exactly.
|
428
|
-
expected = pline[1:]
|
429
|
-
if current_index >= len(original_lines):
|
430
|
-
print("Context line expected but file ended")
|
431
|
-
return False
|
432
|
-
orig_line = original_lines[current_index].rstrip("\n")
|
433
|
-
if orig_line != expected:
|
434
|
-
print("Context line mismatch. Expected:", expected, "Got:", orig_line)
|
435
|
-
return False
|
436
|
-
new_lines.append(original_lines[current_index])
|
437
|
-
current_index += 1
|
438
|
-
elif pline.startswith("-"):
|
439
|
-
# Removal line: verify and skip from original.
|
440
|
-
expected = pline[1:]
|
441
|
-
if current_index >= len(original_lines):
|
442
|
-
print("Removal line expected but file ended")
|
443
|
-
return False
|
444
|
-
orig_line = original_lines[current_index].rstrip("\n")
|
445
|
-
if orig_line != expected:
|
446
|
-
print("Removal line mismatch. Expected:", expected, "Got:", orig_line)
|
447
|
-
return False
|
448
|
-
current_index += 1
|
449
|
-
elif pline.startswith("+"):
|
450
|
-
# Addition line: add to new_lines.
|
451
|
-
new_lines.append(pline[1:] + "\n")
|
452
|
-
else:
|
453
|
-
print("Unexpected line in hunk:", pline)
|
454
|
-
return False
|
455
|
-
i += 1
|
456
|
-
else:
|
457
|
-
# Skip non-hunk header lines.
|
458
|
-
i += 1
|
459
|
-
|
460
|
-
# Append any remaining lines from the original file.
|
461
|
-
new_lines.extend(original_lines[current_index:])
|
462
|
-
# Ensure parent directories exist before writing the file.
|
463
|
-
file_path.parent.mkdir(parents=True, exist_ok=True)
|
464
|
-
# Write the new content back to the file.
|
465
|
-
file_path.write_text("".join(new_lines), encoding="utf8")
|
466
|
-
return True
|
467
|
-
|
468
|
-
# Parse the diff into per-file patches.
|
469
|
-
file_patches = parse_diff_per_file(diff_text)
|
470
|
-
if not file_patches:
|
471
|
-
print("No file patches found in diff.")
|
472
|
-
return False
|
473
|
-
|
474
|
-
# Record original file hashes.
|
475
|
-
original_hashes = {}
|
476
|
-
for file_path, _ in file_patches:
|
477
|
-
target_file = Path(project_dir) / file_path
|
478
|
-
if target_file.exists():
|
479
|
-
original_hashes[file_path] = file_hash(target_file)
|
480
|
-
else:
|
481
|
-
original_hashes[file_path] = None
|
482
|
-
|
483
|
-
any_change = False
|
484
|
-
# Process each file patch.
|
485
|
-
for file_path, patch in file_patches:
|
486
|
-
target_file = Path(project_dir) / file_path
|
487
|
-
if "+++ /dev/null" in patch:
|
488
|
-
# Deletion patch: delete the file if it exists.
|
489
|
-
if target_file.exists():
|
490
|
-
target_file.unlink()
|
491
|
-
if not target_file.exists():
|
492
|
-
any_change = True
|
493
|
-
else:
|
494
|
-
print(f"Failed to delete file: {target_file}")
|
495
|
-
return False
|
496
|
-
else:
|
497
|
-
# Modification or new file creation.
|
498
|
-
success = apply_patch_to_file(target_file, patch)
|
499
|
-
if not success:
|
500
|
-
print(f"Failed to apply patch to file: {target_file}")
|
501
|
-
return False
|
502
|
-
|
503
|
-
# Verify that at least one file was changed by comparing hashes.
|
504
|
-
for file_path, patch in file_patches:
|
505
|
-
target_file = Path(project_dir) / file_path
|
506
|
-
if "+++ /dev/null" in patch:
|
507
|
-
if not target_file.exists():
|
508
|
-
any_change = True
|
509
|
-
else:
|
510
|
-
print(f"Expected deletion but file still exists: {target_file}")
|
511
|
-
return False
|
512
|
-
else:
|
513
|
-
old_hash = original_hashes.get(file_path)
|
514
|
-
if target_file.exists():
|
515
|
-
new_hash = file_hash(target_file)
|
516
|
-
if old_hash != new_hash:
|
517
|
-
any_change = True
|
518
|
-
else:
|
519
|
-
print(f"No change detected in file: {target_file}")
|
520
|
-
else:
|
521
|
-
print(f"Expected modification or creation but file is missing: {target_file}")
|
522
|
-
return False
|
523
|
-
|
524
|
-
if not any_change:
|
525
|
-
print("Patch applied but no file modifications detected.")
|
526
|
-
return False
|
527
|
-
return True
|
528
|
-
|
529
|
-
|
530
393
|
def parse_arguments():
|
531
394
|
parser = argparse.ArgumentParser(description='Generate and optionally apply git diffs using GPT-4.')
|
532
395
|
parser.add_argument('prompt', type=str, help='Prompt that runs on the codebase.')
|
@@ -542,9 +405,8 @@ def parse_arguments():
|
|
542
405
|
parser.add_argument('--max_tokens', type=int, default=30000, help='Temperature parameter for model creativity (0.0 to 2.0)')
|
543
406
|
parser.add_argument('--model', type=str, default=None, help='Model to use for the API call.')
|
544
407
|
parser.add_argument('--applymodel', type=str, default=None, help='Model to use for applying the diff. Defaults to the value of --model if not specified.')
|
545
|
-
|
546
408
|
parser.add_argument('--nowarn', action='store_true', help='Disable large token warning')
|
547
|
-
|
409
|
+
parser.add_argument('--verbose', action='store_true', help='Enable verbose output with detailed information')
|
548
410
|
return parser.parse_args()
|
549
411
|
|
550
412
|
def absolute_to_relative(absolute_path):
|
@@ -552,95 +414,8 @@ def absolute_to_relative(absolute_path):
|
|
552
414
|
relative_path = os.path.relpath(absolute_path, cwd)
|
553
415
|
return relative_path
|
554
416
|
|
555
|
-
def
|
556
|
-
""
|
557
|
-
|
558
|
-
Splits a multi-file diff into per-file entries for processing. Handles:
|
559
|
-
- File creations (+++ /dev/null)
|
560
|
-
- File deletions (--- /dev/null)
|
561
|
-
- Standard modifications
|
562
|
-
|
563
|
-
Args:
|
564
|
-
diff_text: Unified diff string as generated by `git diff`
|
565
|
-
|
566
|
-
Returns:
|
567
|
-
List of tuples (file_path, patch) where:
|
568
|
-
- file_path: Relative path to modified file
|
569
|
-
- patch: Full diff fragment for this file
|
570
|
-
|
571
|
-
Note:
|
572
|
-
Uses 'b/' prefix detection from git diffs to determine target paths
|
573
|
-
"""
|
574
|
-
header_re = re.compile(r'^(?:diff --git\s+)?(a/[^ ]+)\s+(b/[^ ]+)\s*$', re.MULTILINE)
|
575
|
-
lines = diff_text.splitlines()
|
576
|
-
|
577
|
-
# Check if any header line exists.
|
578
|
-
if not any(header_re.match(line) for line in lines):
|
579
|
-
# Fallback strategy: detect file headers starting with '--- a/' or '-- a/'
|
580
|
-
diffs = []
|
581
|
-
current_lines = []
|
582
|
-
current_file = None
|
583
|
-
deletion_mode = False
|
584
|
-
header_line_re = re.compile(r'^-{2,3}\s+a/(.+)$')
|
585
|
-
|
586
|
-
for line in lines:
|
587
|
-
if header_line_re.match(line):
|
588
|
-
if current_file is not None and current_lines:
|
589
|
-
if deletion_mode and not any(l.startswith("+++ /dev/null") for l in current_lines):
|
590
|
-
current_lines.append("+++ /dev/null")
|
591
|
-
diffs.append((current_file, "\n".join(current_lines)))
|
592
|
-
current_lines = [line]
|
593
|
-
deletion_mode = False
|
594
|
-
file_from = header_line_re.match(line).group(1).strip()
|
595
|
-
current_file = file_from
|
596
|
-
else:
|
597
|
-
current_lines.append(line)
|
598
|
-
if "deleted file mode" in line:
|
599
|
-
deletion_mode = True
|
600
|
-
if line.startswith("+++ "):
|
601
|
-
parts = line.split()
|
602
|
-
if len(parts) >= 2:
|
603
|
-
file_to = parts[1].strip()
|
604
|
-
if file_to != "/dev/null":
|
605
|
-
current_file = file_to[2:] if (file_to.startswith("a/") or file_to.startswith("b/")) else file_to
|
606
|
-
if current_file is not None and current_lines:
|
607
|
-
if deletion_mode and not any(l.startswith("+++ ") for l in current_lines):
|
608
|
-
current_lines.append("+++ /dev/null")
|
609
|
-
diffs.append((current_file, "\n".join(current_lines)))
|
610
|
-
return diffs
|
611
|
-
else:
|
612
|
-
# Use header-based strategy.
|
613
|
-
diffs = []
|
614
|
-
current_lines = []
|
615
|
-
current_file = None
|
616
|
-
deletion_mode = False
|
617
|
-
for line in lines:
|
618
|
-
m = header_re.match(line)
|
619
|
-
if m:
|
620
|
-
if current_file is not None and current_lines:
|
621
|
-
if deletion_mode and not any(l.startswith("+++ ") for l in current_lines):
|
622
|
-
current_lines.append("+++ /dev/null")
|
623
|
-
diffs.append((current_file, "\n".join(current_lines)))
|
624
|
-
current_lines = [line]
|
625
|
-
deletion_mode = False
|
626
|
-
file_from = m.group(1) # e.g. "a/index.html"
|
627
|
-
file_to = m.group(2) # e.g. "b/index.html"
|
628
|
-
current_file = file_to[2:] if file_to.startswith("b/") else file_to
|
629
|
-
else:
|
630
|
-
current_lines.append(line)
|
631
|
-
if "deleted file mode" in line:
|
632
|
-
deletion_mode = True
|
633
|
-
if line.startswith("+++ "):
|
634
|
-
parts = line.split()
|
635
|
-
if len(parts) >= 2:
|
636
|
-
file_to = parts[1].strip()
|
637
|
-
if file_to != "/dev/null":
|
638
|
-
current_file = file_to[2:] if (file_to.startswith("a/") or file_to.startswith("b/")) else file_to
|
639
|
-
if current_file is not None and current_lines:
|
640
|
-
if deletion_mode and not any(l.startswith("+++ ") for l in current_lines):
|
641
|
-
current_lines.append("+++ /dev/null")
|
642
|
-
diffs.append((current_file, "\n".join(current_lines)))
|
643
|
-
return diffs
|
417
|
+
def colorize_warning_warning(message):
|
418
|
+
return f"\033[91m\033[1m{message}\033[0m"
|
644
419
|
|
645
420
|
def call_llm_for_apply_with_think_tool_available(file_path, original_content, file_diff, model, api_key=None, base_url=None, extra_prompt=None, max_tokens=30000):
|
646
421
|
parser = FlatXMLParser("think")
|
@@ -720,9 +495,9 @@ Diff to apply:
|
|
720
495
|
{"role": "system", "content": system_prompt},
|
721
496
|
{"role": "user", "content": user_prompt},
|
722
497
|
]
|
723
|
-
if api_key
|
498
|
+
if not api_key:
|
724
499
|
api_key = os.getenv('GPTDIFF_LLM_API_KEY')
|
725
|
-
if base_url
|
500
|
+
if not base_url:
|
726
501
|
base_url = os.getenv('GPTDIFF_LLM_BASE_URL', "https://nano-gpt.com/api/v1/")
|
727
502
|
client = OpenAI(api_key=api_key, base_url=base_url)
|
728
503
|
start_time = time.time()
|
@@ -734,8 +509,11 @@ Diff to apply:
|
|
734
509
|
elapsed = time.time() - start_time
|
735
510
|
minutes, seconds = divmod(int(elapsed), 60)
|
736
511
|
time_str = f"{minutes}m {seconds}s" if minutes else f"{seconds}s"
|
737
|
-
|
738
|
-
|
512
|
+
if VERBOSE:
|
513
|
+
print(f"Smartapply time: {time_str}")
|
514
|
+
print("-" * 40)
|
515
|
+
else:
|
516
|
+
print(f"Smartapply completed in {time_str}")
|
739
517
|
return full_response
|
740
518
|
|
741
519
|
def build_environment_from_filelist(file_list, cwd):
|
@@ -763,7 +541,7 @@ def smart_apply_patch(project_dir, diff_text, user_prompt, args):
|
|
763
541
|
parsed_diffs = parse_diff_per_file(diff_text)
|
764
542
|
print("Found", len(parsed_diffs), "files in diff, processing smart apply concurrently:")
|
765
543
|
if len(parsed_diffs) == 0:
|
766
|
-
print("
|
544
|
+
print(colorize_warning_warning("There were no entries in this diff. The LLM may have returned something invalid."))
|
767
545
|
if args.beep:
|
768
546
|
print("\a")
|
769
547
|
return
|
@@ -771,13 +549,14 @@ def smart_apply_patch(project_dir, diff_text, user_prompt, args):
|
|
771
549
|
|
772
550
|
def process_file(file_path, file_diff):
|
773
551
|
full_path = Path(project_dir) / file_path
|
774
|
-
|
552
|
+
if VERBOSE:
|
553
|
+
print(f"Processing file: {file_path}")
|
775
554
|
if '+++ /dev/null' in file_diff:
|
776
555
|
if full_path.exists():
|
777
556
|
full_path.unlink()
|
778
557
|
print(f"\033[1;32mDeleted file {file_path}.\033[0m")
|
779
558
|
else:
|
780
|
-
print(f"
|
559
|
+
print(colorize_warning_warning(f"File {file_path} not found - skipping deletion"))
|
781
560
|
return
|
782
561
|
|
783
562
|
try:
|
@@ -862,11 +641,13 @@ def save_files(files_dict, target_directory):
|
|
862
641
|
print(f"Saved: {full_path}")
|
863
642
|
|
864
643
|
def main():
|
644
|
+
global VERBOSE
|
865
645
|
# Adding color support for Windows CMD
|
866
646
|
if os.name == 'nt':
|
867
647
|
os.system('color')
|
868
648
|
|
869
649
|
args = parse_arguments()
|
650
|
+
VERBOSE = args.verbose
|
870
651
|
|
871
652
|
# openai.api_base = "https://nano-gpt.com/api/v1/"
|
872
653
|
if len(sys.argv) < 2:
|
@@ -916,9 +697,8 @@ def main():
|
|
916
697
|
|
917
698
|
files_content = ""
|
918
699
|
for file, content in project_files:
|
919
|
-
|
920
|
-
|
921
|
-
# Prepare the prompt for GPT-4
|
700
|
+
if VERBOSE:
|
701
|
+
print(f"Including {len(enc.encode(content)):5d} tokens", absolute_to_relative(file))
|
922
702
|
files_content += f"File: {absolute_to_relative(file)}\nContent:\n{content}\n"
|
923
703
|
|
924
704
|
full_prompt = f"{system_prompt}\n\n{user_prompt}\n\n{files_content}"
|
@@ -13,8 +13,7 @@ This tool uses the same patch-application logic as gptdiff.
|
|
13
13
|
import sys
|
14
14
|
import argparse
|
15
15
|
from pathlib import Path
|
16
|
-
from gptdiff.gptdiff import apply_diff
|
17
|
-
|
16
|
+
from gptdiff.gptdiff import apply_diff, smart_apply_patch
|
18
17
|
|
19
18
|
def parse_arguments():
|
20
19
|
parser = argparse.ArgumentParser(
|
@@ -50,6 +49,7 @@ def parse_arguments():
|
|
50
49
|
default=30000,
|
51
50
|
help="Maximum tokens to use for LLM responses"
|
52
51
|
)
|
52
|
+
parser.add_argument('--dumb', action='store_true', default=False, help='Attempt dumb apply before trying smart apply')
|
53
53
|
return parser.parse_args()
|
54
54
|
|
55
55
|
def main():
|
@@ -64,12 +64,14 @@ def main():
|
|
64
64
|
diff_text = diff_path.read_text(encoding="utf8")
|
65
65
|
|
66
66
|
project_dir = args.project_dir
|
67
|
-
|
68
|
-
|
69
|
-
|
67
|
+
if args.dumb:
|
68
|
+
success = apply_diff(project_dir, diff_text)
|
69
|
+
if success:
|
70
|
+
print("✅ Diff applied successfully.")
|
71
|
+
else:
|
72
|
+
print("❌ Failed to apply diff using git apply. Attempting smart apply.")
|
73
|
+
smart_apply_patch(project_dir, diff_text, "", args)
|
70
74
|
else:
|
71
|
-
print("❌ Failed to apply diff using git apply. Attempting smart apply.")
|
72
|
-
from gptdiff.gptdiff import smart_apply_patch
|
73
75
|
smart_apply_patch(project_dir, diff_text, "", args)
|
74
76
|
|
75
77
|
if __name__ == "__main__":
|
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
|
|
2
2
|
|
3
3
|
setup(
|
4
4
|
name='gptdiff',
|
5
|
-
version='0.1.
|
5
|
+
version='0.1.24',
|
6
6
|
description='A tool to generate and apply git diffs using LLMs',
|
7
7
|
author='255labs',
|
8
8
|
packages=find_packages(), # Use find_packages() to automatically discover packages
|
@@ -0,0 +1,143 @@
|
|
1
|
+
import os
|
2
|
+
from pathlib import Path
|
3
|
+
|
4
|
+
import pytest
|
5
|
+
|
6
|
+
from gptdiff.gptdiff import apply_diff
|
7
|
+
|
8
|
+
|
9
|
+
@pytest.fixture
|
10
|
+
def tmp_project_dir_with_file(tmp_path):
|
11
|
+
"""
|
12
|
+
Create a temporary project directory with a dummy file to patch.
|
13
|
+
"""
|
14
|
+
project_dir = tmp_path / "project"
|
15
|
+
project_dir.mkdir()
|
16
|
+
file = project_dir / "example.txt"
|
17
|
+
file.write_text("original content\n")
|
18
|
+
return project_dir
|
19
|
+
|
20
|
+
|
21
|
+
def test_apply_diff_success(tmp_project_dir_with_file):
|
22
|
+
"""
|
23
|
+
Test that apply_diff successfully applies a valid diff.
|
24
|
+
The diff changes 'original content' to 'modified content'.
|
25
|
+
"""
|
26
|
+
diff_text = (
|
27
|
+
"diff --git a/example.txt b/example.txt\n"
|
28
|
+
"--- a/example.txt\n"
|
29
|
+
"+++ a/example.txt\n"
|
30
|
+
"@@ -1 +1 @@\n"
|
31
|
+
"-original content\n"
|
32
|
+
"+modified content\n"
|
33
|
+
)
|
34
|
+
result = apply_diff(str(tmp_project_dir_with_file), diff_text)
|
35
|
+
assert result is True, "apply_diff should return True for a successful patch"
|
36
|
+
|
37
|
+
file_path = tmp_project_dir_with_file / "example.txt"
|
38
|
+
content = file_path.read_text()
|
39
|
+
assert "modified content" in content, "File content should be updated to 'modified content'"
|
40
|
+
|
41
|
+
|
42
|
+
def test_apply_diff_failure(tmp_project_dir_with_file):
|
43
|
+
"""
|
44
|
+
Test that apply_diff fails when provided with an incorrect hunk header.
|
45
|
+
The diff references a non-existent line, so the patch should not apply.
|
46
|
+
"""
|
47
|
+
diff_text = (
|
48
|
+
"diff --git a/example.txt b/example.txt\n"
|
49
|
+
"--- a/example.txt\n"
|
50
|
+
"+++ a/example.txt\n"
|
51
|
+
"@@ -2,1 +2,1 @@\n"
|
52
|
+
"-original content\n"
|
53
|
+
"+modified content\n"
|
54
|
+
)
|
55
|
+
result = apply_diff(str(tmp_project_dir_with_file), diff_text)
|
56
|
+
assert result is False, "apply_diff should return False when the diff fails to apply"
|
57
|
+
|
58
|
+
file_path = tmp_project_dir_with_file / "example.txt"
|
59
|
+
content = file_path.read_text()
|
60
|
+
assert "original content" in content, "File content should remain unchanged on failure"
|
61
|
+
|
62
|
+
|
63
|
+
def test_apply_diff_file_deletion(tmp_project_dir_with_file):
|
64
|
+
"""
|
65
|
+
Test that apply_diff can successfully delete a file.
|
66
|
+
The diff marks 'example.txt' for deletion.
|
67
|
+
"""
|
68
|
+
diff_text = (
|
69
|
+
"diff --git a/example.txt b/example.txt\n"
|
70
|
+
"deleted file mode 100644\n"
|
71
|
+
"--- a/example.txt\n"
|
72
|
+
"+++ /dev/null\n"
|
73
|
+
"@@ -1,1 +0,0 @@\n"
|
74
|
+
"-original content\n"
|
75
|
+
)
|
76
|
+
result = apply_diff(str(tmp_project_dir_with_file), diff_text)
|
77
|
+
assert result is True, "apply_diff should return True for a successful file deletion"
|
78
|
+
|
79
|
+
file_path = tmp_project_dir_with_file / "example.txt"
|
80
|
+
assert not file_path.exists(), "File should be deleted after applying the diff"
|
81
|
+
|
82
|
+
|
83
|
+
@pytest.fixture
|
84
|
+
def tmp_project_dir_empty(tmp_path):
|
85
|
+
project_dir = tmp_path / "project"
|
86
|
+
project_dir.mkdir()
|
87
|
+
return project_dir
|
88
|
+
|
89
|
+
|
90
|
+
def test_minimal_new_file_diff(tmp_project_dir_empty):
|
91
|
+
diff_text = (
|
92
|
+
"diff --git a/new.txt b/new.txt\n"
|
93
|
+
"new file mode 100644\n"
|
94
|
+
"--- /dev/null\n"
|
95
|
+
"+++ b/new.txt\n"
|
96
|
+
"@@\n"
|
97
|
+
"+hello world\n"
|
98
|
+
)
|
99
|
+
result = apply_diff(str(tmp_project_dir_empty), diff_text)
|
100
|
+
assert result is True
|
101
|
+
new_file = tmp_project_dir_empty / "new.txt"
|
102
|
+
assert new_file.exists()
|
103
|
+
assert new_file.read_text() == "hello world\n"
|
104
|
+
|
105
|
+
import pytest
|
106
|
+
from pathlib import Path
|
107
|
+
from gptdiff.gptdiff import apply_diff
|
108
|
+
|
109
|
+
@pytest.fixture
|
110
|
+
def tmp_project_dir_empty(tmp_path):
|
111
|
+
project_dir = tmp_path / "project"
|
112
|
+
project_dir.mkdir()
|
113
|
+
return project_dir
|
114
|
+
|
115
|
+
def test_new_file_creation_minimal_header_failure(tmp_project_dir_empty):
|
116
|
+
"""
|
117
|
+
Test that a minimal diff for new file creation (with a hunk header that lacks line numbers)
|
118
|
+
creates the file with the expected content.
|
119
|
+
|
120
|
+
Diff text:
|
121
|
+
--- /dev/null
|
122
|
+
+++ b/test_feature_1739491796.py
|
123
|
+
@@
|
124
|
+
+import pytest
|
125
|
+
+
|
126
|
+
|
127
|
+
Expected result: a new file "test_feature_1739491796.py" containing "import pytest\n"
|
128
|
+
"""
|
129
|
+
diff_text = (
|
130
|
+
"--- /dev/null\n"
|
131
|
+
"+++ b/test_feature_1739491796.py\n"
|
132
|
+
"@@\n"
|
133
|
+
"+import pytest\n"
|
134
|
+
"+\n"
|
135
|
+
)
|
136
|
+
result = apply_diff(str(tmp_project_dir_empty), diff_text)
|
137
|
+
assert result is True, "apply_diff should return True for a successful new file creation"
|
138
|
+
new_file = tmp_project_dir_empty / "test_feature_1739491796.py"
|
139
|
+
assert new_file.exists(), "New file should be created"
|
140
|
+
expected_content = "import pytest\n"
|
141
|
+
content = new_file.read_text()
|
142
|
+
assert content.strip() == expected_content.strip(), f"Expected file content:\n{expected_content}\nGot:\n{content}"
|
143
|
+
|
@@ -122,7 +122,7 @@ def test_smartapply_modify_nonexistent_file():
|
|
122
122
|
result = smartapply(diff_text, original_files)
|
123
123
|
assert "newfile.py" in result
|
124
124
|
|
125
|
-
def test_smartapply_multi_file_modification(
|
125
|
+
def test_smartapply_multi_file_modification(monkeypatch):
|
126
126
|
"""Test smartapply handles multi-file modifications through LLM integration.
|
127
127
|
|
128
128
|
Verifies:
|
@@ -167,8 +167,7 @@ diff --git a/file2.py b/file2.py
|
|
167
167
|
return "def func2():\n print('New func2')"
|
168
168
|
return original_content
|
169
169
|
|
170
|
-
|
171
|
-
|
170
|
+
monkeypatch.setattr('gptdiff.gptdiff.call_llm_for_apply', mock_call_llm)
|
172
171
|
updated_files = smartapply(diff_text, original_files)
|
173
172
|
|
174
173
|
# Verify both target files modified
|
@@ -184,7 +183,7 @@ diff --git a/file2.py b/file2.py
|
|
184
183
|
assert updated_files["unrelated.py"] == "def unrelated():\n pass"
|
185
184
|
|
186
185
|
|
187
|
-
def test_smartapply_complex_single_hunk(
|
186
|
+
def test_smartapply_complex_single_hunk(monkeypatch):
|
188
187
|
"""Test complex single hunk with multiple change types
|
189
188
|
|
190
189
|
Validates proper handling of:
|
@@ -235,7 +234,7 @@ def test_smartapply_complex_single_hunk(mocker):
|
|
235
234
|
" results.append(x ** 2)\n"
|
236
235
|
" return results"
|
237
236
|
)
|
238
|
-
|
237
|
+
monkeypatch.setattr('gptdiff.gptdiff.call_llm_for_apply', lambda *args, **kwargs: expected_content)
|
239
238
|
|
240
239
|
updated_files = smartapply(diff_text, original_files)
|
241
240
|
|
@@ -1,80 +0,0 @@
|
|
1
|
-
import os
|
2
|
-
from pathlib import Path
|
3
|
-
|
4
|
-
import pytest
|
5
|
-
|
6
|
-
from gptdiff.gptdiff import apply_diff
|
7
|
-
|
8
|
-
|
9
|
-
@pytest.fixture
|
10
|
-
def tmp_project_dir(tmp_path):
|
11
|
-
"""
|
12
|
-
Create a temporary project directory with a dummy file to patch.
|
13
|
-
"""
|
14
|
-
project_dir = tmp_path / "project"
|
15
|
-
project_dir.mkdir()
|
16
|
-
file = project_dir / "example.txt"
|
17
|
-
file.write_text("original content\n")
|
18
|
-
return project_dir
|
19
|
-
|
20
|
-
|
21
|
-
def test_apply_diff_success(tmp_project_dir):
|
22
|
-
"""
|
23
|
-
Test that apply_diff successfully applies a valid diff.
|
24
|
-
The diff changes 'original content' to 'modified content'.
|
25
|
-
"""
|
26
|
-
diff_text = (
|
27
|
-
"diff --git a/example.txt b/example.txt\n"
|
28
|
-
"--- a/example.txt\n"
|
29
|
-
"+++ b/example.txt\n"
|
30
|
-
"@@ -1 +1 @@\n"
|
31
|
-
"-original content\n"
|
32
|
-
"+modified content\n"
|
33
|
-
)
|
34
|
-
result = apply_diff(str(tmp_project_dir), diff_text)
|
35
|
-
assert result is True, "apply_diff should return True for a successful patch"
|
36
|
-
|
37
|
-
file_path = tmp_project_dir / "example.txt"
|
38
|
-
content = file_path.read_text()
|
39
|
-
assert "modified content" in content, "File content should be updated to 'modified content'"
|
40
|
-
|
41
|
-
|
42
|
-
def test_apply_diff_failure(tmp_project_dir):
|
43
|
-
"""
|
44
|
-
Test that apply_diff fails when provided with an incorrect hunk header.
|
45
|
-
The diff references a non-existent line, so the patch should not apply.
|
46
|
-
"""
|
47
|
-
diff_text = (
|
48
|
-
"diff --git a/example.txt b/example.txt\n"
|
49
|
-
"--- a/example.txt\n"
|
50
|
-
"+++ a/example.txt\n"
|
51
|
-
"@@ -2,1 +2,1 @@\n"
|
52
|
-
"-original content\n"
|
53
|
-
"+modified content\n"
|
54
|
-
)
|
55
|
-
result = apply_diff(str(tmp_project_dir), diff_text)
|
56
|
-
assert result is False, "apply_diff should return False when the diff fails to apply"
|
57
|
-
|
58
|
-
file_path = tmp_project_dir / "example.txt"
|
59
|
-
content = file_path.read_text()
|
60
|
-
assert "original content" in content, "File content should remain unchanged on failure"
|
61
|
-
|
62
|
-
|
63
|
-
def test_apply_diff_file_deletion(tmp_project_dir):
|
64
|
-
"""
|
65
|
-
Test that apply_diff can successfully delete a file.
|
66
|
-
The diff marks 'example.txt' for deletion.
|
67
|
-
"""
|
68
|
-
diff_text = (
|
69
|
-
"diff --git a/example.txt b/example.txt\n"
|
70
|
-
"deleted file mode 100644\n"
|
71
|
-
"--- a/example.txt\n"
|
72
|
-
"+++ /dev/null\n"
|
73
|
-
"@@ -1,1 +0,0 @@\n"
|
74
|
-
"-original content\n"
|
75
|
-
)
|
76
|
-
result = apply_diff(str(tmp_project_dir), diff_text)
|
77
|
-
assert result is True, "apply_diff should return True for a successful file deletion"
|
78
|
-
|
79
|
-
file_path = tmp_project_dir / "example.txt"
|
80
|
-
assert not file_path.exists(), "File should be deleted after applying the diff"
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|