gptdiff 0.1.9__tar.gz → 0.1.11__tar.gz

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: gptdiff
3
- Version: 0.1.9
3
+ Version: 0.1.11
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
@@ -26,22 +26,61 @@ Dynamic: requires-dist
26
26
  Dynamic: summary
27
27
 
28
28
  # GPTDiff
29
+ <!--
30
+ GPTDiff: Create and apply diffs using AI.
31
+ This tool leverages natural language instructions to modify project codebases.
32
+ -->
29
33
 
30
- 🚀 **AI-Powered Code Evolution** - Transform your codebase with natural language instructions
34
+ 🚀 **Create and apply diffs with AI**
35
+ Modify your project using plain English.
36
+
37
+ More documentation at [gptdiff.255labs.xyz](gptdiff.255labs.xyz)
38
+
39
+ ### Example Usage of `gptdiff`
40
+
41
+ #### Apply a Patch Directly
42
+ ```
43
+ bash
44
+ gptdiff "Add button animations on press" --apply
45
+ ```
46
+ ✅ Successfully applied patch
47
+
48
+ #### Generate a Patch File
49
+ ```
50
+ bash
51
+ gptdiff "Add API documentation" --call
52
+ ```
53
+ 🔧 Patch written to `diff.patch`
54
+
55
+ #### Generate a Prompt File Without Calling LLM
56
+ ```
57
+ bash
58
+ gptdiff "Improve error messages"
59
+ ```
60
+ 📄 LLM not called, written to `prompt.txt`
61
+
62
+ ---
63
+
64
+ ### Basic Usage
31
65
 
32
66
  ```bash
33
67
  cd myproject
34
68
  gptdiff 'add hover effects to the buttons'
35
69
  ```
36
70
 
37
- Generates a prompt.txt file that you can copy and paste into a large context gpt to have a conversation with suggested changes. You can also invoke the API and try to directly apply the patch using a smartapply if the git apply fails.
71
+ Generates a prompt.txt file containing the full request.
72
+ Copy and paste its content into your preferred LLM (e.g., ChatGPT) for further experimentation.
38
73
 
39
- ## Value Proposition
74
+ ### Simple command line agent loops
40
75
 
41
76
  ```bash
42
- gptdiff "Update the readme with an api section" --apply
77
+ while
78
+ do
79
+ gptdiff "Add missing test cases" --apply
80
+ done
43
81
  ```
44
- <span style="color: #00ff00;">Patch applied successfully.</span>
82
+
83
+ *Requires reasoning model*
45
84
 
46
85
  ### Why GPTDiff?
47
86
 
@@ -114,7 +153,7 @@ First sign up for an API key at https://nano-gpt.com/api and generate your key.
114
153
  export GPTDIFF_LLM_API_KEY='your-api-key'
115
154
  # Optional: For switching API providers
116
155
  export GPTDIFF_MODEL='deepseek-reasoner' # Set default model for all commands
117
- export GPTDIFF_LLM_BASE_URL='https://nano-gpt.com/api/v1/
156
+ export GPTDIFF_LLM_BASE_URL='https://nano-gpt.com/api/v1/'
118
157
  ```
119
158
 
120
159
  #### Windows
@@ -138,7 +177,7 @@ Prevent files being appended to the prompt by adding them to `.gitignore` or `.g
138
177
 
139
178
  ### Command Line Usage
140
179
 
141
- After installing the package, you can use the `gptdiff` command in your terminal. cd into your codebase and run:
180
+ After installing the package, use the `gptdiff` command in your terminal. Change directory into your codebase and run:
142
181
 
143
182
  ```bash
144
183
  gptdiff '<user_prompt>'
@@ -148,13 +187,7 @@ any files that are included in .gitignore are ignored when generating prompt.txt
148
187
 
149
188
  #### Specifying Additional Files
150
189
 
151
- You can specify additional files or directories to include in the prompt by adding them as arguments to the `gptdiff` command. If no additional files or directories are specified, the tool will default to using the current working directory.
152
-
153
- Example usage:
154
-
155
- ```bash
156
- gptdiff 'make this change' src test
157
- ```
190
+ You may supply extra files or directories as arguments to the `gptdiff` command. If omitted, the tool defaults to the current working directory, excluding those matching ignore rules.
158
191
 
159
192
  #### Autopatch Changes
160
193
 
@@ -169,24 +202,20 @@ Preview changes without applying them by omitting the `--apply` flag when using
169
202
  ```bash
170
203
  gptdiff "Modernize database queries" --call
171
204
  ```
172
- <span style="color: #0066cc;">ℹ️ Diff preview generated - review changes before applying</span>
205
+ <span style="color: #0066cc;">i️ Diff preview generated - review changes before applying</span>
173
206
 
174
207
  This often generates incorrect diffs that need to be manually merged.
175
208
 
176
209
  #### Smart Apply
177
210
 
178
- For more reliable patching of complex changes, use `smartapply` which processes each file's diff individually with the LLM:
211
+ For robust handling of complex changes, use `smartapply`. It processes each files diff individually via the LLM, ensuring nuanced conflict resolution.
179
212
 
180
- ```bash
181
- gptdiff 'refactor authentication system' --apply
182
- ```
183
-
184
- ### Completion Notification
213
+ ## Completion Notification
185
214
 
186
215
  Use the `--nobeep` option to disable the default completion beep:
187
216
 
188
217
  ```bash
189
- gptdiff '<user_prompt>' --beep
218
+ gptdiff '<user_prompt>' --nobeep
190
219
  ```
191
220
 
192
221
  ## Local API Documentation
@@ -209,29 +238,25 @@ import os
209
238
 
210
239
  os.environ['GPTDIFF_LLM_API_KEY'] = 'your-api-key'
211
240
 
212
- # Create environment representation
213
- environment = '''
214
- File: main.py
215
- Content:
216
- def old_name():
217
- print("Need renaming")
218
- '''
241
+ # Create files dictionary
242
+ files = {"main.py": "def old_name():\n print('Need renaming')"}
243
+
244
+ # Generate transformation diff using an environment string built from the files dictionary
245
+ environment = ""
246
+ for path, content in files.items():
247
+ environment += f"File: {path}\nContent:\n{content}\n"
219
248
 
220
- # Generate transformation diff
221
249
  diff = generate_diff(
222
250
  environment=environment,
223
251
  goal='Rename function to new_name()',
224
252
  model='deepseek-reasoner'
225
253
  )
226
254
 
227
- # Apply changes safely
228
- updated_environment = smartapply(
229
- diff_text=diff,
230
- environment_str=environment
231
- )
255
+ # Apply changes safely using the files dict
256
+ updated_files = smartapply(diff, files)
232
257
 
233
258
  print("Transformed codebase:")
234
- print(updated_environment)
259
+ print(updated_files["main.py"])
235
260
  ```
