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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gptdiff/applydiff.py 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,,