Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- #!/usr/bin/env python3
- """
- apply_patch_bundle.py
- Reusable exact-match patch runner for local patch bundles.
- Usage:
- 1: Place this script in the root of your project and name it apply_patch_bundle.py.
- 2: Create a folder named "patches" in the root of your project.
- 3: Upload your source code to chatgpt as a zip file or connect chatgpt to Github and
- use @GitHub in your prompt and point to the repository name and branch. You can
- then request chatgpt to provide a json patch to update/fix whatever you want.
- Chatgpt will provide a json patch file like "update_button.patch".
- 4: You download the .patch file and place it in the /patches folder.
- 5: Then run: python .\apply_patch_bundle.py
- 6: You can see the results in the terminal. If the patch was successful, the .patch
- file is automatically deleted from the /patches folder and you can review your diff
- and commit the changes. If the patch failed, you need to manually remove the .patch
- file from the /patches folder, copy the error message from the terminal to chatgpt,
- undo/remove the changes from local git diff, inform chatgpt the patch failed, you
- discarded the changes and request for a new patch.
- 7: After a successful patch, you repeat the process.
- Default behavior:
- - reads all *.patch files from .\\patches
- - applies operations in filename order
- - writes git diff output to .\\changes.txt
- - deletes consumed *.patch files after successful apply + diff
- Instructions for the code agent - You can add this to project instructions so you don't
- need to add this to your prompt every time.
- agents should provide updates as:
- ```text
- patches/<descriptive-name>.patch
- The .patch file must be a JSON patch bundle compatible with the project’s local patch runner:
- apply_patch_bundle.py
- The user places one or more .patch files in the repository’s patches/ folder and runs:
- python .\apply_patch_bundle.py
- The runner will:
- Read all patches/*.patch files in sorted order.
- Apply each exact-match operation.
- Wait briefly for writes to complete.
- Run git diff > changes.txt.
- Delete the consumed .patch files after successful application.
- If a patch fails, the runner does not delete the patch files.
- Important
- Do not provide fake unified diffs with invalid hunk markers like:
- @@
- 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.
- Patch bundle format
- A patch file should contain a JSON object:
- {
- "name": "short-descriptive-name",
- "description": "Optional human-readable summary of the patch",
- "operations": [
- {
- "type": "replace_once",
- "path": "relative/path/to/file.ts",
- "old": "exact old text",
- "new": "exact new text"
- }
- ]
- }
- All paths must be relative to the repository root. Do not use absolute paths. Do not use .. path traversal.
- Supported operations
- replace_once
- Use when one exact block should be replaced exactly once.
- {
- "type": "replace_once",
- "path": "lib/example.ts",
- "old": "const oldValue = true;",
- "new": "const oldValue = false;"
- }
- The runner fails if old appears zero times or more than once.
- replace_all
- Use only when replacing all occurrences is intentional.
- {
- "type": "replace_all",
- "path": "lib/example.ts",
- "old": "OldName",
- "new": "NewName",
- "expected_replacements": 3
- }
- Prefer including expected_replacements so the patch fails safely if the file differs.
- insert_after
- Use when inserting content after an exact anchor.
- {
- "type": "insert_after",
- "path": "lib/example.ts",
- "anchor": "export const existing = true;\n",
- "content": "\nexport const added = true;\n"
- }
- By default, the anchor must appear exactly once. Use expected_matches only when intentionally inserting after multiple identical anchors.
- insert_before
- Use when inserting content before an exact anchor.
- {
- "type": "insert_before",
- "path": "lib/example.ts",
- "anchor": "export const existing = true;\n",
- "content": "export const added = true;\n\n"
- }
- create_file
- Use for new files.
- {
- "type": "create_file",
- "path": "tests/newFeature.test.ts",
- "content": "import { test, expect } from \"vitest\";\n\ntest(\"example\", () => {\n expect(true).toBe(true);\n});\n"
- }
- If replacing an existing file intentionally, add:
- "overwrite": true
- delete_file
- Use for deleting files.
- {
- "type": "delete_file",
- "path": "tests/obsolete.test.ts"
- }
- If it is acceptable that the file is already missing, add:
- "missing_ok": true
- Agent requirements when producing patches
- When asked to update files, agents should:
- Inspect the latest repository state with @Github tool or use the latest files supplied by the user.
- Prefer a downloadable .patch JSON bundle.
- Use exact, minimal replacements.
- Avoid broad rewrites unless explicitly requested.
- Keep changes small and grouped by logical phase.
- Include tests in the patch when behavior changes.
- Include a concise summary of:
- files touched;
- behavior changed;
- tests to run;
- relevant rg audit commands.
- Do not include generated local patch scripts unless the user asks for tooling changes.
- Do not provide full file replacements unless the user explicitly asks for fully updated files/scripts.
- Recommended response format for agents
- When providing a patch, include:
- Download patch:
- [descriptive filename](sandbox:/mnt/data/<filename>.patch)
- Why JSON instead of real unified diffs:
- Git's unified patch format is brittle when generated by LLMs because hunk ranges
- must be exact. This runner uses explicit exact-match operations instead. It
- fails safely if anchors are missing, duplicated unexpectedly, or files differ.
- """
- from __future__ import annotations
- import argparse
- import json
- import subprocess
- import sys
- import time
- from dataclasses import dataclass
- from pathlib import Path
- from typing import Any
- DEFAULT_PATCH_DIR = Path("patches")
- DEFAULT_DIFF_OUTPUT = Path("changes.txt")
- DEFAULT_SLEEP_SECONDS = 0.25
- class PatchError(RuntimeError):
- pass
- @dataclass(frozen=True)
- class PatchContext:
- repo_root: Path
- dry_run: bool
- verbose: bool
- def log(message: str) -> None:
- print(message)
- def normalize_newlines(text: str) -> str:
- # Keep writes deterministic and avoid CRLF/LF mismatch in exact-match bundles.
- return text.replace("\r\n", "\n").replace("\r", "\n")
- def read_text(path: Path) -> str:
- return normalize_newlines(path.read_text(encoding="utf-8"))
- def write_text(path: Path, content: str, dry_run: bool) -> None:
- if dry_run:
- return
- path.parent.mkdir(parents=True, exist_ok=True)
- path.write_text(normalize_newlines(content), encoding="utf-8", newline="\n")
- def require_relative_repo_path(path_value: str) -> Path:
- if not isinstance(path_value, str) or not path_value.strip():
- raise PatchError("Operation path must be a non-empty string.")
- path = Path(path_value.replace("\\", "/"))
- if path.is_absolute():
- raise PatchError(f"Absolute paths are not allowed in patch operations: {path_value}")
- if any(part == ".." for part in path.parts):
- raise PatchError(f"Parent directory traversal is not allowed in patch operations: {path_value}")
- return path
- def get_required_string(operation: dict[str, Any], key: str) -> str:
- value = operation.get(key)
- if not isinstance(value, str):
- raise PatchError(f"Operation {operation.get('type')!r} requires string field {key!r}.")
- return normalize_newlines(value)
- def get_optional_int(operation: dict[str, Any], key: str) -> int | None:
- value = operation.get(key)
- if value is None:
- return None
- if not isinstance(value, int) or value < 0:
- raise PatchError(f"Operation {operation.get('type')!r} field {key!r} must be a non-negative integer.")
- return value
- def replace_once(content: str, old: str, new: str, op_label: str) -> tuple[str, int]:
- count = content.count(old)
- if count != 1:
- raise PatchError(f"{op_label}: expected exactly one match, found {count}.")
- return content.replace(old, new, 1), 1
- def replace_all(content: str, old: str, new: str, expected: int | None, op_label: str) -> tuple[str, int]:
- count = content.count(old)
- if count == 0:
- raise PatchError(f"{op_label}: expected at least one match, found 0.")
- if expected is not None and count != expected:
- raise PatchError(f"{op_label}: expected {expected} match(es), found {count}.")
- return content.replace(old, new), count
- def insert_relative(
- content: str,
- anchor: str,
- inserted: str,
- *,
- before: bool,
- expected_matches: int | None,
- op_label: str,
- ) -> tuple[str, int]:
- count = content.count(anchor)
- expected = expected_matches if expected_matches is not None else 1
- if count != expected:
- raise PatchError(f"{op_label}: expected {expected} anchor match(es), found {count}.")
- replacement = f"{inserted}{anchor}" if before else f"{anchor}{inserted}"
- return content.replace(anchor, replacement, expected), expected
- def apply_file_operation(ctx: PatchContext, operation: dict[str, Any], index: int) -> int:
- op_type = operation.get("type")
- path = ctx.repo_root / require_relative_repo_path(operation.get("path", ""))
- op_label = f"operation #{index} ({op_type}) on {path.relative_to(ctx.repo_root)}"
- if op_type == "create_file":
- content = get_required_string(operation, "content")
- overwrite = operation.get("overwrite", False)
- if path.exists() and not overwrite:
- raise PatchError(f"{op_label}: file already exists. Set overwrite=true to replace it.")
- write_text(path, content, ctx.dry_run)
- return 1
- if op_type == "delete_file":
- if not path.exists():
- missing_ok = operation.get("missing_ok", False)
- if missing_ok:
- return 0
- raise PatchError(f"{op_label}: file does not exist.")
- if not ctx.dry_run:
- path.unlink()
- return 1
- if not path.exists():
- raise PatchError(f"{op_label}: file does not exist.")
- content = read_text(path)
- if op_type == "replace_once":
- old = get_required_string(operation, "old")
- new = get_required_string(operation, "new")
- updated, count = replace_once(content, old, new, op_label)
- write_text(path, updated, ctx.dry_run)
- return count
- if op_type == "replace_all":
- old = get_required_string(operation, "old")
- new = get_required_string(operation, "new")
- expected = get_optional_int(operation, "expected_replacements")
- updated, count = replace_all(content, old, new, expected, op_label)
- write_text(path, updated, ctx.dry_run)
- return count
- if op_type == "insert_after":
- anchor = get_required_string(operation, "anchor")
- inserted = get_required_string(operation, "content")
- expected = get_optional_int(operation, "expected_matches")
- updated, count = insert_relative(
- content,
- anchor,
- inserted,
- before=False,
- expected_matches=expected,
- op_label=op_label,
- )
- write_text(path, updated, ctx.dry_run)
- return count
- if op_type == "insert_before":
- anchor = get_required_string(operation, "anchor")
- inserted = get_required_string(operation, "content")
- expected = get_optional_int(operation, "expected_matches")
- updated, count = insert_relative(
- content,
- anchor,
- inserted,
- before=True,
- expected_matches=expected,
- op_label=op_label,
- )
- write_text(path, updated, ctx.dry_run)
- return count
- raise PatchError(f"{op_label}: unsupported operation type {op_type!r}.")
- def load_patch_file(path: Path) -> dict[str, Any]:
- try:
- payload = json.loads(path.read_text(encoding="utf-8"))
- except json.JSONDecodeError as exc:
- raise PatchError(f"{path}: invalid JSON: {exc}") from exc
- if not isinstance(payload, dict):
- raise PatchError(f"{path}: patch file must contain a JSON object.")
- operations = payload.get("operations")
- if not isinstance(operations, list):
- raise PatchError(f"{path}: patch file must contain an operations array.")
- for idx, op in enumerate(operations, start=1):
- if not isinstance(op, dict):
- raise PatchError(f"{path}: operation #{idx} must be an object.")
- return payload
- def run_git_diff(repo_root: Path, output_path: Path) -> None:
- result = subprocess.run(
- ["git", "diff"],
- cwd=repo_root,
- text=True,
- encoding="utf-8",
- errors="replace",
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- check=False,
- )
- if result.returncode != 0:
- raise PatchError(f"git diff failed:\n{(result.stderr or '').strip()}")
- output_path.write_text(result.stdout or "", encoding="utf-8", newline="\n")
- def main() -> int:
- parser = argparse.ArgumentParser(description="Apply JSON patch bundles from patches/*.patch.")
- parser.add_argument("--patch-dir", type=Path, default=DEFAULT_PATCH_DIR)
- parser.add_argument("--diff-output", type=Path, default=DEFAULT_DIFF_OUTPUT)
- parser.add_argument("--sleep", type=float, default=DEFAULT_SLEEP_SECONDS)
- parser.add_argument("--dry-run", action="store_true", help="Validate and simulate without writing files.")
- parser.add_argument("--keep-patches", action="store_true", help="Do not delete consumed patch files.")
- parser.add_argument("--verbose", action="store_true")
- args = parser.parse_args()
- repo_root = Path.cwd()
- patch_dir = args.patch_dir
- diff_output = args.diff_output
- if not (repo_root / ".git").exists():
- raise SystemExit("Run this script from the repository root.")
- if not patch_dir.exists():
- patch_dir.mkdir(parents=True, exist_ok=True)
- patch_files = sorted(patch_dir.glob("*.patch"))
- if not patch_files:
- log(f"No *.patch files found in {patch_dir}")
- return 0
- ctx = PatchContext(repo_root=repo_root, dry_run=args.dry_run, verbose=args.verbose)
- total_ops = 0
- total_changes = 0
- try:
- for patch_file in patch_files:
- payload = load_patch_file(patch_file)
- name = payload.get("name") or patch_file.name
- log(f"Applying patch bundle: {name}")
- operations = payload["operations"]
- for index, operation in enumerate(operations, start=1):
- count = apply_file_operation(ctx, operation, index)
- total_ops += 1
- total_changes += count
- if args.verbose:
- log(f" operation #{index}: {operation.get('type')} -> {count}")
- if args.dry_run:
- log(f"Dry run OK. Would apply {total_ops} operation(s), {total_changes} change(s).")
- return 0
- time.sleep(max(0.0, args.sleep))
- run_git_diff(repo_root, diff_output)
- log(f"Wrote git diff to {diff_output}")
- if not args.keep_patches:
- for patch_file in patch_files:
- patch_file.unlink()
- log(f"Deleted {len(patch_files)} consumed patch file(s) from {patch_dir}")
- log(f"Applied {total_ops} operation(s), {total_changes} change(s).")
- return 0
- except PatchError as exc:
- print(f"Patch failed: {exc}", file=sys.stderr)
- print("No patch files were deleted.", file=sys.stderr)
- return 1
- if __name__ == "__main__":
- raise SystemExit(main())
Advertisement
Add Comment
Please, Sign In to add comment