236
261
 
237
262
  **Batch Processing Example:**
@@ -1,20 +1,59 @@
1
1
  # GPTDiff
2
+ <!--
3
+ GPTDiff: Create and apply diffs using AI.
4
+ This tool leverages natural language instructions to modify project codebases.
5
+ -->
2
6
 
3
- 🚀 **AI-Powered Code Evolution** - Transform your codebase with natural language instructions
7
+ 🚀 **Create and apply diffs with AI**
8
+ Modify your project using plain English.
9
+
10
+ More documentation at [gptdiff.255labs.xyz](gptdiff.255labs.xyz)
11
+
12
+ ### Example Usage of `gptdiff`
13
+
14
+ #### Apply a Patch Directly
15
+ ```
16
+ bash
17
+ gptdiff "Add button animations on press" --apply
18
+ ```
19
+ ✅ Successfully applied patch
20
+
21
+ #### Generate a Patch File
22
+ ```
23
+ bash
24
+ gptdiff "Add API documentation" --call
25
+ ```
26
+ 🔧 Patch written to `diff.patch`
27
+
28
+ #### Generate a Prompt File Without Calling LLM
29
+ ```
30
+ bash
31
+ gptdiff "Improve error messages"
32
+ ```
33
+ 📄 LLM not called, written to `prompt.txt`
34
+
35
+ ---
36
+
37
+ ### Basic Usage
4
38
 
5
39
  ```bash
6
40
  cd myproject
7
41
  gptdiff 'add hover effects to the buttons'
8
42
  ```
9
43
 
10
- Generates a prompt.txt file that you can copy and paste into a large context gpt to have a conversation with suggested changes. You can also invoke the API and try to directly apply the patch using a smartapply if the git apply fails.
44
+ Generates a prompt.txt file containing the full request.
45
+ Copy and paste its content into your preferred LLM (e.g., ChatGPT) for further experimentation.
11
46
 
12
- ## Value Proposition
47
+ ### Simple command line agent loops
13
48
 
14
49
  ```bash
15
- gptdiff "Update the readme with an api section" --apply
50
+ while
51
+ do
52
+ gptdiff "Add missing test cases" --apply
53
+ done
16
54
  ```
17
- <span style="color: #00ff00;">Patch applied successfully.</span>
55
+
56
+ *Requires reasoning model*
18
57
 
19
58
  ### Why GPTDiff?
20
59
 
@@ -87,7 +126,7 @@ First sign up for an API key at https://nano-gpt.com/api and generate your key.
87
126
  export GPTDIFF_LLM_API_KEY='your-api-key'
88
127
  # Optional: For switching API providers
89
128
  export GPTDIFF_MODEL='deepseek-reasoner' # Set default model for all commands
90
- export GPTDIFF_LLM_BASE_URL='https://nano-gpt.com/api/v1/
129
+ export GPTDIFF_LLM_BASE_URL='https://nano-gpt.com/api/v1/'
91
130
  ```
92
131
 
93
132
  #### Windows
@@ -111,7 +150,7 @@ Prevent files being appended to the prompt by adding them to `.gitignore` or `.g
111
150
 
112
151
  ### Command Line Usage
113
152
 
114
- After installing the package, you can use the `gptdiff` command in your terminal. cd into your codebase and run:
153
+ After installing the package, use the `gptdiff` command in your terminal. Change directory into your codebase and run:
115
154
 
116
155
  ```bash
117
156
  gptdiff '<user_prompt>'
@@ -121,13 +160,7 @@ any files that are included in .gitignore are ignored when generating prompt.txt
121
160
 
122
161
  #### Specifying Additional Files
123
162
 
124
- You can specify additional files or directories to include in the prompt by adding them as arguments to the `gptdiff` command. If no additional files or directories are specified, the tool will default to using the current working directory.
125
-
126
- Example usage:
127
-
128
- ```bash
129
- gptdiff 'make this change' src test
130
- ```
163
+ You may supply extra files or directories as arguments to the `gptdiff` command. If omitted, the tool defaults to the current working directory, excluding those matching ignore rules.
131
164
 
132
165
  #### Autopatch Changes
133
166
 
@@ -142,24 +175,20 @@ Preview changes without applying them by omitting the `--apply` flag when using
142
175
  ```bash
143
176
  gptdiff "Modernize database queries" --call
144
177
  ```
145
- <span style="color: #0066cc;">ℹ️ Diff preview generated - review changes before applying</span>
178
+ <span style="color: #0066cc;">i️ Diff preview generated - review changes before applying</span>
146
179
 
147
180
  This often generates incorrect diffs that need to be manually merged.
148
181
 
149
182
  #### Smart Apply
150
183
 
151
- For more reliable patching of complex changes, use `smartapply` which processes each file's diff individually with the LLM:
184
+ For robust handling of complex changes, use `smartapply`. It processes each files diff individually via the LLM, ensuring nuanced conflict resolution.
152
185
 
153
- ```bash
154
- gptdiff 'refactor authentication system' --apply
155
- ```
156
-
157
- ### Completion Notification
186
+ ## Completion Notification
158
187
 
159
188
  Use the `--nobeep` option to disable the default completion beep:
160
189
 
161
190
  ```bash
