Guest User

poor man's codex

a guest
Jul 25th, 2026
32
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 15.77 KB | Source Code | 0 0
  1. #!/usr/bin/env python3
  2. """
  3. apply_patch_bundle.py
  4.  
  5. Reusable exact-match patch runner for local patch bundles.
  6.  
  7. Usage:
  8.  1: Place this script in the root of your project and name it apply_patch_bundle.py.
  9.  2: Create a folder named "patches" in the root of your project.
  10.  3: Upload your source code to chatgpt as a zip file or connect chatgpt to Github and
  11.     use @GitHub in your prompt and point to the repository name and branch. You can
  12.     then request chatgpt to provide a json patch to update/fix whatever you want.
  13.     Chatgpt will provide a json patch file like "update_button.patch".
  14.  4: You download the .patch file and place it in the /patches folder.
  15.  5: Then run: python .\apply_patch_bundle.py
  16.  6: You can see the results in the terminal. If the patch was successful, the .patch
  17.     file is automatically deleted from the /patches folder and you can review your diff
  18.     and commit the changes. If the patch failed, you need to manually remove the .patch
  19.     file from the /patches folder, copy the error message from the terminal to chatgpt,
  20.     undo/remove the changes from local git diff, inform chatgpt the patch failed, you
  21.     discarded the changes and request for a new patch.
  22.  7: After a successful patch, you repeat the process.
  23.  
  24.  
  25. Default behavior:
  26.  - reads all *.patch files from .\\patches
  27.  - applies operations in filename order
  28.  - writes git diff output to .\\changes.txt
  29.  - deletes consumed *.patch files after successful apply + diff
  30.  
  31. Instructions for the code agent - You can add this to project instructions so you don't
  32. need to add this to your prompt every time.
  33.  
  34. agents should provide updates as:
  35.  
  36. ```text
  37. patches/<descriptive-name>.patch
  38.  
  39. The .patch file must be a JSON patch bundle compatible with the project’s local patch runner:
  40.  
  41. apply_patch_bundle.py
  42.  
  43. The user places one or more .patch files in the repository’s patches/ folder and runs:
  44.  
  45. python .\apply_patch_bundle.py
  46.  
  47. The runner will:
  48.  
  49. Read all patches/*.patch files in sorted order.
  50. Apply each exact-match operation.
  51. Wait briefly for writes to complete.
  52. Run git diff > changes.txt.
  53. Delete the consumed .patch files after successful application.
  54.  
  55. If a patch fails, the runner does not delete the patch files.
  56.  
  57. Important
  58.  
  59. Do not provide fake unified diffs with invalid hunk markers like:
  60.  
  61. @@
  62.  
  63. The project does not use free-form LLM-generated unified diffs for this workflow because malformed hunk ranges are brittle. Instead, use the JSON patch-bundle format below.
  64.  
  65. Patch bundle format
  66.  
  67. A patch file should contain a JSON object:
  68.  
  69. {
  70.  "name": "short-descriptive-name",
  71.  "description": "Optional human-readable summary of the patch",
  72.  "operations": [
  73.    {
  74.      "type": "replace_once",
  75.      "path": "relative/path/to/file.ts",
  76.      "old": "exact old text",
  77.      "new": "exact new text"
  78.    }
  79.  ]
  80. }
  81.  
  82. All paths must be relative to the repository root. Do not use absolute paths. Do not use .. path traversal.
  83.  
  84. Supported operations
  85. replace_once
  86.  
  87. Use when one exact block should be replaced exactly once.
  88.  
  89. {
  90.  "type": "replace_once",
  91.  "path": "lib/example.ts",
  92.  "old": "const oldValue = true;",
  93.  "new": "const oldValue = false;"
  94. }
  95.  
  96. The runner fails if old appears zero times or more than once.
  97.  
  98. replace_all
  99.  
  100. Use only when replacing all occurrences is intentional.
  101.  
  102. {
  103.  "type": "replace_all",
  104.  "path": "lib/example.ts",
  105.  "old": "OldName",
  106.  "new": "NewName",
  107.  "expected_replacements": 3
  108. }
  109.  
  110. Prefer including expected_replacements so the patch fails safely if the file differs.
  111.  
  112. insert_after
  113.  
  114. Use when inserting content after an exact anchor.
  115.  
  116. {
  117.  "type": "insert_after",
  118.  "path": "lib/example.ts",
  119.  "anchor": "export const existing = true;\n",
  120.  "content": "\nexport const added = true;\n"
  121. }
  122.  
  123. By default, the anchor must appear exactly once. Use expected_matches only when intentionally inserting after multiple identical anchors.
  124.  
  125. insert_before
  126.  
  127. Use when inserting content before an exact anchor.
  128.  
  129. {
  130.  "type": "insert_before",
  131.  "path": "lib/example.ts",
  132.  "anchor": "export const existing = true;\n",
  133.  "content": "export const added = true;\n\n"
  134. }
  135. create_file
  136.  
  137. Use for new files.
  138.  
  139. {
  140.  "type": "create_file",
  141.  "path": "tests/newFeature.test.ts",
  142.  "content": "import { test, expect } from \"vitest\";\n\ntest(\"example\", () => {\n  expect(true).toBe(true);\n});\n"
  143. }
  144.  
  145. If replacing an existing file intentionally, add:
  146.  
  147. "overwrite": true
  148. delete_file
  149.  
  150. Use for deleting files.
  151.  
  152. {
  153.  "type": "delete_file",
  154.  "path": "tests/obsolete.test.ts"
  155. }
  156.  
  157. If it is acceptable that the file is already missing, add:
  158.  
  159. "missing_ok": true
  160. Agent requirements when producing patches
  161.  
  162. When asked to update files, agents should:
  163.  
  164. Inspect the latest repository state with @Github tool or use the latest files supplied by the user.
  165. Prefer a downloadable .patch JSON bundle.
  166. Use exact, minimal replacements.
  167. Avoid broad rewrites unless explicitly requested.
  168. Keep changes small and grouped by logical phase.
  169. Include tests in the patch when behavior changes.
  170. Include a concise summary of:
  171. files touched;
  172. behavior changed;
  173. tests to run;
  174. relevant rg audit commands.
  175. Do not include generated local patch scripts unless the user asks for tooling changes.
  176. Do not provide full file replacements unless the user explicitly asks for fully updated files/scripts.
  177. Recommended response format for agents
  178.  
  179. When providing a patch, include:
  180.  
  181. Download patch:
  182. [descriptive filename](sandbox:/mnt/data/<filename>.patch)
  183.  
  184.  
  185. Why JSON instead of real unified diffs:
  186.  Git's unified patch format is brittle when generated by LLMs because hunk ranges
  187.  must be exact. This runner uses explicit exact-match operations instead. It
  188.  fails safely if anchors are missing, duplicated unexpectedly, or files differ.
  189. """
  190.  
  191. from __future__ import annotations
  192.  
  193. import argparse
  194. import json
  195. import subprocess
  196. import sys
  197. import time
  198. from dataclasses import dataclass
  199. from pathlib import Path
  200. from typing import Any
  201.  
  202.  
  203. DEFAULT_PATCH_DIR = Path("patches")
  204. DEFAULT_DIFF_OUTPUT = Path("changes.txt")
  205. DEFAULT_SLEEP_SECONDS = 0.25
  206.  
  207.  
  208. class PatchError(RuntimeError):
  209.     pass
  210.  
  211.  
  212. @dataclass(frozen=True)
  213. class PatchContext:
  214.     repo_root: Path
  215.     dry_run: bool
  216.     verbose: bool
  217.  
  218.  
  219. def log(message: str) -> None:
  220.     print(message)
  221.  
  222.  
  223. def normalize_newlines(text: str) -> str:
  224.     # Keep writes deterministic and avoid CRLF/LF mismatch in exact-match bundles.
  225.     return text.replace("\r\n", "\n").replace("\r", "\n")
  226.  
  227.  
  228. def read_text(path: Path) -> str:
  229.     return normalize_newlines(path.read_text(encoding="utf-8"))
  230.  
  231.  
  232. def write_text(path: Path, content: str, dry_run: bool) -> None:
  233.     if dry_run:
  234.         return
  235.     path.parent.mkdir(parents=True, exist_ok=True)
  236.     path.write_text(normalize_newlines(content), encoding="utf-8", newline="\n")
  237.  
  238.  
  239. def require_relative_repo_path(path_value: str) -> Path:
  240.     if not isinstance(path_value, str) or not path_value.strip():
  241.         raise PatchError("Operation path must be a non-empty string.")
  242.  
  243.     path = Path(path_value.replace("\\", "/"))
  244.     if path.is_absolute():
  245.         raise PatchError(f"Absolute paths are not allowed in patch operations: {path_value}")
  246.  
  247.     if any(part == ".." for part in path.parts):
  248.         raise PatchError(f"Parent directory traversal is not allowed in patch operations: {path_value}")
  249.  
  250.     return path
  251.  
  252.  
  253. def get_required_string(operation: dict[str, Any], key: str) -> str:
  254.     value = operation.get(key)
  255.     if not isinstance(value, str):
  256.         raise PatchError(f"Operation {operation.get('type')!r} requires string field {key!r}.")
  257.     return normalize_newlines(value)
  258.  
  259.  
  260. def get_optional_int(operation: dict[str, Any], key: str) -> int | None:
  261.     value = operation.get(key)
  262.     if value is None:
  263.         return None
  264.     if not isinstance(value, int) or value < 0:
  265.         raise PatchError(f"Operation {operation.get('type')!r} field {key!r} must be a non-negative integer.")
  266.     return value
  267.  
  268.  
  269. def replace_once(content: str, old: str, new: str, op_label: str) -> tuple[str, int]:
  270.     count = content.count(old)
  271.     if count != 1:
  272.         raise PatchError(f"{op_label}: expected exactly one match, found {count}.")
  273.     return content.replace(old, new, 1), 1
  274.  
  275.  
  276. def replace_all(content: str, old: str, new: str, expected: int | None, op_label: str) -> tuple[str, int]:
  277.     count = content.count(old)
  278.     if count == 0:
  279.         raise PatchError(f"{op_label}: expected at least one match, found 0.")
  280.     if expected is not None and count != expected:
  281.         raise PatchError(f"{op_label}: expected {expected} match(es), found {count}.")
  282.     return content.replace(old, new), count
  283.  
  284.  
  285. def insert_relative(
  286.     content: str,
  287.     anchor: str,
  288.     inserted: str,
  289.     *,
  290.     before: bool,
  291.     expected_matches: int | None,
  292.     op_label: str,
  293. ) -> tuple[str, int]:
  294.     count = content.count(anchor)
  295.     expected = expected_matches if expected_matches is not None else 1
  296.     if count != expected:
  297.         raise PatchError(f"{op_label}: expected {expected} anchor match(es), found {count}.")
  298.  
  299.     replacement = f"{inserted}{anchor}" if before else f"{anchor}{inserted}"
  300.     return content.replace(anchor, replacement, expected), expected
  301.  
  302.  
  303. def apply_file_operation(ctx: PatchContext, operation: dict[str, Any], index: int) -> int:
  304.     op_type = operation.get("type")
  305.     path = ctx.repo_root / require_relative_repo_path(operation.get("path", ""))
  306.  
  307.     op_label = f"operation #{index} ({op_type}) on {path.relative_to(ctx.repo_root)}"
  308.  
  309.     if op_type == "create_file":
  310.         content = get_required_string(operation, "content")
  311.         overwrite = operation.get("overwrite", False)
  312.         if path.exists() and not overwrite:
  313.             raise PatchError(f"{op_label}: file already exists. Set overwrite=true to replace it.")
  314.         write_text(path, content, ctx.dry_run)
  315.         return 1
  316.  
  317.     if op_type == "delete_file":
  318.         if not path.exists():
  319.             missing_ok = operation.get("missing_ok", False)
  320.             if missing_ok:
  321.                 return 0
  322.             raise PatchError(f"{op_label}: file does not exist.")
  323.         if not ctx.dry_run:
  324.             path.unlink()
  325.         return 1
  326.  
  327.     if not path.exists():
  328.         raise PatchError(f"{op_label}: file does not exist.")
  329.  
  330.     content = read_text(path)
  331.  
  332.     if op_type == "replace_once":
  333.         old = get_required_string(operation, "old")
  334.         new = get_required_string(operation, "new")
  335.         updated, count = replace_once(content, old, new, op_label)
  336.         write_text(path, updated, ctx.dry_run)
  337.         return count
  338.  
  339.     if op_type == "replace_all":
  340.         old = get_required_string(operation, "old")
  341.         new = get_required_string(operation, "new")
  342.         expected = get_optional_int(operation, "expected_replacements")
  343.         updated, count = replace_all(content, old, new, expected, op_label)
  344.         write_text(path, updated, ctx.dry_run)
  345.         return count
  346.  
  347.     if op_type == "insert_after":
  348.         anchor = get_required_string(operation, "anchor")
  349.         inserted = get_required_string(operation, "content")
  350.         expected = get_optional_int(operation, "expected_matches")
  351.         updated, count = insert_relative(
  352.             content,
  353.             anchor,
  354.             inserted,
  355.             before=False,
  356.             expected_matches=expected,
  357.             op_label=op_label,
  358.         )
  359.         write_text(path, updated, ctx.dry_run)
  360.         return count
  361.  
  362.     if op_type == "insert_before":
  363.         anchor = get_required_string(operation, "anchor")
  364.         inserted = get_required_string(operation, "content")
  365.         expected = get_optional_int(operation, "expected_matches")
  366.         updated, count = insert_relative(
  367.             content,
  368.             anchor,
  369.             inserted,
  370.             before=True,
  371.             expected_matches=expected,
  372.             op_label=op_label,
  373.         )
  374.         write_text(path, updated, ctx.dry_run)
  375.         return count
  376.  
  377.     raise PatchError(f"{op_label}: unsupported operation type {op_type!r}.")
  378.  
  379.  
  380. def load_patch_file(path: Path) -> dict[str, Any]:
  381.     try:
  382.         payload = json.loads(path.read_text(encoding="utf-8"))
  383.     except json.JSONDecodeError as exc:
  384.         raise PatchError(f"{path}: invalid JSON: {exc}") from exc
  385.  
  386.     if not isinstance(payload, dict):
  387.         raise PatchError(f"{path}: patch file must contain a JSON object.")
  388.  
  389.     operations = payload.get("operations")
  390.     if not isinstance(operations, list):
  391.         raise PatchError(f"{path}: patch file must contain an operations array.")
  392.  
  393.     for idx, op in enumerate(operations, start=1):
  394.         if not isinstance(op, dict):
  395.             raise PatchError(f"{path}: operation #{idx} must be an object.")
  396.  
  397.     return payload
  398.  
  399.  
  400. def run_git_diff(repo_root: Path, output_path: Path) -> None:
  401.     result = subprocess.run(
  402.         ["git", "diff"],
  403.         cwd=repo_root,
  404.         text=True,
  405.         encoding="utf-8",
  406.         errors="replace",
  407.         stdout=subprocess.PIPE,
  408.         stderr=subprocess.PIPE,
  409.         check=False,
  410.     )
  411.     if result.returncode != 0:
  412.         raise PatchError(f"git diff failed:\n{(result.stderr or '').strip()}")
  413.  
  414.     output_path.write_text(result.stdout or "", encoding="utf-8", newline="\n")
  415.  
  416.  
  417. def main() -> int:
  418.     parser = argparse.ArgumentParser(description="Apply JSON patch bundles from patches/*.patch.")
  419.     parser.add_argument("--patch-dir", type=Path, default=DEFAULT_PATCH_DIR)
  420.     parser.add_argument("--diff-output", type=Path, default=DEFAULT_DIFF_OUTPUT)
  421.     parser.add_argument("--sleep", type=float, default=DEFAULT_SLEEP_SECONDS)
  422.     parser.add_argument("--dry-run", action="store_true", help="Validate and simulate without writing files.")
  423.     parser.add_argument("--keep-patches", action="store_true", help="Do not delete consumed patch files.")
  424.     parser.add_argument("--verbose", action="store_true")
  425.     args = parser.parse_args()
  426.  
  427.     repo_root = Path.cwd()
  428.     patch_dir = args.patch_dir
  429.     diff_output = args.diff_output
  430.  
  431.     if not (repo_root / ".git").exists():
  432.         raise SystemExit("Run this script from the repository root.")
  433.  
  434.     if not patch_dir.exists():
  435.         patch_dir.mkdir(parents=True, exist_ok=True)
  436.  
  437.     patch_files = sorted(patch_dir.glob("*.patch"))
  438.     if not patch_files:
  439.         log(f"No *.patch files found in {patch_dir}")
  440.         return 0
  441.  
  442.     ctx = PatchContext(repo_root=repo_root, dry_run=args.dry_run, verbose=args.verbose)
  443.  
  444.     total_ops = 0
  445.     total_changes = 0
  446.  
  447.     try:
  448.         for patch_file in patch_files:
  449.             payload = load_patch_file(patch_file)
  450.             name = payload.get("name") or patch_file.name
  451.             log(f"Applying patch bundle: {name}")
  452.  
  453.             operations = payload["operations"]
  454.             for index, operation in enumerate(operations, start=1):
  455.                 count = apply_file_operation(ctx, operation, index)
  456.                 total_ops += 1
  457.                 total_changes += count
  458.                 if args.verbose:
  459.                     log(f"  operation #{index}: {operation.get('type')} -> {count}")
  460.  
  461.         if args.dry_run:
  462.             log(f"Dry run OK. Would apply {total_ops} operation(s), {total_changes} change(s).")
  463.             return 0
  464.  
  465.         time.sleep(max(0.0, args.sleep))
  466.         run_git_diff(repo_root, diff_output)
  467.         log(f"Wrote git diff to {diff_output}")
  468.  
  469.         if not args.keep_patches:
  470.             for patch_file in patch_files:
  471.                 patch_file.unlink()
  472.             log(f"Deleted {len(patch_files)} consumed patch file(s) from {patch_dir}")
  473.  
  474.         log(f"Applied {total_ops} operation(s), {total_changes} change(s).")
  475.         return 0
  476.  
  477.     except PatchError as exc:
  478.         print(f"Patch failed: {exc}", file=sys.stderr)
  479.         print("No patch files were deleted.", file=sys.stderr)
  480.         return 1
  481.  
  482.  
  483. if __name__ == "__main__":
  484.     raise SystemExit(main())
  485.  
Advertisement
Add Comment
Please, Sign In to add comment