gptdiff 0.1.22__tar.gz → 0.1.27__tar.gz

Sign up to get free protection for your applications and to get access to all the features.
Files changed (24) hide show
  1. {gptdiff-0.1.22 → gptdiff-0.1.27}/PKG-INFO +9 -7
  2. {gptdiff-0.1.22 → gptdiff-0.1.27}/README.md +9 -7
  3. gptdiff-0.1.27/gptdiff/applydiff.py +265 -0
  4. {gptdiff-0.1.22 → gptdiff-0.1.27}/gptdiff/gptdiff.py +150 -325
  5. {gptdiff-0.1.22 → gptdiff-0.1.27}/gptdiff/gptpatch.py +19 -8
  6. {gptdiff-0.1.22 → gptdiff-0.1.27}/gptdiff.egg-info/PKG-INFO +9 -7
  7. {gptdiff-0.1.22 → gptdiff-0.1.27}/gptdiff.egg-info/SOURCES.txt +1 -0
  8. {gptdiff-0.1.22 → gptdiff-0.1.27}/setup.py +1 -1
  9. gptdiff-0.1.27/tests/test_applydiff.py +171 -0
  10. {gptdiff-0.1.22 → gptdiff-0.1.27}/tests/test_parse_diff_per_file.py +14 -0
  11. {gptdiff-0.1.22 → gptdiff-0.1.27}/tests/test_smartapply.py +24 -5
  12. gptdiff-0.1.22/tests/test_applydiff.py +0 -80
  13. {gptdiff-0.1.22 → gptdiff-0.1.27}/LICENSE.txt +0 -0
  14. {gptdiff-0.1.22 → gptdiff-0.1.27}/gptdiff/__init__.py +0 -0
  15. {gptdiff-0.1.22 → gptdiff-0.1.27}/gptdiff.egg-info/dependency_links.txt +0 -0
  16. {gptdiff-0.1.22 → gptdiff-0.1.27}/gptdiff.egg-info/entry_points.txt +0 -0
  17. {gptdiff-0.1.22 → gptdiff-0.1.27}/gptdiff.egg-info/requires.txt +0 -0
  18. {gptdiff-0.1.22 → gptdiff-0.1.27}/gptdiff.egg-info/top_level.txt +0 -0
  19. {gptdiff-0.1.22 → gptdiff-0.1.27}/setup.cfg +0 -0
  20. {gptdiff-0.1.22 → gptdiff-0.1.27}/tests/test_applydiff_edgecases.py +0 -0
  21. {gptdiff-0.1.22 → gptdiff-0.1.27}/tests/test_diff_parse.py +0 -0
  22. {gptdiff-0.1.22 → gptdiff-0.1.27}/tests/test_failing_case.py +0 -0
  23. {gptdiff-0.1.22 → gptdiff-0.1.27}/tests/test_strip_bad_ouput.py +0 -0
  24. {gptdiff-0.1.22 → gptdiff-0.1.27}/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.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
- ### Why GPTDiff?
85
+ ## Why Choose GPTDiff?
86
86
 
87
- - **Understands Your Code** - Describe changes in plain English
88
- - **Safe Modifications** - Keeps existing code working
89
- - **Auto-Fix** - `--apply` fixes mistakes in generated changes
90
- - **Works Instantly** - No complex setup needed
91
- - **Whole Project View** - Handles multiple files together
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
 
@@ -55,13 +55,15 @@ done
55
55
 
56
56
  *Requires reasoning model*
57
57
 
58
- ### Why GPTDiff?
59
-
60
- - **Understands Your Code** - Describe changes in plain English
61
- - **Safe Modifications** - Keeps existing code working
62
- - **Auto-Fix** - `--apply` fixes mistakes in generated changes
63
- - **Works Instantly** - No complex setup needed
64
- - **Whole Project View** - Handles multiple files together
58
+ ## Why Choose GPTDiff?
59
+
60
+ - **Describe changes in plain English**
61
+ - **AI gets your whole project**
62
+ - **Auto-fixes conflicts**
63
+ - **Keeps code functional**
64
+ - **Fast setup, no fuss**
65
+ - **You approve every change**
66
+ - **Costs are upfront**
65
67
 
66
68
  ## Core Capabilities
67
69
 
@@ -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
+