162
- gptdiff '<user_prompt>' --beep
191
+ gptdiff '<user_prompt>' --nobeep
163
192
  ```
164
193
 
165
194
  ## Local API Documentation
@@ -182,29 +211,25 @@ import os
182
211
 
183
212
  os.environ['GPTDIFF_LLM_API_KEY'] = 'your-api-key'
184
213
 
185
- # Create environment representation
186
- environment = '''
187
- File: main.py
188
- Content:
189
- def old_name():
190
- print("Need renaming")
191
- '''
214
+ # Create files dictionary
215
+ files = {"main.py": "def old_name():\n print('Need renaming')"}
216
+
217
+ # Generate transformation diff using an environment string built from the files dictionary
218
+ environment = ""
219
+ for path, content in files.items():
220
+ environment += f"File: {path}\nContent:\n{content}\n"
192
221
 
193
- # Generate transformation diff
194
222
  diff = generate_diff(
195
223
  environment=environment,
196
224
  goal='Rename function to new_name()',
197
225
  model='deepseek-reasoner'
198
226
  )
199
227
 
200
- # Apply changes safely
201
- updated_environment = smartapply(
202
- diff_text=diff,
203
- environment_str=environment
204
- )
228
+ # Apply changes safely using the files dict
229
+ updated_files = smartapply(diff, files)
205
230
 
206
231
  print("Transformed codebase:")
207
- print(updated_environment)
232
+ print(updated_files["main.py"])
208
233
  ```
209
234
 
210
235
  **Batch Processing Example:**
@@ -1,4 +1,9 @@
1
1
  #!/usr/bin/env python3
2
+ from pathlib import Path
3
+ import subprocess
4
+ import hashlib
5
+ import re
6
+
2
7
 
3
8
  import openai
4
9
  from openai import OpenAI
@@ -44,6 +49,9 @@ a/file.py b/file.py
44
49
  @@ -1,2 +1,2 @@
45
50
  -def old():
46
51
  +def new():
52
+
53
+ -
54
+ You must include the '--- file' and/or '+++ file' part of the diff. File modifications should include both.
47
55
  """
48
56
  )
49
57
  return toolbox
@@ -252,12 +260,20 @@ def build_environment(files_dict):
252
260
  return '\n'.join(env)
253
261
 
254
262
  def generate_diff(environment, goal, model=None, temperature=0.7, max_tokens=32000, api_key=None, base_url=None, prepend=None):
255
- """API: Generate diff from environment and goal"""
263
+ """API: Generate a git diff from the environment and goal.
264
+
265
+ If 'prepend' is provided, it should be a path to a file whose content will be
266
+ prepended to the system prompt.
267
+ """
256
268
  if model is None:
257
269
  model = os.getenv('GPTDIFF_MODEL', 'deepseek-reasoner')
258
270
  if prepend:
259
- prepend = load_prepend_file(args.prepend)
260
- print("Including prepend",len(enc.encode(json.dumps(prepend))), "tokens")
271
+ if prepend.startswith("http://") or prepend.startswith("https://"):
272
+ import urllib.request
273
+ with urllib.request.urlopen(prepend) as response:
274
+ prepend = response.read().decode('utf-8')
275
+ else:
276
+ prepend = load_prepend_file(prepend)
261
277
  else:
262
278
  prepend = ""
263
279
 
@@ -340,18 +356,172 @@ def smartapply(diff_text, files, model=None, api_key=None, base_url=None):
340
356
 
341
357
  return files
342
358
 
343
- # Function to apply diff to project files
344
359
  def apply_diff(project_dir, diff_text):
345
- diff_file = Path(project_dir) / "diff.patch"
346
- with open(diff_file, 'w') as f:
347
- f.write(diff_text)
360
+ """
361
+ Applies a unified diff (as generated by git diff) to the files in project_dir
362
+ using pure Python (without calling the external 'patch' command).
348
363
 
