GPT Patcher
ChatGPT can provide a unified patch as output for code related changes (project instructions or prompt example 'return diffs of the changes proposed for ease of applying them.'). These unified patches are in a format not understood by git or the patch command. They do not contain specific line numbers but context surrounding the changes. To avoid issues with GPT hallucinations, strict checks need to be performed.
The script below can help apply these patches.
It accepts file, stdin, clipboard or interactive manual paste as source for the patch, parses it for the target filename or prompts if none found, checks if it can apply the patch and outputs the newly patched file.
Command line option -i can replace the original file immediately, but creates backup files.
Command line option -t can be used to patch a different filename.
On errors, the script will abort.
- gpt-patcher.py
#!/usr/bin/env python3 import argparse import os import re import sys import shutil try: import pyperclip except ImportError: pyperclip = None PATCH_BEGIN = "*** Begin Patch" PATCH_END = "*** End Patch" PATCH_UPDATE_FILE = "*** Update File:" def read_patch_input(source: str = None): """Read patch from file, stdin, clipboard, or prompt.""" if source: with open(source, "r", encoding="utf-8") as f: data = f.read() elif not sys.stdin.isatty(): data = sys.stdin.read() else: clip = pyperclip.paste().strip() if pyperclip else "" if clip: print("Using patch from clipboard.") data = clip else: if pyperclip: print("No valid patch detected in clipboard.") print("Please paste your patch below, then press Ctrl-D (Linux/macOS) or Ctrl-Z (Windows) when done:\n") try: data = sys.stdin.read() except KeyboardInterrupt: sys.exit("\nAborted.") return data.strip() def detect_patch_format(data: str): """Return (has_header, has_update_line) flags.""" has_header = PATCH_BEGIN in data and PATCH_END in data has_update_line = PATCH_UPDATE_FILE in data return has_header, has_update_line def parse_patch(data: str, filename_override=None): """Extract the target filename and list of hunks.""" match = re.search(r"\*\*\* Update File:\s*(.+)", data) filename = match.group(1).strip() if match else filename_override if not filename: filename = input("No target file specified. Please enter the filename to patch: ").strip() if not filename: sys.exit("Error: Target filename required.") # Extract hunks (between @@ markers) hunks = [] current_hunk = [] in_hunk = False for line in data.splitlines(): if line.startswith("@@"): if current_hunk: hunks.append(current_hunk) current_hunk = [] in_hunk = True elif in_hunk: current_hunk.append(line) if current_hunk: hunks.append(current_hunk) return filename, hunks def make_backup(path: str): """Create sequential .bak backups if needed.""" base_backup = path + ".bak" if not os.path.exists(base_backup): shutil.copy2(path, base_backup) return base_backup i = 1 while True: backup_name = f"{base_backup}{i}" if not os.path.exists(backup_name): shutil.copy2(path, backup_name) return backup_name i += 1 def find_and_replace_block(content, old_block, new_block, context_before=None, context_after=None, filename=None): """Find a full old_block with optional context and replace with new_block.""" pattern_parts = [] if context_before: pattern_parts.append(re.escape(context_before)) pattern_parts.append(re.escape(old_block)) if context_after: pattern_parts.append(re.escape(context_after)) pattern = "(?s)" + ".*?".join(pattern_parts) match = re.search(pattern, content) if not match: # Try looser match match = re.search(re.escape(old_block), content) if not match: sys.exit( f"Error: Could not find patch target block in '{filename}' containing:\n{old_block.strip()[:200]}" ) start, end = match.span() return content[:start] + content[start:end].replace(old_block, new_block, 1) + content[end:] def apply_hunk_to_content(content: str, hunk, filename): """Apply a hunk (multi-line context aware).""" minus_lines, plus_lines, context_lines = [], [], [] for line in hunk: if line.startswith("-"): minus_lines.append(line[1:]) elif line.startswith("+"): plus_lines.append(line[1:]) elif line.startswith(" "): context_lines.append(line[1:]) old_block = "\n".join(minus_lines) new_block = "\n".join(plus_lines) context_before = "\n".join(context_lines[:3]) if context_lines else None context_after = "\n".join(context_lines[-3:]) if context_lines else None return find_and_replace_block(content, old_block, new_block, context_before, context_after, filename) def apply_patch_to_file(filename, hunks): """Apply patch hunks to file contents.""" with open(filename, "r", encoding="utf-8") as f: content = f.read() for hunk in hunks: content = apply_hunk_to_content(content, hunk, filename) return content def main(): parser = argparse.ArgumentParser(description="Apply ChatGPT or raw unified diffs to files.") parser.add_argument("patchfile", nargs="?", help="File containing the patch (optional)") parser.add_argument("-i", "--in-place", action="store_true", help="Replace original file (create backup)") parser.add_argument("-t", "--target", help="Apply patch to this file instead of the one mentioned in patch") args = parser.parse_args() data = read_patch_input(args.patchfile) has_header, has_update_line = detect_patch_format(data) # If header present but update line missing, prompt for file patch_file, hunks = parse_patch(data, filename_override=args.target) if not os.path.exists(patch_file): sys.exit(f"Error: Target file '{patch_file}' not found.") result = apply_patch_to_file(patch_file, hunks) if args.in_place: backup = make_backup(patch_file) with open(patch_file, "w", encoding="utf-8") as f: f.write(result) print(f"Patched {patch_file} (backup: {backup})") else: sys.stdout.write(result) if __name__ == "__main__": main()