gptdiff 0.1.22__py3-none-any.whl → 0.1.27__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.
- gptdiff/applydiff.py +265 -0
- gptdiff/gptdiff.py +150 -325
- gptdiff/gptpatch.py +19 -8
- {gptdiff-0.1.22.dist-info → gptdiff-0.1.27.dist-info}/METADATA +9 -7
- gptdiff-0.1.27.dist-info/RECORD +10 -0
- gptdiff-0.1.22.dist-info/RECORD +0 -9
- {gptdiff-0.1.22.dist-info → gptdiff-0.1.27.dist-info}/LICENSE.txt +0 -0
- {gptdiff-0.1.22.dist-info → gptdiff-0.1.27.dist-info}/WHEEL +0 -0
- {gptdiff-0.1.22.dist-info → gptdiff-0.1.27.dist-info}/entry_points.txt +0 -0
- {gptdiff-0.1.22.dist-info → gptdiff-0.1.27.dist-info}/top_level.txt +0 -0
gptdiff/applydiff.py
ADDED
@@ -0,0 +1,265 @@
|
|
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
|
+
This doesn't work all the time and needs to be revised with stronger models
|
193
|
+
"""
|
194
|
+
header_re = re.compile(r'^(?:diff --git\s+)?(a/[^ ]+)\s+(b/[^ ]+)\s*$', re.MULTILINE)
|
195
|
+
lines = diff_text.splitlines()
|
196
|
+
|
197
|
+
# Check if any header line exists.
|
198
|
+
if not any(header_re.match(line) for line in lines):
|
199
|
+
# Fallback strategy: detect file headers starting with '--- a/' or '-- a/'
|
200
|
+
diffs = []
|
201
|
+
current_lines = []
|
202
|
+
current_file = None
|
203
|
+
deletion_mode = False
|
204
|
+
header_line_re = re.compile(r'^-{2,3}\s+a/(.+)$')
|
205
|
+
|
206
|
+
for line in lines:
|
207
|
+
if header_line_re.match(line):
|
208
|
+
if current_file is not None and current_lines:
|
209
|
+
if deletion_mode and not any(l.startswith("+++ /dev/null") for l in current_lines):
|
210
|
+
current_lines.append("+++ /dev/null")
|
211
|
+
diffs.append((current_file, "\n".join(current_lines)))
|
212
|
+
current_lines = [line]
|
213
|
+
deletion_mode = False
|
214
|
+
file_from = header_line_re.match(line).group(1).strip()
|
215
|
+
current_file = file_from
|
216
|
+
else:
|
217
|
+
current_lines.append(line)
|
218
|
+
if "deleted file mode" in line:
|
219
|
+
deletion_mode = True
|
220
|
+
if line.startswith("+++ "):
|
221
|
+
parts = line.split()
|
222
|
+
if len(parts) >= 2:
|
223
|
+
file_to = parts[1].strip()
|
224
|
+
if file_to != "/dev/null":
|
225
|
+
current_file = file_to[2:] if (file_to.startswith("a/") or file_to.startswith("b/")) else file_to
|
226
|
+
if current_file is not None and current_lines:
|
227
|
+
if deletion_mode and not any(l.startswith("+++ ") for l in current_lines):
|
228
|
+
current_lines.append("+++ /dev/null")
|
229
|
+
diffs.append((current_file, "\n".join(current_lines)))
|
230
|
+
return diffs
|
231
|
+
else:
|
232
|
+
# Use header-based strategy.
|
233
|
+
diffs = []
|
234
|
+
current_lines = []
|
235
|
+
current_file = None
|
236
|
+
deletion_mode = False
|
237
|
+
for line in lines:
|
238
|
+
m = header_re.match(line)
|
239
|
+
if m:
|
240
|
+
if current_file is not None and current_lines:
|
241
|
+
if deletion_mode and not any(l.startswith("+++ ") for l in current_lines):
|
242
|
+
current_lines.append("+++ /dev/null")
|
243
|
+
diffs.append((current_file, "\n".join(current_lines)))
|
244
|
+
current_lines = [line]
|
245
|
+
deletion_mode = False
|
246
|
+
file_from = m.group(1) # e.g. "a/index.html"
|
247
|
+
file_to = m.group(2) # e.g. "b/index.html"
|
248
|
+
current_file = file_to[2:] if file_to.startswith("b/") else file_to
|
249
|
+
else:
|
250
|
+
current_lines.append(line)
|
251
|
+
if "deleted file mode" in line:
|
252
|
+
deletion_mode = True
|
253
|
+
if line.startswith("+++ "):
|
254
|
+
parts = line.split()
|
255
|
+
if len(parts) >= 2:
|
256
|
+
file_to = parts[1].strip()
|
257
|
+
if file_to != "/dev/null":
|
258
|
+
current_file = file_to[2:] if (file_to.startswith("a/") or file_to.startswith("b/")) else file_to
|
259
|
+
if current_file is not None and current_lines:
|
260
|
+
if deletion_mode and not any(l.startswith("+++ ") for l in current_lines):
|
261
|
+
current_lines.append("+++ /dev/null")
|
262
|
+
diffs.append((current_file, "\n".join(current_lines)))
|
263
|
+
return diffs
|
264
|
+
|
265
|
+
|
gptdiff/gptdiff.py
CHANGED
@@ -1,31 +1,42 @@
|
|
1
1
|
#!/usr/bin/env python3
|
2
2
|
from pathlib import Path
|
3
|
+
from urllib.parse import urlparse
|
3
4
|
import subprocess
|
4
5
|
import hashlib
|
5
6
|
import re
|
6
|
-
|
7
|
+
import time
|
8
|
+
import os
|
9
|
+
import json
|
10
|
+
import subprocess
|
11
|
+
import sys
|
12
|
+
import fnmatch
|
13
|
+
import argparse
|
14
|
+
import pkgutil
|
15
|
+
import contextvars
|
16
|
+
from pkgutil import get_data
|
17
|
+
import threading
|
18
|
+
from threading import Lock
|
7
19
|
|
8
20
|
import openai
|
9
21
|
from openai import OpenAI
|
10
|
-
|
11
22
|
import tiktoken
|
12
23
|
import time
|
13
|
-
|
14
24
|
import os
|
15
25
|
import json
|
16
26
|
import subprocess
|
17
|
-
from pathlib import Path
|
18
27
|
import sys
|
19
28
|
import fnmatch
|
20
29
|
import argparse
|
21
30
|
import pkgutil
|
22
|
-
import re
|
23
31
|
import contextvars
|
24
|
-
from ai_agent_toolbox import MarkdownParser, MarkdownPromptFormatter, Toolbox, FlatXMLParser, FlatXMLPromptFormatter
|
25
|
-
import threading
|
26
32
|
from pkgutil import get_data
|
33
|
+
import threading
|
34
|
+
from ai_agent_toolbox import MarkdownParser, MarkdownPromptFormatter, Toolbox, FlatXMLParser, FlatXMLPromptFormatter
|
35
|
+
from .applydiff import apply_diff, parse_diff_per_file
|
27
36
|
|
37
|
+
VERBOSE = False
|
28
38
|
diff_context = contextvars.ContextVar('diffcontent', default="")
|
39
|
+
|
29
40
|
def create_diff_toolbox():
|
30
41
|
toolbox = Toolbox()
|
31
42
|
|
@@ -97,7 +108,9 @@ def color_code_diff(diff_text: str) -> str:
|
|
97
108
|
|
98
109
|
def load_gitignore_patterns(gitignore_path):
|
99
110
|
with open(gitignore_path, 'r') as f:
|
100
|
-
patterns = [
|
111
|
+
patterns = [
|
112
|
+
line.strip() for line in f if line.strip() and not line.startswith('#')
|
113
|
+
]
|
101
114
|
return patterns
|
102
115
|
|
103
116
|
def is_ignored(filepath, gitignore_patterns):
|
@@ -165,7 +178,7 @@ def load_project_files(project_dir, cwd):
|
|
165
178
|
Prints skipped files to stdout for visibility
|
166
179
|
"""
|
167
180
|
ignore_paths = [Path(cwd) / ".gitignore", Path(cwd) / ".gptignore"]
|
168
|
-
gitignore_patterns = [".gitignore", "diff.patch", "prompt.txt", ".gptignore", "*.pdf", "*.docx", ".git", "*.orig", "*.rej", "*.diff"]
|
181
|
+
gitignore_patterns = [".gitignore", "diff.patch", "prompt.txt", ".*", ".gptignore", "*.pdf", "*.docx", ".git", "*.orig", "*.rej", "*.diff"]
|
169
182
|
|
170
183
|
for p in ignore_paths:
|
171
184
|
if p.exists():
|
@@ -175,14 +188,15 @@ def load_project_files(project_dir, cwd):
|
|
175
188
|
project_files = []
|
176
189
|
for file in list_files_and_dirs(project_dir, gitignore_patterns):
|
177
190
|
if os.path.isfile(file):
|
178
|
-
|
179
|
-
|
180
|
-
|
191
|
+
try:
|
192
|
+
with open(file, 'r') as f:
|
193
|
+
content = f.read()
|
194
|
+
if VERBOSE:
|
181
195
|
print(file)
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
196
|
+
project_files.append((file, content))
|
197
|
+
except UnicodeDecodeError:
|
198
|
+
print(f"Skipping file {file} due to UnicodeDecodeError")
|
199
|
+
continue
|
186
200
|
|
187
201
|
print("")
|
188
202
|
return project_files
|
@@ -191,40 +205,73 @@ def load_prepend_file(file):
|
|
191
205
|
with open(file, 'r') as f:
|
192
206
|
return f.read()
|
193
207
|
|
194
|
-
|
208
|
+
def domain_for_url(base_url):
|
209
|
+
parsed = urlparse(base_url)
|
210
|
+
if parsed.netloc:
|
211
|
+
if parsed.username:
|
212
|
+
domain = parsed.hostname
|
213
|
+
if parsed.port:
|
214
|
+
domain += f":{parsed.port}"
|
215
|
+
else:
|
216
|
+
domain = parsed.netloc
|
217
|
+
else:
|
218
|
+
domain = base_url
|
219
|
+
return domain
|
220
|
+
|
195
221
|
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
222
|
enc = tiktoken.get_encoding("o200k_base")
|
223
|
+
|
224
|
+
# Use colors in print statements
|
225
|
+
red = "\033[91m"
|
226
|
+
green = "\033[92m"
|
227
|
+
blue = "\033[94m"
|
228
|
+
reset = "\033[0m"
|
197
229
|
start_time = time.time()
|
198
230
|
|
199
231
|
parser = MarkdownParser()
|
200
232
|
formatter = MarkdownPromptFormatter()
|
201
233
|
toolbox = create_diff_toolbox()
|
202
234
|
tool_prompt = formatter.usage_prompt(toolbox)
|
203
|
-
system_prompt += "\n"+tool_prompt
|
235
|
+
system_prompt += "\n" + tool_prompt
|
204
236
|
|
205
237
|
if 'gemini' in model:
|
206
|
-
user_prompt = system_prompt+"\n"+user_prompt
|
238
|
+
user_prompt = system_prompt + "\n" + user_prompt
|
207
239
|
|
240
|
+
input_content = system_prompt + "\n" + user_prompt + "\n" + files_content
|
241
|
+
token_count = len(enc.encode(input_content))
|
208
242
|
messages = [
|
209
243
|
{"role": "system", "content": system_prompt},
|
210
|
-
{"role": "user", "content": user_prompt + "\n"+files_content},
|
244
|
+
{"role": "user", "content": user_prompt + "\n" + files_content},
|
211
245
|
]
|
212
|
-
print("Using", model)
|
213
|
-
print("SYSTEM PROMPT")
|
214
|
-
print(system_prompt)
|
215
|
-
print("USER PROMPT")
|
216
|
-
print(user_prompt, "+", len(enc.encode(files_content)), "tokens of file content")
|
217
246
|
|
218
|
-
if
|
247
|
+
if VERBOSE:
|
248
|
+
print(f"{green}Using {model}{reset}")
|
249
|
+
print(f"{green}SYSTEM PROMPT{reset}")
|
250
|
+
print(system_prompt)
|
251
|
+
print(f"{green}USER PROMPT{reset}")
|
252
|
+
print(user_prompt, "+", len(enc.encode(files_content)), "tokens of file content")
|
253
|
+
else:
|
254
|
+
print(f"Generating diff using model '{green}{model}{reset}' from '{blue}{domain_for_url(base_url)}{reset}' with {token_count} input tokens...")
|
255
|
+
|
256
|
+
if not api_key:
|
219
257
|
api_key = os.getenv('GPTDIFF_LLM_API_KEY')
|
220
|
-
if base_url
|
258
|
+
if not base_url:
|
221
259
|
base_url = os.getenv('GPTDIFF_LLM_BASE_URL', "https://nano-gpt.com/api/v1/")
|
260
|
+
base_url = base_url or "https://nano-gpt.com/api/v1/"
|
261
|
+
|
222
262
|
client = OpenAI(api_key=api_key, base_url=base_url)
|
223
263
|
response = client.chat.completions.create(model=model,
|
224
264
|
messages=messages,
|
225
265
|
max_tokens=max_tokens,
|
226
266
|
temperature=temperature)
|
227
267
|
|
268
|
+
if VERBOSE:
|
269
|
+
print("Debug: Raw LLM Response\n---")
|
270
|
+
print(response.choices[0].message.content.strip())
|
271
|
+
print("---")
|
272
|
+
else:
|
273
|
+
print("Diff generated.")
|
274
|
+
|
228
275
|
prompt_tokens = response.usage.prompt_tokens
|
229
276
|
completion_tokens = response.usage.completion_tokens
|
230
277
|
total_tokens = response.usage.total_tokens
|
@@ -236,9 +283,6 @@ def call_llm_for_diff(system_prompt, user_prompt, files_content, model, temperat
|
|
236
283
|
print("-" * 40)
|
237
284
|
|
238
285
|
# Now, these rates are updated to per million tokens
|
239
|
-
cost_per_million_prompt_tokens = 30
|
240
|
-
cost_per_million_completion_tokens = 60
|
241
|
-
cost = (prompt_tokens / 1_000_000 * cost_per_million_prompt_tokens) + (completion_tokens / 1_000_000 * cost_per_million_completion_tokens)
|
242
286
|
|
243
287
|
full_response = response.choices[0].message.content.strip()
|
244
288
|
full_response, reasoning = swallow_reasoning(full_response)
|
@@ -250,7 +294,7 @@ def call_llm_for_diff(system_prompt, user_prompt, files_content, model, temperat
|
|
250
294
|
toolbox.use(event)
|
251
295
|
diff_response = diff_context.get()
|
252
296
|
|
253
|
-
return full_response, diff_response, prompt_tokens, completion_tokens, total_tokens
|
297
|
+
return full_response, diff_response, prompt_tokens, completion_tokens, total_tokens
|
254
298
|
|
255
299
|
# New API functions
|
256
300
|
def build_environment(files_dict):
|
@@ -361,172 +405,6 @@ def smartapply(diff_text, files, model=None, api_key=None, base_url=None):
|
|
361
405
|
|
362
406
|
return files
|
363
407
|
|
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
408
|
def parse_arguments():
|
531
409
|
parser = argparse.ArgumentParser(description='Generate and optionally apply git diffs using GPT-4.')
|
532
410
|
parser.add_argument('prompt', type=str, help='Prompt that runs on the codebase.')
|
@@ -542,9 +420,8 @@ def parse_arguments():
|
|
542
420
|
parser.add_argument('--max_tokens', type=int, default=30000, help='Temperature parameter for model creativity (0.0 to 2.0)')
|
543
421
|
parser.add_argument('--model', type=str, default=None, help='Model to use for the API call.')
|
544
422
|
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
423
|
parser.add_argument('--nowarn', action='store_true', help='Disable large token warning')
|
547
|
-
|
424
|
+
parser.add_argument('--verbose', action='store_true', help='Enable verbose output with detailed information')
|
548
425
|
return parser.parse_args()
|
549
426
|
|
550
427
|
def absolute_to_relative(absolute_path):
|
@@ -552,95 +429,8 @@ def absolute_to_relative(absolute_path):
|
|
552
429
|
relative_path = os.path.relpath(absolute_path, cwd)
|
553
430
|
return relative_path
|
554
431
|
|
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
|
432
|
+
def colorize_warning_warning(message):
|
433
|
+
return f"\033[91m\033[1m{message}\033[0m"
|
644
434
|
|
645
435
|
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
436
|
parser = FlatXMLParser("think")
|
@@ -720,9 +510,9 @@ Diff to apply:
|
|
720
510
|
{"role": "system", "content": system_prompt},
|
721
511
|
{"role": "user", "content": user_prompt},
|
722
512
|
]
|
723
|
-
if api_key
|
513
|
+
if not api_key:
|
724
514
|
api_key = os.getenv('GPTDIFF_LLM_API_KEY')
|
725
|
-
if base_url
|
515
|
+
if not base_url:
|
726
516
|
base_url = os.getenv('GPTDIFF_LLM_BASE_URL', "https://nano-gpt.com/api/v1/")
|
727
517
|
client = OpenAI(api_key=api_key, base_url=base_url)
|
728
518
|
start_time = time.time()
|
@@ -734,8 +524,11 @@ Diff to apply:
|
|
734
524
|
elapsed = time.time() - start_time
|
735
525
|
minutes, seconds = divmod(int(elapsed), 60)
|
736
526
|
time_str = f"{minutes}m {seconds}s" if minutes else f"{seconds}s"
|
737
|
-
|
738
|
-
|
527
|
+
if VERBOSE:
|
528
|
+
print(f"Smartapply time: {time_str}")
|
529
|
+
print("-" * 40)
|
530
|
+
else:
|
531
|
+
print(f"Smartapply completed in {time_str}")
|
739
532
|
return full_response
|
740
533
|
|
741
534
|
def build_environment_from_filelist(file_list, cwd):
|
@@ -760,31 +553,44 @@ def smart_apply_patch(project_dir, diff_text, user_prompt, args):
|
|
760
553
|
Attempt to apply a diff via smartapply: process each file concurrently using the LLM.
|
761
554
|
"""
|
762
555
|
from pathlib import Path
|
556
|
+
start_time = time.time()
|
763
557
|
parsed_diffs = parse_diff_per_file(diff_text)
|
764
558
|
print("Found", len(parsed_diffs), "files in diff, processing smart apply concurrently:")
|
559
|
+
green = "\033[92m"
|
560
|
+
red = "\033[91m"
|
561
|
+
blue = "\033[94m"
|
562
|
+
reset = "\033[0m"
|
563
|
+
|
765
564
|
if len(parsed_diffs) == 0:
|
766
|
-
print("
|
565
|
+
print(colorize_warning_warning("There were no entries in this diff. The LLM may have returned something invalid."))
|
767
566
|
if args.beep:
|
768
567
|
print("\a")
|
769
568
|
return
|
770
569
|
threads = []
|
570
|
+
success_files = []
|
571
|
+
failed_files = []
|
572
|
+
success_lock = Lock()
|
771
573
|
|
772
574
|
def process_file(file_path, file_diff):
|
773
575
|
full_path = Path(project_dir) / file_path
|
774
|
-
|
576
|
+
if VERBOSE:
|
577
|
+
print(f"Processing file: {file_path}")
|
775
578
|
if '+++ /dev/null' in file_diff:
|
776
579
|
if full_path.exists():
|
777
580
|
full_path.unlink()
|
778
581
|
print(f"\033[1;32mDeleted file {file_path}.\033[0m")
|
779
582
|
else:
|
780
|
-
print(f"
|
583
|
+
print(colorize_warning_warning(f"File {file_path} not found - skipping deletion"))
|
781
584
|
return
|
782
585
|
|
783
|
-
|
784
|
-
|
785
|
-
|
786
|
-
|
787
|
-
|
586
|
+
original_content = ""
|
587
|
+
if full_path.exists():
|
588
|
+
try:
|
589
|
+
original_content = full_path.read_text()
|
590
|
+
except (UnicodeDecodeError, IOError) as e:
|
591
|
+
print(f"Cannot read {file_path} due to {str(e)}, treating as new file")
|
592
|
+
else:
|
593
|
+
print(f"File {file_path} does not exist, treating as new file")
|
788
594
|
|
789
595
|
# Use SMARTAPPLY-specific environment variables if set, otherwise fallback.
|
790
596
|
smart_apply_model = os.getenv("GPTDIFF_SMARTAPPLY_MODEL")
|
@@ -807,9 +613,7 @@ def smart_apply_patch(project_dir, diff_text, user_prompt, args):
|
|
807
613
|
else:
|
808
614
|
base_url = os.getenv("GPTDIFF_LLM_BASE_URL", "https://nano-gpt.com/api/v1/")
|
809
615
|
|
810
|
-
print("
|
811
|
-
print("Running smartapply with", model, "on", file_path)
|
812
|
-
print("-" * 40)
|
616
|
+
print(f"Running smartapply in parallel using model '{green}{model}{reset}' from '{blue}{domain_for_url(base_url)}{reset}'...")
|
813
617
|
try:
|
814
618
|
updated_content = call_llm_for_apply_with_think_tool_available(
|
815
619
|
file_path, original_content, file_diff, model,
|
@@ -818,12 +622,18 @@ def smart_apply_patch(project_dir, diff_text, user_prompt, args):
|
|
818
622
|
max_tokens=args.max_tokens)
|
819
623
|
if updated_content.strip() == "":
|
820
624
|
print("Cowardly refusing to write empty file to", file_path, "merge failed")
|
625
|
+
with success_lock:
|
626
|
+
failed_files.append(file_path)
|
821
627
|
return
|
822
628
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
823
629
|
full_path.write_text(updated_content)
|
824
630
|
print(f"\033[1;32mSuccessful 'smartapply' update {file_path}.\033[0m")
|
631
|
+
with success_lock:
|
632
|
+
success_files.append(file_path)
|
825
633
|
except Exception as e:
|
826
634
|
print(f"\033[1;31mFailed to process {file_path}: {str(e)}\033[0m")
|
635
|
+
with success_lock:
|
636
|
+
failed_files.append(file_path)
|
827
637
|
|
828
638
|
for file_path, file_diff in parsed_diffs:
|
829
639
|
thread = threading.Thread(target=process_file, args=(file_path, file_diff))
|
@@ -831,7 +641,17 @@ def smart_apply_patch(project_dir, diff_text, user_prompt, args):
|
|
831
641
|
threads.append(thread)
|
832
642
|
for thread in threads:
|
833
643
|
thread.join()
|
834
|
-
|
644
|
+
elapsed = time.time() - start_time
|
645
|
+
minutes, seconds = divmod(int(elapsed), 60)
|
646
|
+
time_str = f"{minutes}m {seconds}s" if minutes else f"{seconds}s"
|
647
|
+
print(f"Smartapply successfully applied changes in {time_str}. Check the updated files to confirm.")
|
648
|
+
if failed_files:
|
649
|
+
print(f"\033[1;31mSmart apply completed in {time_str} with failures for {len(failed_files)} files:\033[0m")
|
650
|
+
for file in failed_files:
|
651
|
+
print(f" - {file}")
|
652
|
+
print("Please check the errors above for details.")
|
653
|
+
else:
|
654
|
+
print(f"\033[1;32mSmart apply completed successfully in {time_str} for all {len(success_files)} files.\033[0m")
|
835
655
|
if args.beep:
|
836
656
|
print("\a")
|
837
657
|
|
@@ -862,11 +682,13 @@ def save_files(files_dict, target_directory):
|
|
862
682
|
print(f"Saved: {full_path}")
|
863
683
|
|
864
684
|
def main():
|
685
|
+
global VERBOSE
|
865
686
|
# Adding color support for Windows CMD
|
866
687
|
if os.name == 'nt':
|
867
688
|
os.system('color')
|
868
689
|
|
869
690
|
args = parse_arguments()
|
691
|
+
VERBOSE = args.verbose
|
870
692
|
|
871
693
|
# openai.api_base = "https://nano-gpt.com/api/v1/"
|
872
694
|
if len(sys.argv) < 2:
|
@@ -916,9 +738,8 @@ def main():
|
|
916
738
|
|
917
739
|
files_content = ""
|
918
740
|
for file, content in project_files:
|
919
|
-
|
920
|
-
|
921
|
-
# Prepare the prompt for GPT-4
|
741
|
+
if VERBOSE:
|
742
|
+
print(f"Including {len(enc.encode(content)):5d} tokens", absolute_to_relative(file))
|
922
743
|
files_content += f"File: {absolute_to_relative(file)}\nContent:\n{content}\n"
|
923
744
|
|
924
745
|
full_prompt = f"{system_prompt}\n\n{user_prompt}\n\n{files_content}"
|
@@ -930,9 +751,8 @@ def main():
|
|
930
751
|
with open('prompt.txt', 'w') as f:
|
931
752
|
f.write(full_prompt)
|
932
753
|
print(f"Total tokens: {token_count:5d}")
|
933
|
-
print(f"\033[1;
|
754
|
+
print(f"\033[1;32mWrote full prompt to prompt.txt.\033[0m")
|
934
755
|
print('Instead, wrote full prompt to prompt.txt. Use `xclip -selection clipboard < prompt.txt` then paste into chatgpt')
|
935
|
-
print(f"Total cost: ${0.0:.4f}")
|
936
756
|
exit(0)
|
937
757
|
else:
|
938
758
|
# Validate API key presence before any API operations
|
@@ -949,7 +769,7 @@ def main():
|
|
949
769
|
print("Request canceled")
|
950
770
|
sys.exit(0)
|
951
771
|
try:
|
952
|
-
full_text, diff_text, prompt_tokens, completion_tokens, total_tokens
|
772
|
+
full_text, diff_text, prompt_tokens, completion_tokens, total_tokens = call_llm_for_diff(system_prompt, user_prompt, files_content, args.model,
|
953
773
|
temperature=args.temperature,
|
954
774
|
api_key=os.getenv('GPTDIFF_LLM_API_KEY'),
|
955
775
|
base_url=os.getenv('GPTDIFF_LLM_BASE_URL', "https://nano-gpt.com/api/v1/"),
|
@@ -961,35 +781,40 @@ def main():
|
|
961
781
|
prompt_tokens = 0
|
962
782
|
completion_tokens = 0
|
963
783
|
total_tokens = 0
|
964
|
-
cost = 0
|
965
784
|
print(f"Error in LLM response {e}")
|
966
785
|
|
967
786
|
if(diff_text.strip() == ""):
|
968
|
-
print(f"\033[1;
|
969
|
-
print("
|
787
|
+
print(f"\033[1;33mWarning: No valid diff data was generated. This could be due to an unclear prompt or an invalid LLM response.\033[0m")
|
788
|
+
print("Suggested action: Refine your prompt or check the full response below for clues.")
|
789
|
+
print("Full LLM response:\n---\n" + full_text + "\n---")
|
970
790
|
if args.beep:
|
971
|
-
print("\a")
|
791
|
+
print("\a")
|
972
792
|
return
|
973
793
|
|
974
794
|
elif args.apply:
|
975
795
|
print("\nAttempting apply with the following diff:")
|
976
|
-
print("\n<diff>")
|
977
796
|
print(color_code_diff(diff_text))
|
978
|
-
print("\
|
979
|
-
|
980
|
-
if
|
981
|
-
print(f"\033[1;32mPatch applied successfully with
|
797
|
+
print("\033[94m**Attempting to apply patch using basic method...**\033[0m")
|
798
|
+
apply_result = apply_diff(project_dir, diff_text)
|
799
|
+
if apply_result:
|
800
|
+
print(f"\033[1;32mPatch applied successfully with basic apply.\033[0m")
|
982
801
|
else:
|
983
|
-
print("
|
802
|
+
print("\033[94m**Attempting smart apply with LLM...**\033[0m")
|
984
803
|
smart_apply_patch(project_dir, diff_text, user_prompt, args)
|
985
804
|
|
986
805
|
if args.beep:
|
987
|
-
print("\a")
|
806
|
+
print("\a")
|
988
807
|
|
989
|
-
|
990
|
-
|
991
|
-
|
992
|
-
|
808
|
+
green = "\033[92m"
|
809
|
+
reset = "\033[0m"
|
810
|
+
if VERBOSE:
|
811
|
+
print("API Usage Details:")
|
812
|
+
print(f"- Prompt tokens: {prompt_tokens}")
|
813
|
+
print(f"- Completion tokens: {completion_tokens}")
|
814
|
+
print(f"- Total tokens: {total_tokens}")
|
815
|
+
print(f"- Model used: {green}{args.model}{reset}")
|
816
|
+
else:
|
817
|
+
print(f"API Usage: {total_tokens} tokens, Model used: {green}{args.model}{reset}")
|
993
818
|
|
994
819
|
def swallow_reasoning(full_response: str) -> (str, str):
|
995
820
|
"""
|
gptdiff/gptpatch.py
CHANGED
@@ -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, color_code_diff
|
18
17
|
|
19
18
|
def parse_arguments():
|
20
19
|
parser = argparse.ArgumentParser(
|
@@ -50,10 +49,14 @@ def parse_arguments():
|
|
50
49
|
default=30000,
|
51
50
|
help="Maximum tokens to use for LLM responses"
|
52
51
|
)
|
52
|
+
parser.add_argument('--verbose', action='store_true', help='Enable verbose output with detailed information')
|
53
|
+
parser.add_argument('--dumb', action='store_true', default=False, help='Attempt dumb apply before trying smart apply')
|
53
54
|
return parser.parse_args()
|
54
55
|
|
55
56
|
def main():
|
56
57
|
args = parse_arguments()
|
58
|
+
import gptdiff.gptdiff as gd
|
59
|
+
gd.VERBOSE = args.verbose
|
57
60
|
if args.diff:
|
58
61
|
diff_text = args.diff
|
59
62
|
else:
|
@@ -64,13 +67,21 @@ def main():
|
|
64
67
|
diff_text = diff_path.read_text(encoding="utf8")
|
65
68
|
|
66
69
|
project_dir = args.project_dir
|
67
|
-
|
68
|
-
if
|
69
|
-
print("
|
70
|
+
|
71
|
+
if args.verbose:
|
72
|
+
print("\n\033[1;34mDiff to be applied:\033[0m")
|
73
|
+
print(color_code_diff(diff_text))
|
74
|
+
print("")
|
75
|
+
|
76
|
+
if args.dumb:
|
77
|
+
success = apply_diff(project_dir, diff_text)
|
78
|
+
if success:
|
79
|
+
print("\033[1;32m✅ Diff applied successfully.\033[0m")
|
80
|
+
else:
|
81
|
+
print("\033[1;31m❌ Failed to apply diff using git apply. Attempting smart apply.\033[0m")
|
82
|
+
smart_apply_patch(project_dir, diff_text, "", args)
|
70
83
|
else:
|
71
|
-
print("❌ Failed to apply diff using git apply. Attempting smart apply.")
|
72
|
-
from gptdiff.gptdiff import smart_apply_patch
|
73
84
|
smart_apply_patch(project_dir, diff_text, "", args)
|
74
85
|
|
75
86
|
if __name__ == "__main__":
|
76
|
-
main()
|
87
|
+
main()
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.2
|
2
2
|
Name: gptdiff
|
3
|
-
Version: 0.1.
|
3
|
+
Version: 0.1.27
|
4
4
|
Summary: A tool to generate and apply git diffs using LLMs
|
5
5
|
Author: 255labs
|
6
6
|
Classifier: License :: OSI Approved :: MIT License
|
@@ -82,13 +82,15 @@ done
|
|
82
82
|
|
83
83
|
*Requires reasoning model*
|
84
84
|
|
85
|
-
|
85
|
+
## Why Choose GPTDiff?
|
86
86
|
|
87
|
-
- **
|
88
|
-
- **
|
89
|
-
- **Auto-
|
90
|
-
- **
|
91
|
-
- **
|
87
|
+
- **Describe changes in plain English**
|
88
|
+
- **AI gets your whole project**
|
89
|
+
- **Auto-fixes conflicts**
|
90
|
+
- **Keeps code functional**
|
91
|
+
- **Fast setup, no fuss**
|
92
|
+
- **You approve every change**
|
93
|
+
- **Costs are upfront**
|
92
94
|
|
93
95
|
## Core Capabilities
|
94
96
|
|
@@ -0,0 +1,10 @@
|
|
1
|
+
gptdiff/__init__.py,sha256=o1hrK4GFvbfKcHPlLVArz4OunE3euIicEBYaLrdDo0k,198
|
2
|
+
gptdiff/applydiff.py,sha256=_11ITFMcigwvVptaIpEtyfLUTIy_mYPWExcXUqCBfOs,11200
|
3
|
+
gptdiff/gptdiff.py,sha256=sG0tPku3d5agx9F8EqKTl914S5ZkRi_8oMIBhFiz-nI,33355
|
4
|
+
gptdiff/gptpatch.py,sha256=Vqk2vliYs_BxtuTpwdS88n3A8XToh6RvrCA4N8VqOu0,2759
|
5
|
+
gptdiff-0.1.27.dist-info/LICENSE.txt,sha256=zCJk7yUYpMjFvlipi1dKtaljF8WdZ2NASndBYYbU8BY,1228
|
6
|
+
gptdiff-0.1.27.dist-info/METADATA,sha256=l5oPGbwCn731KeLRT3xEMElWuE1fr15c_pGLGrEzzA8,8723
|
7
|
+
gptdiff-0.1.27.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
|
8
|
+
gptdiff-0.1.27.dist-info/entry_points.txt,sha256=0VlVNr-gc04a3SZD5_qKIBbtg_L5P2x3xlKE5ftcdkc,82
|
9
|
+
gptdiff-0.1.27.dist-info/top_level.txt,sha256=XNkQkQGINaDndEwRxg8qToOrJ9coyfAb-EHrSUXzdCE,8
|
10
|
+
gptdiff-0.1.27.dist-info/RECORD,,
|
gptdiff-0.1.22.dist-info/RECORD
DELETED
@@ -1,9 +0,0 @@
|
|
1
|
-
gptdiff/__init__.py,sha256=o1hrK4GFvbfKcHPlLVArz4OunE3euIicEBYaLrdDo0k,198
|
2
|
-
gptdiff/gptdiff.py,sha256=AuZwZ1pg52RPheAzdhtZXSTjBGH4t4KRm7r9ziGHJVQ,41388
|
3
|
-
gptdiff/gptpatch.py,sha256=Z8CWWIfIL2o7xPLVdhzN5GSyJq0vsK4XQRzu4hMWNQk,2194
|
4
|
-
gptdiff-0.1.22.dist-info/LICENSE.txt,sha256=zCJk7yUYpMjFvlipi1dKtaljF8WdZ2NASndBYYbU8BY,1228
|
5
|
-
gptdiff-0.1.22.dist-info/METADATA,sha256=_RspqYV4VPaRrpYTQXNVecFirrxzZq7MelPpZLV3O9Q,8785
|
6
|
-
gptdiff-0.1.22.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
|
7
|
-
gptdiff-0.1.22.dist-info/entry_points.txt,sha256=0VlVNr-gc04a3SZD5_qKIBbtg_L5P2x3xlKE5ftcdkc,82
|
8
|
-
gptdiff-0.1.22.dist-info/top_level.txt,sha256=XNkQkQGINaDndEwRxg8qToOrJ9coyfAb-EHrSUXzdCE,8
|
9
|
-
gptdiff-0.1.22.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|