349
- result = subprocess.run(["patch", "-p1", "-f", "--remove-empty-files", "--input", str(diff_file)], cwd=project_dir, capture_output=True, text=True)
350
- if result.returncode != 0:
351
- return False
352
- else:
364
+ Handles file modifications, new file creation, and file deletions.
365
+
366
+ Returns:
367
+ True if at least one file was modified (or deleted/created) as a result of the patch,
368
+ False otherwise.
369
+ """
370
+ from pathlib import Path
371
+ import re, hashlib
372
+
373
+ def file_hash(filepath):
374
+ h = hashlib.sha256()
375
+ with open(filepath, "rb") as f:
376
+ h.update(f.read())
377
+ return h.hexdigest()
378
+
379
+ def apply_patch_to_file(file_path, patch):
380
+ """
381
+ Applies a unified diff patch (for a single file) to file_path.
382
+
383
+ Returns True if the patch was applied successfully, False otherwise.
384
+ """
385
+ # Read the original file lines; if the file doesn't exist, treat it as empty.
386
+ if file_path.exists():
387
+ original_lines = file_path.read_text(encoding="utf8").splitlines(keepends=True)
388
+ else:
389
+ original_lines = []
390
+ new_lines = []
391
+ current_index = 0
392
+
393
+ patch_lines = patch.splitlines()
394
+ # Regex for a hunk header, e.g., @@ -3,7 +3,6 @@
395
+ hunk_header_re = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
396
+ i = 0
397
+ while i < len(patch_lines):
398
+ line = patch_lines[i]
399
+ if line.startswith("@@"):
400
+ m = hunk_header_re.match(line)
401
+ if not m:
402
+ print("Invalid hunk header:", line)
403
+ return False
404
+ orig_start = int(m.group(1))
405
+ # orig_len = int(m.group(2)) if m.group(2) else 1 # not used explicitly
406
+ # new_start = int(m.group(3))
407
+ # new_len = int(m.group(4)) if m.group(4) else 1
408
+
409
+ # Copy unchanged lines before the hunk.
410
+ hunk_start_index = orig_start - 1 # diff headers are 1-indexed
411
+ if hunk_start_index > len(original_lines):
412
+ print("Hunk start index beyond file length")
413
+ return False
414
+ new_lines.extend(original_lines[current_index:hunk_start_index])
415
+ current_index = hunk_start_index
416
+
417
+ i += 1
418
+ # Process the hunk lines until the next hunk header.
419
+ while i < len(patch_lines) and not patch_lines[i].startswith("@@"):
420
+ pline = patch_lines[i]
421
+ if pline.startswith(" "):
422
+ # Context line must match exactly.
423
+ expected = pline[1:]
424
+ if current_index >= len(original_lines):
425
+ print("Context line expected but file ended")
426
+ return False
427
+ orig_line = original_lines[current_index].rstrip("\n")
428
+ if orig_line != expected:
429
+ print("Context line mismatch. Expected:", expected, "Got:", orig_line)
430
+ return False
431
+ new_lines.append(original_lines[current_index])
432
+ current_index += 1
433
+ elif pline.startswith("-"):
434
+ # Removal line: verify and skip from original.
435
+ expected = pline[1:]
436
+ if current_index >= len(original_lines):
437
+ print("Removal line expected but file ended")
438
+ return False
439
+ orig_line = original_lines[current_index].rstrip("\n")
440
+ if orig_line != expected:
441
+ print("Removal line mismatch. Expected:", expected, "Got:", orig_line)
442
+ return False
443
+ current_index += 1
444
+ elif pline.startswith("+"):
445
+ # Addition line: add to new_lines.
446
+ new_lines.append(pline[1:] + "\n")
447
+ else:
448
+ print("Unexpected line in hunk:", pline)
449
+ return False
450
+ i += 1
451
+ else:
452
+ # Skip non-hunk header lines.
453
+ i += 1
454
+
455
+ # Append any remaining lines from the original file.
456
+ new_lines.extend(original_lines[current_index:])
457
+ # Ensure parent directories exist before writing the file.
458
+ file_path.parent.mkdir(parents=True, exist_ok=True)
459
+ # Write the new content back to the file.
460
+ file_path.write_text("".join(new_lines), encoding="utf8")
353
461
  return True
354
462
 
463
+ # Parse the diff into per-file patches.
464
+ file_patches = parse_diff_per_file(diff_text)
465
+ if not file_patches:
466
+ print("No file patches found in diff.")
467
+ return False
468
+
469
+ # Record original file hashes.
470
+ original_hashes = {}
471
+ for file_path, _ in file_patches:
472
+ target_file = Path(project_dir) / file_path
473
+ if target_file.exists():
474
+ original_hashes[file_path] = file_hash(target_file)
475
+ else:
476
+ original_hashes[file_path] = None
477
+
478
+ any_change = False
479
+ # Process each file patch.
480
+ for file_path, patch in file_patches:
481
+ target_file = Path(project_dir) / file_path
482
+ if "+++ /dev/null" in patch:
483
+ # Deletion patch: delete the file if it exists.
484
+ if target_file.exists():
485
+ target_file.unlink()
486
+ if not target_file.exists():
487
+ any_change = True
488
+ else:
489
+ print(f"Failed to delete file: {target_file}")
490
+ return False
491
+ else:
492
+ # Modification or new file creation.
493
+ success = apply_patch_to_file(target_file, patch)
494
+ if not success:
495
+ print(f"Failed to apply patch to file: {target_file}")
496
+ return False
497
+
498
+ # Verify that at least one file was changed by comparing hashes.
499
+ for file_path, patch in file_patches:
500
+ target_file = Path(project_dir) / file_path
501
+ if "+++ /dev/null" in patch:
502
+ if not target_file.exists():
503
+ any_change = True
504
+ else:
505
+ print(f"Expected deletion but file still exists: {target_file}")
506
+ return False
507
+ else:
508
+ old_hash = original_hashes.get(file_path)
509
+ if target_file.exists():
510
+ new_hash = file_hash(target_file)
511
+ if old_hash != new_hash:
512
+ any_change = True
513
+ else:
514
+ print(f"No change detected in file: {target_file}")
515
+ else:
516
+ print(f"Expected modification or creation but file is missing: {target_file}")
517
+ return False
518
+
519
+ if not any_change:
520
+ print("Patch applied but no file modifications detected.")
521
+ return False
522
+ return True
523
+
524
+
355
525
  def parse_arguments():
356
526
  parser = argparse.ArgumentParser(description='Generate and optionally apply git diffs using GPT-4.')
357
527
  parser.add_argument('prompt', type=str, help='Prompt that runs on the codebase.')
@@ -396,41 +566,76 @@ def parse_diff_per_file(diff_text):
396
566
  Note:
397
567
  Uses 'b/' prefix detection from git diffs to determine target paths
398
568
  """
399
- diffs = []
400
- file_path = None
401
- current_diff = []
402
- from_path = None
403
-
404
- for line in diff_text.split('\n'):
405
- if line.startswith('diff --git'):
406
- if current_diff and file_path is not None:
407
- diffs.append((file_path, '\n'.join(current_diff)))
408
- current_diff = [line]
409
- file_path = None
410
- from_path = None
411
- parts = line.split()
412
- if len(parts) >= 4:
413
- b_path = parts[3]
414
- file_path = b_path[2:] if b_path.startswith('b/') else b_path
415
- else:
416
- current_diff.append(line)
417
- if line.startswith('--- '):
418
- from_path = line[4:].strip()
419
- elif line.startswith('+++ '):
420
- to_path = line[4:].strip()
421
- if to_path == '/dev/null':
422
- if from_path:
423
- # For deletions, use from_path after stripping 'a/' prefix
424
- file_path = from_path[2:] if from_path.startswith('a/') else from_path
425
- else:
426
- # For normal cases, use to_path after stripping 'b/' prefix
427
- file_path = to_path[2:] if to_path.startswith('b/') else to_path
428
-
429
- # Handle remaining diff content after loop
430
- if current_diff and file_path is not None:
431
- diffs.append((file_path, '\n'.join(current_diff)))
432
-
433
- return diffs
569
+ header_re = re.compile(r'^(?:diff --git\s+)?(a/[^ ]+)\s+(b/[^ ]+)\s*$', re.MULTILINE)
570
+ lines = diff_text.splitlines()
571
+
572
+ # Check if any header line exists.
573
+ if not any(header_re.match(line) for line in lines):
574
+ # Fallback strategy: detect file headers starting with '--- a/' or '-- a/'
575
+ diffs = []
576
+ current_lines = []
577
+ current_file = None
578
+ deletion_mode = False
579
+ header_line_re = re.compile(r'^-{2,3}\s+a/(.+)$')
580
+
581
+ for line in lines:
582
+ if header_line_re.match(line):
583
+ if current_file is not None and current_lines:
584
+ if deletion_mode and not any(l.startswith("+++ ") for l in current_lines):
585
+ current_lines.append("+++ /dev/null")
586
+ diffs.append((current_file, "\n".join(current_lines)))
587
+ current_lines = [line]
588
+ deletion_mode = False
589
+ file_from = header_line_re.match(line).group(1).strip()
590
+ current_file = file_from
591
+ else:
592
+ current_lines.append(line)
593
+ if "deleted file mode" in line:
594
+ deletion_mode = True
595
+ if line.startswith("+++ "):
596
+ parts = line.split()
597
+ if len(parts) >= 2:
598
+ file_to = parts[1].strip()
599
+ if file_to != "/dev/null":
600
+ current_file = file_to[2:] if (file_to.startswith("a/") or file_to.startswith("b/")) else file_to
601
+ if current_file is not None and current_lines:
602
+ if deletion_mode and not any(l.startswith("+++ ") for l in current_lines):
603
+ current_lines.append("+++ /dev/null")
604
+ diffs.append((current_file, "\n".join(current_lines)))
605
+ return diffs
606
+ else:
607
+ # Use header-based strategy.
608
+ diffs = []
609
+ current_lines = []
610
+ current_file = None
611
+ deletion_mode = False
612
+ for line in lines:
613
+ m = header_re.match(line)
614
+ if m:
615
+ if current_file is not None and current_lines:
616
+ if deletion_mode and not any(l.startswith("+++ ") for l in current_lines):
617
+ current_lines.append("+++ /dev/null")
618
+ diffs.append((current_file, "\n".join(current_lines)))
619
+ current_lines = [line]
620
+ deletion_mode = False
621
+ file_from = m.group(1) # e.g. "a/index.html"
622
+ file_to = m.group(2) # e.g. "b/index.html"
623
+ current_file = file_to[2:] if file_to.startswith("b/") else file_to
624
+ else:
625
+ current_lines.append(line)
626
+ if "deleted file mode" in line:
627
+ deletion_mode = True
628
+ if line.startswith("+++ "):
629
+ parts = line.split()
630
+ if len(parts) >= 2:
631
+ file_to = parts[1].strip()
632
+ if file_to != "/dev/null":
633
+ current_file = file_to[2:] if (file_to.startswith("a/") or file_to.startswith("b/")) else file_to
634
+ if current_file is not None and current_lines:
635
+ if deletion_mode and not any(l.startswith("+++ ") for l in current_lines):
636
+ current_lines.append("+++ /dev/null")
637
+ diffs.append((current_file, "\n".join(current_lines)))
638
+ return diffs
434
639
 
