gptdiff 0.1.22__py3-none-any.whl → 0.1.24__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
gptdiff/applydiff.py ADDED
@@ -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
+
gptdiff/gptdiff.py CHANGED
@@ -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}"
gptdiff/gptpatch.py CHANGED
@@ -13,8 +13,7 @@ This tool uses the same patch-application logic as gptdiff.
13
13
  import sys
14
14
  import argparse
15
15
  from pathlib import Path
16
- from gptdiff.gptdiff import apply_diff
17
-
16
+ from gptdiff.gptdiff import apply_diff, smart_apply_patch
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
@@ -0,0 +1,10 @@
1
+ gptdiff/__init__.py,sha256=o1hrK4GFvbfKcHPlLVArz4OunE3euIicEBYaLrdDo0k,198
2
+ gptdiff/applydiff.py,sha256=nvTerBtFuXWf1j6nisGY7CQ6qJCIM8J9UHxgoiWReoY,11116
3
+ gptdiff/gptdiff.py,sha256=XN05Zbr1H69_iG8Bx8RQ34vTXXg3WHDANRcGo3ihrhA,31518
4
+ gptdiff/gptpatch.py,sha256=opakY6j_I05ZNx2ACYgxB8SxoZ3POf9iFxDkV5Yn1oU,2393
5
+ gptdiff-0.1.24.dist-info/LICENSE.txt,sha256=zCJk7yUYpMjFvlipi1dKtaljF8WdZ2NASndBYYbU8BY,1228
6
+ gptdiff-0.1.24.dist-info/METADATA,sha256=TE_nbtNX0IMjDek5MHxvDDUaAeVhCXw7p5kCmh0TpZg,8785
7
+ gptdiff-0.1.24.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
8
+ gptdiff-0.1.24.dist-info/entry_points.txt,sha256=0VlVNr-gc04a3SZD5_qKIBbtg_L5P2x3xlKE5ftcdkc,82
9
+ gptdiff-0.1.24.dist-info/top_level.txt,sha256=XNkQkQGINaDndEwRxg8qToOrJ9coyfAb-EHrSUXzdCE,8
10
+ gptdiff-0.1.24.dist-info/RECORD,,
@@ -1,9 +0,0 @@
1
- gptdiff/__init__.py,sha256=o1hrK4GFvbfKcHPlLVArz4OunE3euIicEBYaLrdDo0k,198
2
- gptdiff/gptdiff.py,sha256=AuZwZ1pg52RPheAzdhtZXSTjBGH4t4KRm7r9ziGHJVQ,41388
3
- gptdiff/gptpatch.py,sha256=Z8CWWIfIL2o7xPLVdhzN5GSyJq0vsK4XQRzu4hMWNQk,2194
4
- gptdiff-0.1.22.dist-info/LICENSE.txt,sha256=zCJk7yUYpMjFvlipi1dKtaljF8WdZ2NASndBYYbU8BY,1228
5
- gptdiff-0.1.22.dist-info/METADATA,sha256=_RspqYV4VPaRrpYTQXNVecFirrxzZq7MelPpZLV3O9Q,8785
6
- gptdiff-0.1.22.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
7
- gptdiff-0.1.22.dist-info/entry_points.txt,sha256=0VlVNr-gc04a3SZD5_qKIBbtg_L5P2x3xlKE5ftcdkc,82
8
- gptdiff-0.1.22.dist-info/top_level.txt,sha256=XNkQkQGINaDndEwRxg8qToOrJ9coyfAb-EHrSUXzdCE,8
9
- gptdiff-0.1.22.dist-info/RECORD,,