gptdiff 0.1.22__tar.gz → 0.1.24__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.
Files changed (24) hide show
  1. {gptdiff-0.1.22 → gptdiff-0.1.24}/PKG-INFO +1 -1
  2. gptdiff-0.1.24/gptdiff/applydiff.py +264 -0
  3. {gptdiff-0.1.22 → gptdiff-0.1.24}/gptdiff/gptdiff.py +74 -294
  4. {gptdiff-0.1.22 → gptdiff-0.1.24}/gptdiff/gptpatch.py +9 -7
  5. {gptdiff-0.1.22 → gptdiff-0.1.24}/gptdiff.egg-info/PKG-INFO +1 -1
  6. {gptdiff-0.1.22 → gptdiff-0.1.24}/gptdiff.egg-info/SOURCES.txt +1 -0
  7. {gptdiff-0.1.22 → gptdiff-0.1.24}/setup.py +1 -1
  8. gptdiff-0.1.24/tests/test_applydiff.py +143 -0
  9. {gptdiff-0.1.22 → gptdiff-0.1.24}/tests/test_smartapply.py +4 -5
  10. gptdiff-0.1.22/tests/test_applydiff.py +0 -80
  11. {gptdiff-0.1.22 → gptdiff-0.1.24}/LICENSE.txt +0 -0
  12. {gptdiff-0.1.22 → gptdiff-0.1.24}/README.md +0 -0
  13. {gptdiff-0.1.22 → gptdiff-0.1.24}/gptdiff/__init__.py +0 -0
  14. {gptdiff-0.1.22 → gptdiff-0.1.24}/gptdiff.egg-info/dependency_links.txt +0 -0
  15. {gptdiff-0.1.22 → gptdiff-0.1.24}/gptdiff.egg-info/entry_points.txt +0 -0
  16. {gptdiff-0.1.22 → gptdiff-0.1.24}/gptdiff.egg-info/requires.txt +0 -0
  17. {gptdiff-0.1.22 → gptdiff-0.1.24}/gptdiff.egg-info/top_level.txt +0 -0
  18. {gptdiff-0.1.22 → gptdiff-0.1.24}/setup.cfg +0 -0
  19. {gptdiff-0.1.22 → gptdiff-0.1.24}/tests/test_applydiff_edgecases.py +0 -0
  20. {gptdiff-0.1.22 → gptdiff-0.1.24}/tests/test_diff_parse.py +0 -0
  21. {gptdiff-0.1.22 → gptdiff-0.1.24}/tests/test_failing_case.py +0 -0
  22. {gptdiff-0.1.22 → gptdiff-0.1.24}/tests/test_parse_diff_per_file.py +0 -0
  23. {gptdiff-0.1.22 → gptdiff-0.1.24}/tests/test_strip_bad_ouput.py +0 -0
  24. {gptdiff-0.1.22 → gptdiff-0.1.24}/tests/test_swallow_reasoning.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: gptdiff
3
- Version: 0.1.22
3
+ Version: 0.1.24
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
@@ -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 = [line.strip() for line in f if line.strip() and not line.startswith('#')]
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
- try:
179
- with open(file, 'r') as f:
180
- content = f.read()
189
+ try:
190
+ with open(file, 'r') as f:
191
+ content = f.read()
192
+ if VERBOSE:
181
193
  print(file)
182
- project_files.append((file, content))
183
- except UnicodeDecodeError:
184
- print(f"Skipping file {file} due to UnicodeDecodeError")
185
- continue
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
- 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")
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 is None:
238
+ if not api_key:
219
239
  api_key = os.getenv('GPTDIFF_LLM_API_KEY')
220
- if base_url is None:
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 parse_diff_per_file(diff_text):
556
- """Parse unified diff text into individual file patches.
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 is None:
498
+ if not api_key:
724
499
  api_key = os.getenv('GPTDIFF_LLM_API_KEY')
725
- if base_url is None:
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
- print(f"Smartapply time: {time_str}")
738
- print("-" * 40)
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("\033[1;33mThere were no entries in this diff. The LLM may have returned something invalid.\033[0m")
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
- print(f"Processing file: {file_path}")
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"\033[1;33mFile {file_path} not found - skipping deletion\033[0m")
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
- print(f"Including {len(enc.encode(content)):5d} tokens", absolute_to_relative(file))
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
- success = apply_diff(project_dir, diff_text)
68
- if success:
69
- print("✅ Diff applied successfully.")
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__":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: gptdiff
3
- Version: 0.1.22
3
+ Version: 0.1.24
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
@@ -2,6 +2,7 @@ LICENSE.txt
2
2
  README.md
3
3
  setup.py
4
4
  gptdiff/__init__.py
5
+ gptdiff/applydiff.py
5
6
  gptdiff/gptdiff.py
6
7
  gptdiff/gptpatch.py
7
8
  gptdiff.egg-info/PKG-INFO
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name='gptdiff',
5
- version='0.1.22',
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(mocker):
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
- mocker.patch('gptdiff.gptdiff.call_llm_for_apply', side_effect=mock_call_llm)
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(mocker):
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
- mocker.patch('gptdiff.gptdiff.call_llm_for_apply', return_value=expected_content)
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