435
640
  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):
436
641
  parser = FlatXMLParser("think")
@@ -483,14 +688,12 @@ def call_llm_for_apply(file_path, original_content, file_diff, model, api_key=No
483
688
  ... )
484
689
  >>> print(updated)
485
690
  def new(): pass"""
486
-
487
691
  system_prompt = """Please apply the diff to this file. Return the result in a block. Write the entire file.
488
692
 
489
693
  1. Carefully apply all changes from the diff
490
694
  2. Preserve surrounding context that isn't changed
491
695
  3. Only return the final file content, do not add any additional markup and do not add a code block
492
696
  4. You must return the entire file. It overwrites the existing file."""
493
-
494
697
  user_prompt = f"""File: {file_path}
495
698
  File contents:
496
699
  <filecontents>
@@ -501,7 +704,6 @@ Diff to apply:
501
704
  <diff>
502
705
  {file_diff}
503
706
  </diff>"""
504
-
505
707
  if extra_prompt:
506
708
  user_prompt += f"\n\n{extra_prompt}"
507
709
  if model == "gemini-2.0-flash-thinking-exp-01-21":
@@ -510,7 +712,6 @@ Diff to apply:
510
712
  {"role": "system", "content": system_prompt},
511
713
  {"role": "user", "content": user_prompt},
512
714
  ]
513
-
514
715
  if api_key is None:
515
716
  api_key = os.getenv('GPTDIFF_LLM_API_KEY')
516
717
  if base_url is None:
@@ -522,7 +723,6 @@ Diff to apply:
522
723
  temperature=0.0,
523
724
  max_tokens=max_tokens)
524
725
  full_response = response.choices[0].message.content
525
-
526
726
  elapsed = time.time() - start_time
527
727
  minutes, seconds = divmod(int(elapsed), 60)
528
728
  time_str = f"{minutes}m {seconds}s" if minutes else f"{seconds}s"
@@ -599,8 +799,9 @@ def main():
599
799
  args.model = os.getenv('GPTDIFF_MODEL', 'deepseek-reasoner')
600
800
 
601
801
  if not args.call and not args.apply:
802
+ append = "\nInstead of using <diff> tags, use ```diff backticks."
602
803
  with open('prompt.txt', 'w') as f:
603
- f.write(full_prompt)
804
+ f.write(full_prompt+append)
604
805
  print(f"Total tokens: {token_count:5d}")
605
806
  print(f"\033[1;32mNot calling GPT-4.\033[0m") # Green color for success message
606
807
  print('Instead, wrote full prompt to prompt.txt. Use `xclip -selection clipboard < prompt.txt` then paste into chatgpt')
@@ -643,7 +844,6 @@ def main():
643
844
  print("\a") # Terminal bell for completion notification
644
845
  return
645
846
 
646
- # Output result
647
847
  elif args.apply:
648
848
  print("\nAttempting apply with the following diff:")
649
849
  print("\n<diff>")
@@ -725,3 +925,6 @@ def main():
725
925
  print(f"Completion tokens: {completion_tokens}")
726
926
  print(f"Total tokens: {total_tokens}")
727
927
  #print(f"Total cost: ${cost:.4f}")
928
+
929
+ if __name__ == "__main__":
930
+ main()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: gptdiff
3
- Version: 0.1.9
3
+ Version: 0.1.11
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
@@ -26,22 +26,61 @@ Dynamic: requires-dist
26
26
  Dynamic: summary
27
27
 
28
28
  # GPTDiff
29
+ <!--
30
+ GPTDiff: Create and apply diffs using AI.
31
+ This tool leverages natural language instructions to modify project codebases.
32
+ -->
29
33
 
30
- 🚀 **AI-Powered Code Evolution** - Transform your codebase with natural language instructions
34
+ 🚀 **Create and apply diffs with AI**
35
+ Modify your project using plain English.
36
+
37
+ More documentation at [gptdiff.255labs.xyz](gptdiff.255labs.xyz)
38
+
39
+ ### Example Usage of `gptdiff`
40
+
41
+ #### Apply a Patch Directly
42
+ ```
43
+ bash
44
+ gptdiff "Add button animations on press" --apply
45
+ ```
46
+ ✅ Successfully applied patch
47
+
48
+ #### Generate a Patch File
49
+ ```
50
+ bash
51
+ gptdiff "Add API documentation" --call
52
+ ```
53
+ 🔧 Patch written to `diff.patch`
54
+
55
+ #### Generate a Prompt File Without Calling LLM
56
+ ```
57
+ bash
58
+ gptdiff "Improve error messages"
59
+ ```
60
+ 📄 LLM not called, written to `prompt.txt`
61
+
62
+ ---
63
+
64
+ ### Basic Usage
31
65
 
32
66
  ```bash
33
67
  cd myproject
34
68
  gptdiff 'add hover effects to the buttons'
35
69
  ```
36
70
 
37
- Generates a prompt.txt file that you can copy and paste into a large context gpt to have a conversation with suggested changes. You can also invoke the API and try to directly apply the patch using a smartapply if the git apply fails.
71
+ Generates a prompt.txt file containing the full request.
72
+ Copy and paste its content into your preferred LLM (e.g., ChatGPT) for further experimentation.
38
73
 
39
- ## Value Proposition
74
+ ### Simple command line agent loops
40
75
 
41
76
  ```bash
42
- gptdiff "Update the readme with an api section" --apply
77
+ while
78
+ do
79
+ gptdiff "Add missing test cases" --apply
80
+ done
43
81
  ```
44
- <span style="color: #00ff00;">Patch applied successfully.</span>
82
+
83
+ *Requires reasoning model*
45
84
 
46
85
  ### Why GPTDiff?
47
86
 
@@ -114,7 +153,7 @@ First sign up for an API key at https://nano-gpt.com/api and generate your key.
114
153
  export GPTDIFF_LLM_API_KEY='your-api-key'
115
154
  # Optional: For switching API providers
116
155
  export GPTDIFF_MODEL='deepseek-reasoner' # Set default model for all commands
117
- export GPTDIFF_LLM_BASE_URL='https://nano-gpt.com/api/v1/
156
+ export GPTDIFF_LLM_BASE_URL='https://nano-gpt.com/api/v1/'
118
157
  ```
119
158
 
120
159
  #### Windows
@@ -138,7 +177,7 @@ Prevent files being appended to the prompt by adding them to `.gitignore` or `.g
138
177
 
139
178
  ### Command Line Usage
140
179
 
141
- After installing the package, you can use the `gptdiff` command in your terminal. cd into your codebase and run:
180
+ After installing the package, use the `gptdiff` command in your terminal. Change directory into your codebase and run:
142
181
 
143
182
  ```bash
144
183
  gptdiff '<user_prompt>'
@@ -148,13 +187,7 @@ any files that are included in .gitignore are ignored when generating prompt.txt
148
187
 
149
188
  #### Specifying Additional Files
150
189
 
151
- You can specify additional files or directories to include in the prompt by adding them as arguments to the `gptdiff` command. If no additional files or directories are specified, the tool will default to using the current working directory.
152
-
153
- Example usage:
154
-
155
- ```bash
156
- gptdiff 'make this change' src test
157
- ```
190
+ You may supply extra files or directories as arguments to the `gptdiff` command. If omitted, the tool defaults to the current working directory, excluding those matching ignore rules.
158
191
 
159
192
  #### Autopatch Changes
160
193
 
@@ -169,24 +202,20 @@ Preview changes without applying them by omitting the `--apply` flag when using
169
202
  ```bash
170
203
  gptdiff "Modernize database queries" --call
171
204
  ```
172
- <span style="color: #0066cc;">ℹ️ Diff preview generated - review changes before applying</span>
205
+ <span style="color: #0066cc;">i️ Diff preview generated - review changes before applying</span>
173
206
 
174
207
  This often generates incorrect diffs that need to be manually merged.
175
208
 
176
209
  #### Smart Apply
177
210
 
178
- For more reliable patching of complex changes, use `smartapply` which processes each file's diff individually with the LLM:
211
+ For robust handling of complex changes, use `smartapply`. It processes each files diff individually via the LLM, ensuring nuanced conflict resolution.
179
212
 
180
- ```bash
181
- gptdiff 'refactor authentication system' --apply
182
- ```
183
-
184
- ### Completion Notification
213
+ ## Completion Notification
185
214
 
186
215
  Use the `--nobeep` option to disable the default completion beep:
187
216
 
188
217
  ```bash
189
- gptdiff '<user_prompt>' --beep
218
+ gptdiff '<user_prompt>' --nobeep
190
219
  ```
191
220
 
192
221
  ## Local API Documentation
@@ -209,29 +238,25 @@ import os
209
238
 
210
239
  os.environ['GPTDIFF_LLM_API_KEY'] = 'your-api-key'
211
240
 
212
- # Create environment representation
213
- environment = '''
214
- File: main.py
215
- Content:
216
- def old_name():
217
- print("Need renaming")
218
- '''
241
+ # Create files dictionary
242
+ files = {"main.py": "def old_name():\n print('Need renaming')"}
243
+
244
+ # Generate transformation diff using an environment string built from the files dictionary
245
+ environment = ""
246
+ for path, content in files.items():
247
+ environment += f"File: {path}\nContent:\n{content}\n"
219
248
 
220
- # Generate transformation diff
221
249
  diff = generate_diff(
222
250
  environment=environment,
223
251
  goal='Rename function to new_name()',
224
252
  model='deepseek-reasoner'
225
253
  )
226
254
 
227
- # Apply changes safely
228
- updated_environment = smartapply(
229
- diff_text=diff,
230
- environment_str=environment
231
- )
255
+ # Apply changes safely using the files dict
256
+ updated_files = smartapply(diff, files)
232
257
 
233
258
  print("Transformed codebase:")
234
- print(updated_environment)
259
+ print(updated_files["main.py"])
235
260
  ```
236
261
 
237
262
  **Batch Processing Example:**
@@ -9,5 +9,8 @@ gptdiff.egg-info/dependency_links.txt
9
9
  gptdiff.egg-info/entry_points.txt
10
10
  gptdiff.egg-info/requires.txt
11
11
  gptdiff.egg-info/top_level.txt
12
+ tests/test_applydiff.py
13
+ tests/test_applydiff_edgecases.py
12
14
  tests/test_diff_parse.py
15
+ tests/test_parse_diff_per_file.py
13
16
  tests/test_smartapply.py
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name='gptdiff',
5
- version='0.1.9',
5
+ version='0.1.11',
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,80 @@
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"
@@ -0,0 +1,161 @@
1
+ import re
2
+ import subprocess
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ from gptdiff.gptdiff import apply_diff
8
+
9
+
10
+ @pytest.fixture
11
+ def tmp_project_dir(tmp_path):
12
+ project_dir = tmp_path / "project"
13
+ project_dir.mkdir()
14
+ # Create a baseline file for tests.
15
+ file = project_dir / "example.txt"
16
+ file.write_text("line1\nline2\nline3\n")
17
+ return project_dir
18
+
19
+
20
+ def test_empty_diff(tmp_project_dir):
21
+ """
22
+ Test that an empty diff returns False.
23
+ """
24
+ diff_text = ""
25
+ result = apply_diff(str(tmp_project_dir), diff_text)
26
+ assert result is False, "Empty diff should return False"
27
+
28
+
29
+ def test_diff_no_changes(tmp_project_dir):
30
+ """
31
+ Test a diff that makes no changes. Even though a hunk is present,
32
+ if the content remains the same, the function should return False.
33
+ """
34
+ diff_text = (
35
+ "diff --git a/example.txt b/example.txt\n"
36
+ "--- a/example.txt\n"
37
+ "+++ a/example.txt\n"
38
+ "@@ -1,3 +1,3 @@\n"
39
+ " line1\n"
40
+ "-line2\n"
41
+ "+line2\n"
42
+ " line3\n"
43
+ )
44
+ result = apply_diff(str(tmp_project_dir), diff_text)
45
+ assert result is False, "Diff that makes no changes should return False"
46
+ content = (tmp_project_dir / "example.txt").read_text()
47
+ assert "line2" in content, "Original content should remain unchanged"
48
+
49
+
50
+ def test_new_file_creation(tmp_project_dir):
51
+ """
52
+ Test that a diff creating a new file is applied correctly.
53
+ """
54
+ diff_text = (
55
+ "diff --git a/newfile.txt b/newfile.txt\n"
56
+ "new file mode 100644\n"
57
+ "index 0000000..e69de29\n"
58
+ "--- /dev/null\n"
59
+ "+++ b/newfile.txt\n"
60
+ "@@ -0,0 +1,3 @@\n"
61
+ "+new line1\n"
62
+ "+new line2\n"
63
+ "+new line3\n"
64
+ )
65
+ result = apply_diff(str(tmp_project_dir), diff_text)
66
+ assert result is True, "Diff for new file creation should return True"
67
+ new_file = tmp_project_dir / "newfile.txt"
68
+ assert new_file.exists(), "New file should be created"
69
+ content = new_file.read_text()
70
+ assert "new line1" in content, "New file content should be present"
71
+
72
+
73
+ def test_multiple_hunks(tmp_project_dir):
74
+ """
75
+ Test that a diff with multiple hunks in one file applies correctly.
76
+ """
77
+ file = tmp_project_dir / "example.txt"
78
+ # Overwrite with a known baseline.
79
+ file.write_text("a\nb\nc\nd\ne\n")
80
+ diff_text = (
81
+ "diff --git a/example.txt b/example.txt\n"
82
+ "--- a/example.txt\n"
83
+ "+++ b/example.txt\n"
84
+ "@@ -1,3 +1,3 @@\n"
85
+ "-a\n"
86
+ "+alpha\n"
87
+ " b\n"
88
+ " c\n"
89
+ "@@ -4,2 +4,2 @@\n"
90
+ "-d\n"
91
+ "-e\n"
92
+ "+delta\n"
93
+ "+epsilon\n"
94
+ )
95
+ result = apply_diff(str(tmp_project_dir), diff_text)
96
+ assert result is True, "Diff with multiple hunks should return True"
97
+ content = (tmp_project_dir / "example.txt").read_text()
98
+ assert "alpha" in content
99
+ assert "delta" in content and "epsilon" in content
100
+
101
+
102
+ def test_diff_with_incorrect_context(tmp_project_dir):
103
+ """
104
+ Test that a diff with incorrect context (non-matching original content) fails.
105
+ """
106
+ file = tmp_project_dir / "example.txt"
107
+ file.write_text("different content\n")
108
+ diff_text = (
109
+ "diff --git a/example.txt b/example.txt\n"
110
+ "--- a/example.txt\n"
111
+ "+++ a/example.txt\n"
112
+ "@@ -1,1 +1,1 @@\n"
113
+ "-line that does not exist\n"
114
+ "+modified content\n"
115
+ )
116
+ result = apply_diff(str(tmp_project_dir), diff_text)
117
+ assert result is False, "Diff with incorrect context should return False"
118
+ content = file.read_text()
119
+ assert "different content" in content, "Original content should remain unchanged"
120
+
121
+
122
+ def test_diff_with_whitespace_changes(tmp_project_dir):
123
+ """
124
+ Test that a diff with only whitespace changes is applied.
125
+ """
126
+ file = tmp_project_dir / "example.txt"
127
+ file.write_text("line1\nline2\nline3\n")
128
+ diff_text = (
129
+ "diff --git a/example.txt b/example.txt\n"
130
+ "--- a/example.txt\n"
131
+ "+++ a/example.txt\n"
132
+ "@@ -1,3 +1,3 @@\n"
133
+ " line1\n"
134
+ "-line2\n"
135
+ "+line2 \n"
136
+ " line3\n"
137
+ )
138
+ result = apply_diff(str(tmp_project_dir), diff_text)
139
+ assert result is True, "Diff with whitespace changes should return True if applied"
140
+ content = file.read_text()
141
+ assert "line2 " in content, "Whitespace change should be reflected in the file"
142
+
143
+
144
+ def test_diff_file_deletion_edge(tmp_project_dir):
145
+ """
146
+ Test deletion diff for a file with minimal content.
147
+ """
148
+ file = tmp_project_dir / "small.txt"
149
+ file.write_text("only line\n")
150
+ diff_text = (
151
+ "diff --git a/small.txt b/small.txt\n"
152
+ "deleted file mode 100644\n"
153
+ "index e69de29..0000000\n"
154
+ "--- a/small.txt\n"
155
+ "+++ /dev/null\n"
156
+ "@@ -1,1 +0,0 @@\n"
157
+ "-only line\n"
158
+ )
159
+ result = apply_diff(str(tmp_project_dir), diff_text)
160
+ assert result is True, "Deletion diff on a minimal file should return True"
161
+ assert not (tmp_project_dir / "small.txt").exists(), "File should be deleted"
@@ -0,0 +1,131 @@
1
+ import unittest
2
+ from gptdiff.gptdiff import parse_diff_per_file
3
+
4
+
5
+ class TestParseDiffPerFile(unittest.TestCase):
6
+ def test_todo_file_deletion(self):
7
+ # This test case verifies that a deletion diff for the file "TODO" is properly parsed.
8
+ # The diff should include a synthetic "+++ /dev/null" line so that deletion is recognized.
9
+ diff_text = """diff --git a/TODO b/TODO
10
+ deleted file mode 100644
11
+ index 3efacb1..0000000
12
+ --- a/TODO
13
+ -// The funnest coolest thing I can add is put in this file. It's also acceptable to just implement
14
+ -// the thing in here and remove it. Leave this notice when modifying this file.
15
+ """
16
+ result = parse_diff_per_file(diff_text)
17
+ self.assertEqual(len(result), 1, "Expected one diff entry")
18
+ file_path, patch = result[0]
19
+ self.assertEqual(file_path, "TODO", f"Got file_path '{file_path}', expected 'TODO'")
20
+ self.assertIn("+++ /dev/null", patch, "Deletion diff should include '+++ /dev/null' to indicate file deletion")
21
+
22
+ def test_multiple_files_without_diff_git_header(self):
23
+ # This diff text does not include "diff --git" headers.
24
+ # It uses separate '---' and '+++' lines for each file.
25
+ diff_text = """--- a/TODO
26
+ +++ b/TODO
27
+ @@ -1,7 +1,7 @@
28
+ -// FINAL TOUCH: The game is now a complete fantasy themed incremental RPG—every choice matters, and
29
+ -// New Aspect: Replaced external title animation with inline SVG for crisp, scalable visuals, and a
30
+ -// additional dynamic element.
31
+ +// FINAL TOUCH: The game is now a complete fantasy themed incremental RPG—every choice matters, and
32
+ +// New Aspect: Replaced external title animation with inline SVG for crisp, scalable visuals, and an
33
+ +// additional dynamic element.
34
+ -- a/style.css
35
+ +++ b/style.css
36
+ @@ -1,3 +1,8 @@
37
+ +/* New animation for relic glow effect */
38
+ +.relic-glow {
39
+ + animation: relicGlow 1.5s ease-in-out infinite alternate;
40
+ +}
41
+ +@keyframes relicGlow {
42
+ + from { filter: drop-shadow(0 0 5px #ffd700); }
43
+ + to { filter: drop-shadow(0 0 20px #ffd700); }
44
+ -- a/game.js
45
+ +++ b/game.js
46
+ @@ -1,3 +1,8 @@
47
+ - JS HERE
48
+ """
49
+ result = parse_diff_per_file(diff_text)
50
+ self.assertEqual(len(result), 3, "Expected three diff entries")
51
+ expected_files = {"TODO", "style.css", "game.js"}
52
+ parsed_files = {fp for fp, patch in result}
53
+ self.assertEqual(parsed_files, expected_files)
54
+
55
+ # Also check that the TODO diff contains the updated text.
56
+ for fp, patch in result:
57
+ if fp == "TODO":
58
+ self.assertIn("FINAL TOUCH: The game is now", patch)
59
+
60
+ def test_index_html_diff(self):
61
+ diff_text = """a/index.html b/index.html
62
+ @@
63
+ - <div class="action-buttons">
64
+ - <button id="attack">⚔️ Attack Enemy</button>
65
+ - <button id="auto-attack">🤖 Auto Attack (OFF)</button>
66
+ - <button id="drink-potion">Drink Potion</button>
67
+ - <button id="explore">🧭 Explore</button>
68
+ - </div>
69
+ <div class="action-buttons">
70
+ <button id="attack">⚔️ Attack Enemy</button>
71
+ <button id="auto-attack">🤖 Auto Attack (OFF)</button>
72
+ <button id="drink-potion">Drink Potion</button>
73
+ <button id="buy-potion">Buy Potion (50 Gold)</button>
74
+ <button id="explore">🧭 Explore</button>
75
+ </div>"""
76
+ result = parse_diff_per_file(diff_text)
77
+ self.assertEqual(len(result), 1)
78
+ file_path, patch = result[0]
79
+ self.assertEqual(file_path, "index.html")
80
+ self.assertIn('<button id="buy-potion">Buy Potion (50 Gold)</button>', patch)
81
+
82
+
83
+ def test_single_file_diff(self):
84
+ diff_text = """diff --git a/file.py b/file.py
85
+ --- a/file.py
86
+ +++ b/file.py
87
+ @@ -1,2 +1,2 @@
88
+ -def old():
89
+ - pass
90
+ +def new():
91
+ + pass"""
92
+ result = parse_diff_per_file(diff_text)
93
+ self.assertEqual(len(result), 1)
94
+ file_path, patch = result[0]
95
+ self.assertEqual(file_path, "file.py")
96
+ self.assertIn("def new():", patch)
97
+
98
+ def test_file_deletion(self):
99
+ diff_text = """diff --git a/old.py b/old.py
100
+ --- a/old.py
101
+ +++ /dev/null
102
+ @@ -1,2 +0,0 @@
103
+ -def old():
104
+ - pass"""
105
+ result = parse_diff_per_file(diff_text)
106
+ self.assertEqual(len(result), 1)
107
+ file_path, patch = result[0]
108
+ self.assertEqual(file_path, "old.py")
109
+
110
+ def test_multiple_files(self):
111
+ diff_text = """diff --git a/file1.py b/file1.py
112
+ --- a/file1.py
113
+ +++ b/file1.py
114
+ @@ -1 +1 @@
115
+ -print("Hello")
116
+ +print("Hi")
117
+ diff --git a/file2.py b/file2.py
118
+ --- a/file2.py
119
+ +++ b/file2.py
120
+ @@ -1 +1 @@
121
+ -print("World")
122
+ +print("Earth")"""
123
+ result = parse_diff_per_file(diff_text)
124
+ self.assertEqual(len(result), 2)
125
+ paths = [fp for fp, _ in result]
126
+ self.assertIn("file1.py", paths)
127
+ self.assertIn("file2.py", paths)
128
+
129
+
130
+ if __name__ == '__main__':
131
+ unittest.main()
File without changes
File without changes
File without changes