From ee60098f6b18248f3622fa0aa7dd6be5b516ba4e Mon Sep 17 00:00:00 2001 From: Cannon07 Date: Mon, 20 Apr 2026 02:13:02 +0530 Subject: [PATCH 1/2] feat: add ApplyPatch support for OpenCode Handle the custom patch format (*** Begin Patch / *** Update File / *** Add File / *** Delete File) used by models like GPT 5.4 in OpenCode. Adds apply-patch.lua to parse the format and compute per-file diffs, wires it into core-pre-tool.sh and core-post-tool.sh, and updates the OpenCode adapter to pass patch_text from both field name variants. Co-Authored-By: Claude Opus 4.6 --- README.md | 6 +- backends/opencode/index.ts | 3 +- bin/apply-patch.lua | 254 +++++++++++++++++++++++++++++++++++++ bin/core-post-tool.sh | 33 +++++ bin/core-pre-tool.sh | 66 +++++++++- 5 files changed, 359 insertions(+), 3 deletions(-) create mode 100644 bin/apply-patch.lua diff --git a/README.md b/README.md index 4aad6e9..e4a37d0 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,7 @@ All options with defaults: ```lua require("code-preview").setup({ + debug = false, -- enable debug logging to stdpath("log")/code-preview.log diff = { layout = "tab", -- "tab" (new tab) | "vsplit" (current tab) | "inline" (GitHub-style) labels = { current = "CURRENT", proposed = "PROPOSED" }, @@ -281,6 +282,7 @@ code-preview.nvim/ ├── lua/code-preview/ │ ├── init.lua setup(), config, commands │ ├── diff.lua show_diff(), close_diff() +│ ├── log.lua opt-in debug logging │ ├── changes.lua change status registry (modified/created/deleted) │ ├── neo_tree.lua neo-tree integration (icons, virtual nodes, reveal) │ ├── health.lua :checkhealth (both backends) @@ -293,7 +295,8 @@ code-preview.nvim/ │ ├── nvim-socket.sh Neovim socket discovery │ ├── nvim-send.sh RPC send helper │ ├── apply-edit.lua Single Edit transformer -│ └── apply-multi-edit.lua MultiEdit transformer +│ ├── apply-multi-edit.lua MultiEdit transformer +│ └── apply-patch.lua ApplyPatch transformer (custom patch format) ├── backends/ │ ├── claudecode/ Claude Code adapter │ │ ├── code-preview-diff.sh PreToolUse hook entry point @@ -339,6 +342,7 @@ vim.api.nvim_create_autocmd({ "FocusGained", "BufEnter", "CursorHold" }, { **Diff doesn't open** - Run `:CodePreviewStatus` — check that `Neovim socket` is found - Run `:checkhealth code-preview` — check for missing dependencies +- Enable debug logging (`debug = true` in setup) and check `~/.local/state/nvim/code-preview.log` - Restart the CLI agent after installing hooks (hooks are read at startup) **Claude Code hooks not firing** diff --git a/backends/opencode/index.ts b/backends/opencode/index.ts index 96c3959..62e68a6 100644 --- a/backends/opencode/index.ts +++ b/backends/opencode/index.ts @@ -72,8 +72,9 @@ function toNormalizedJson( // Bash fields if (args.command !== undefined) toolInput.command = args.command - // ApplyPatch fields + // ApplyPatch fields — handle both possible field names from different models if (args.patchText !== undefined) toolInput.patch_text = args.patchText + if (args.patch !== undefined) toolInput.patch_text = args.patch return JSON.stringify({ tool_name: toolName, cwd, tool_input: toolInput }) } diff --git a/bin/apply-patch.lua b/bin/apply-patch.lua new file mode 100644 index 0000000..14686ee --- /dev/null +++ b/bin/apply-patch.lua @@ -0,0 +1,254 @@ +#!/usr/bin/env -S nvim --headless -l +--- apply-patch.lua — Parse custom patch format and produce per-file original/proposed pairs +--- +--- Usage: nvim --headless -l apply-patch.lua +--- +--- Reads the patch text from a JSON file ({"patch_text": "..."}), parses the +--- custom patch format used by OpenCode/GPT models: +--- +--- *** Begin Patch +--- *** Update File: path/to/file +--- @@ +--- -old line +--- +new line +--- context line +--- *** End Patch +--- +--- Writes per-file results to output_dir: +--- /files.json — list of {path, orig, prop} objects +--- /-orig — original content +--- /-prop — proposed content + +local patch_json_path = arg[1] +local cwd = arg[2] +local output_dir = arg[3] + +if not patch_json_path or not cwd or not output_dir then + io.stderr:write("Usage: nvim --headless -l apply-patch.lua \n") + vim.cmd("cquit! 1") + return +end + +-- Read patch text from JSON file +local f = io.open(patch_json_path, "r") +if not f then + io.stderr:write("Cannot open patch JSON: " .. patch_json_path .. "\n") + vim.cmd("cquit! 1") + return +end +local json_str = f:read("*a") +f:close() + +local ok, data = pcall(vim.json.decode, json_str) +if not ok or not data.patch_text then + io.stderr:write("Invalid patch JSON or missing patch_text\n") + vim.cmd("cquit! 1") + return +end + +local patch_text = data.patch_text + +-- Parse the custom patch format into file sections +local files = {} +local current_file = nil +local current_action = nil -- "update", "add", "delete" + +for line in (patch_text .. "\n"):gmatch("([^\n]*)\n") do + local update_path = line:match("^%*%*%* Update File:%s*(.+)$") + local add_path = line:match("^%*%*%* Add File:%s*(.+)$") + local delete_path = line:match("^%*%*%* Delete File:%s*(.+)$") + + if update_path then + current_file = { path = update_path:gsub("%s+$", ""), action = "update", hunks = {}, current_hunk = nil } + table.insert(files, current_file) + current_action = "update" + elseif add_path then + current_file = { path = add_path:gsub("%s+$", ""), action = "add", hunks = {}, current_hunk = nil } + table.insert(files, current_file) + current_action = "add" + elseif delete_path then + current_file = { path = delete_path:gsub("%s+$", ""), action = "delete", hunks = {}, current_hunk = nil } + table.insert(files, current_file) + current_action = "delete" + elseif line:match("^@@") and current_file then + -- Start a new hunk + current_file.current_hunk = { lines = {} } + table.insert(current_file.hunks, current_file.current_hunk) + elseif line == "*** End Patch" or line == "*** Begin Patch" then + current_file = nil + elseif current_file and current_file.current_hunk then + table.insert(current_file.current_hunk.lines, line) + end +end + +-- Resolve file path relative to cwd +local function resolve_path(path) + if path:sub(1, 1) == "/" then + return path + end + return cwd .. "/" .. path +end + +-- Read file content as lines +local function read_lines(path) + local fh = io.open(path, "r") + if not fh then + return {} + end + local lines = {} + for line in fh:lines() do + table.insert(lines, line) + end + fh:close() + return lines +end + +-- Apply hunks to original lines to produce proposed lines +local function apply_hunks(orig_lines, hunks) + if #hunks == 0 then + return orig_lines + end + + local result = {} + local orig_idx = 1 + + for _, hunk in ipairs(hunks) do + -- Each hunk has context lines (space-prefixed), removals (-), additions (+) + -- Context lines help us find position in the original file + + -- First, find where this hunk starts in the original by matching context + local hunk_lines = hunk.lines + + -- Collect the context/remove pattern to locate position + local match_lines = {} + for _, hl in ipairs(hunk_lines) do + local prefix = hl:sub(1, 1) + if prefix == " " then + table.insert(match_lines, { type = "context", text = hl:sub(2) }) + elseif prefix == "-" then + table.insert(match_lines, { type = "remove", text = hl:sub(2) }) + elseif prefix == "+" then + table.insert(match_lines, { type = "add", text = hl:sub(2) }) + else + -- Lines without a recognized prefix are treated as context + table.insert(match_lines, { type = "context", text = hl }) + end + end + + -- Find the start position by matching context/remove lines against original + local first_match_text = nil + for _, ml in ipairs(match_lines) do + if ml.type == "context" or ml.type == "remove" then + first_match_text = ml.text + break + end + end + + if first_match_text then + -- Advance orig_idx to find the matching line + while orig_idx <= #orig_lines do + if orig_lines[orig_idx] == first_match_text then + break + end + -- Copy non-matching lines to result (they're before this hunk) + table.insert(result, orig_lines[orig_idx]) + orig_idx = orig_idx + 1 + end + end + + -- Apply the hunk + for _, ml in ipairs(match_lines) do + if ml.type == "context" then + table.insert(result, ml.text) + orig_idx = orig_idx + 1 + elseif ml.type == "remove" then + -- Skip original line (don't add to result) + orig_idx = orig_idx + 1 + elseif ml.type == "add" then + table.insert(result, ml.text) + -- Don't advance orig_idx + end + end + end + + -- Copy remaining original lines + while orig_idx <= #orig_lines do + table.insert(result, orig_lines[orig_idx]) + orig_idx = orig_idx + 1 + end + + return result +end + +-- Write lines to a file +local function write_lines(path, lines) + local fh = io.open(path, "w") + if not fh then + return false + end + for i, line in ipairs(lines) do + fh:write(line) + if i < #lines then + fh:write("\n") + end + end + -- Add trailing newline + if #lines > 0 then + fh:write("\n") + end + fh:close() + return true +end + +-- Process each file section +local results = {} +for i, file_section in ipairs(files) do + local abs_path = resolve_path(file_section.path) + local tag = string.format("%02d", i) + local orig_out = output_dir .. "/" .. tag .. "-orig" + local prop_out = output_dir .. "/" .. tag .. "-prop" + + if file_section.action == "delete" then + local orig_lines = read_lines(abs_path) + write_lines(orig_out, orig_lines) + write_lines(prop_out, {}) + elseif file_section.action == "add" then + write_lines(orig_out, {}) + -- For add, all hunk lines should be additions + local new_lines = {} + for _, hunk in ipairs(file_section.hunks) do + for _, hl in ipairs(hunk.lines) do + if hl:sub(1, 1) == "+" then + table.insert(new_lines, hl:sub(2)) + elseif hl:sub(1, 1) ~= "-" then + -- Bare lines (no prefix) in add mode are content + table.insert(new_lines, hl) + end + end + end + write_lines(prop_out, new_lines) + else -- update + local orig_lines = read_lines(abs_path) + write_lines(orig_out, orig_lines) + local proposed = apply_hunks(orig_lines, file_section.hunks) + write_lines(prop_out, proposed) + end + + table.insert(results, { + path = abs_path, + rel_path = file_section.path, + action = file_section.action, + orig = orig_out, + prop = prop_out, + }) +end + +-- Write the file list as JSON +local results_path = output_dir .. "/files.json" +local rf = io.open(results_path, "w") +if rf then + rf:write(vim.json.encode(results)) + rf:close() +end + +vim.cmd("qall!") diff --git a/bin/core-post-tool.sh b/bin/core-post-tool.sh index 6c407ce..067d71b 100755 --- a/bin/core-post-tool.sh +++ b/bin/core-post-tool.sh @@ -42,6 +42,39 @@ if [[ "$TOOL_NAME" == "Bash" ]]; then exit 0 fi +# ApplyPatch: extract file paths from patch_text and close each diff +if [[ "$TOOL_NAME" == "ApplyPatch" ]]; then + PATCH_TEXT="$(echo "$INPUT" | jq -r '.tool_input.patch_text // empty' 2>/dev/null || true)" + CWD_POST="$(echo "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)" + if [[ -n "$PATCH_TEXT" ]]; then + # Extract paths from both standard unified diff (+++ lines) and + # custom patch format (*** Update File: / *** Add File: lines) + extract_patch_paths() { + echo "$1" | grep -E '^\+\+\+ ' | while IFS= read -r line; do + fpath="${line#+++ }" + fpath="${fpath#b/}" + [[ "$fpath" == "/dev/null" ]] && continue + echo "$fpath" + done + echo "$1" | grep -E '^\*\*\* (Update|Add) File:' | while IFS= read -r line; do + echo "$line" | sed -E 's/^\*\*\* (Update|Add) File:[[:space:]]*//' | sed 's/[[:space:]]*$//' + done + } + + while IFS= read -r fpath; do + [[ -z "$fpath" ]] && continue + if [[ "$fpath" != /* && -n "$CWD_POST" ]]; then + fpath="$CWD_POST/$fpath" + fi + fpath_esc="$(escape_lua "$fpath")" + log_post "closing diff for patch file=$fpath" + nvim_send "require('code-preview.diff').close_for_file('$fpath_esc')" || true + done < <(extract_patch_paths "$PATCH_TEXT") + fi + rm -f "${TMPDIR:-/tmp}"/claude-diff-original* "${TMPDIR:-/tmp}"/claude-diff-proposed* "${TMPDIR:-/tmp}"/claude-patch-* + exit 0 +fi + # Extract file path early — needed for tagged is_open() check FILE_PATH="$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null || true)" FILE_PATH_ESC="$(escape_lua "${FILE_PATH:-}")" diff --git a/bin/core-pre-tool.sh b/bin/core-pre-tool.sh index c4531e4..c8ec18b 100755 --- a/bin/core-pre-tool.sh +++ b/bin/core-pre-tool.sh @@ -150,7 +150,71 @@ case "$TOOL_NAME" in ;; ApplyPatch) - # Stub for V1 — skip diff preview (matches current OpenCode behavior) + PATCH_TEXT="$(echo "$INPUT" | jq -r '.tool_input.patch_text // empty')" + if [[ -z "$PATCH_TEXT" ]]; then + log_pre "ApplyPatch: empty patch_text, exiting" + exit 0 + fi + log_pre "ApplyPatch: received patch (${#PATCH_TEXT} chars)" + + # Write patch JSON to a temp file for the Lua parser + PATCH_JSON="$TMPDIR/claude-patch-input-$HOOK_ID.json" + echo "$INPUT" | jq '{patch_text: .tool_input.patch_text}' > "$PATCH_JSON" + + PATCH_OUTDIR="$TMPDIR/claude-patch-out-$HOOK_ID" + mkdir -p "$PATCH_OUTDIR" + + # Parse the custom patch format and compute per-file original/proposed + log_pre "ApplyPatch: running apply-patch.lua" + NVIM_LISTEN_ADDRESS= nvim --headless -l "$SCRIPT_DIR/apply-patch.lua" "$PATCH_JSON" "$CWD" "$PATCH_OUTDIR" 2>/dev/null || true + + RESULTS_FILE="$PATCH_OUTDIR/files.json" + if [[ ! -f "$RESULTS_FILE" ]]; then + log_pre "ApplyPatch: apply-patch.lua produced no results" + rm -f "$PATCH_JSON" + rm -rf "$PATCH_OUTDIR" + exit 0 + fi + + # Read results and send each file's diff to nvim + FILE_COUNT=$(jq 'length' "$RESULTS_FILE") + log_pre "ApplyPatch: parsed $FILE_COUNT file(s)" + + for i in $(seq 0 $((FILE_COUNT - 1))); do + PATCH_FILE_PATH=$(jq -r ".[$i].path" "$RESULTS_FILE") + REL_PATH=$(jq -r ".[$i].rel_path" "$RESULTS_FILE") + ACTION=$(jq -r ".[$i].action" "$RESULTS_FILE") + PATCH_ORIG=$(jq -r ".[$i].orig" "$RESULTS_FILE") + PATCH_PROP=$(jq -r ".[$i].prop" "$RESULTS_FILE") + + log_pre "ApplyPatch: file=$REL_PATH action=$ACTION" + + if [[ "$HAS_NVIM" == "true" ]]; then + display_esc="$(escape_lua "$REL_PATH")" + orig_esc="$(escape_lua "$PATCH_ORIG")" + prop_esc="$(escape_lua "$PATCH_PROP")" + fpath_esc="$(escape_lua "$PATCH_FILE_PATH")" + + HOOK_CTX=$(nvim --server "$NVIM_SOCKET" --remote-expr "luaeval(\"require('code-preview').hook_context('${fpath_esc}')\")" 2>/dev/null || echo '{}') + VISIBLE_ONLY=$(echo "$HOOK_CTX" | jq -r '.visible_only // false') + FILE_VISIBLE=$(echo "$HOOK_CTX" | jq -r '.file_visible // false') + + SHOULD_SHOW="1" + if [[ "$VISIBLE_ONLY" == "true" && "$FILE_VISIBLE" != "true" ]]; then + SHOULD_SHOW="0" + log_pre "ApplyPatch: skipping diff for $REL_PATH (visible_only)" + fi + + if [[ "$SHOULD_SHOW" == "1" ]]; then + log_pre "ApplyPatch: sending diff for $REL_PATH to nvim" + nvim_send "require('code-preview.diff').show_diff('$orig_esc', '$prop_esc', '$display_esc', '$fpath_esc')" || true + fi + else + log_pre "ApplyPatch: no nvim connection, skipping diff for $REL_PATH" + fi + done + + rm -f "$PATCH_JSON" exit 0 ;; From 071926da05fd28de23bf9358c9521fc3ee05f78b Mon Sep 17 00:00:00 2001 From: Cannon07 Date: Mon, 20 Apr 2026 02:32:11 +0530 Subject: [PATCH 2/2] test: add unit tests for apply-patch.lua custom patch parser Tests the *** Begin Patch format parser directly via nvim --headless, covering update, add, delete, multi-file, and multi-hunk scenarios. Co-Authored-By: Claude Opus 4.6 --- tests/backends/opencode/test_apply_patch.sh | 252 ++++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 tests/backends/opencode/test_apply_patch.sh diff --git a/tests/backends/opencode/test_apply_patch.sh b/tests/backends/opencode/test_apply_patch.sh new file mode 100644 index 0000000..a474473 --- /dev/null +++ b/tests/backends/opencode/test_apply_patch.sh @@ -0,0 +1,252 @@ +#!/usr/bin/env bash +# test_apply_patch.sh — Tests for apply-patch.lua custom patch format parser +# +# Exercises the Lua parser directly (nvim --headless -l) without the +# OpenCode TypeScript harness. Verifies that the *** Begin Patch / *** Update +# File / *** Add File / *** Delete File format is correctly parsed and +# per-file original/proposed pairs are computed. + +APPLY_PATCH="$REPO_ROOT/bin/apply-patch.lua" + +# ── Setup ──────────────────────────────────────────────────────── + +setup_test_project +TEST_OUTDIR="$(mktemp -d /tmp/code-preview-patch-test.XXXXXX)" + +# Helper: run apply-patch.lua with a patch string, return output dir +run_apply_patch() { + local patch_text="$1" + local outdir="$TEST_OUTDIR/run-$$-$RANDOM" + mkdir -p "$outdir" + + local patch_json="$outdir/input.json" + # Use jq to properly escape the patch text into JSON + jq -n --arg pt "$patch_text" '{patch_text: $pt}' > "$patch_json" + + NVIM_LISTEN_ADDRESS= nvim --headless --clean -l "$APPLY_PATCH" "$patch_json" "$TEST_PROJECT_DIR" "$outdir" 2>/dev/null + + echo "$outdir" +} + +# ── Test: Update existing file ─────────────────────────────────── + +test_patch_update_file() { + create_test_file "hello.txt" "line one +line two +line three" >/dev/null + + local patch + patch=$(printf '%s\n' \ + "*** Begin Patch" \ + "*** Update File: hello.txt" \ + "@@" \ + " line one" \ + "-line two" \ + "+line two modified" \ + " line three" \ + "*** End Patch") + + local outdir + outdir="$(run_apply_patch "$patch")" + + assert_file_exists "$outdir/files.json" "files.json should exist" || return 1 + + local count + count=$(jq 'length' "$outdir/files.json") + assert_eq "1" "$count" "should have 1 file entry" || return 1 + + local action + action=$(jq -r '.[0].action' "$outdir/files.json") + assert_eq "update" "$action" "action should be 'update'" || return 1 + + local rel_path + rel_path=$(jq -r '.[0].rel_path' "$outdir/files.json") + assert_eq "hello.txt" "$rel_path" "rel_path should be hello.txt" || return 1 + + # Check proposed content has the modification + local prop_file + prop_file=$(jq -r '.[0].prop' "$outdir/files.json") + local prop_content + prop_content="$(cat "$prop_file")" + assert_contains "$prop_content" "line two modified" "proposed should contain modified line" || return 1 + assert_not_contains "$prop_content" $'\nline two\n' "proposed should not contain original line two" || return 1 + + # Check original content is preserved + local orig_file + orig_file=$(jq -r '.[0].orig' "$outdir/files.json") + local orig_content + orig_content="$(cat "$orig_file")" + assert_contains "$orig_content" "line two" "original should contain unmodified line" || return 1 +} + +# ── Test: Add new file ─────────────────────────────────────────── + +test_patch_add_file() { + local patch + patch=$(printf '%s\n' \ + "*** Begin Patch" \ + "*** Add File: src/new_file.lua" \ + "@@" \ + "+local M = {}" \ + "+return M" \ + "*** End Patch") + + local outdir + outdir="$(run_apply_patch "$patch")" + + assert_file_exists "$outdir/files.json" "files.json should exist" || return 1 + + local action + action=$(jq -r '.[0].action' "$outdir/files.json") + assert_eq "add" "$action" "action should be 'add'" || return 1 + + # Original should be empty for new files + local orig_file + orig_file=$(jq -r '.[0].orig' "$outdir/files.json") + local orig_size + orig_size=$(wc -c < "$orig_file" | tr -d ' ') + assert_eq "0" "$orig_size" "original should be empty for new file" || return 1 + + # Proposed should contain the new content + local prop_file + prop_file=$(jq -r '.[0].prop' "$outdir/files.json") + local prop_content + prop_content="$(cat "$prop_file")" + assert_contains "$prop_content" "local M = {}" "proposed should have first line" || return 1 + assert_contains "$prop_content" "return M" "proposed should have second line" || return 1 +} + +# ── Test: Delete file ──────────────────────────────────────────── + +test_patch_delete_file() { + create_test_file "to_delete.txt" "some content here" >/dev/null + + local patch + patch=$(printf '%s\n' \ + "*** Begin Patch" \ + "*** Delete File: to_delete.txt" \ + "*** End Patch") + + local outdir + outdir="$(run_apply_patch "$patch")" + + assert_file_exists "$outdir/files.json" "files.json should exist" || return 1 + + local action + action=$(jq -r '.[0].action' "$outdir/files.json") + assert_eq "delete" "$action" "action should be 'delete'" || return 1 + + # Original should have the file content + local orig_file + orig_file=$(jq -r '.[0].orig' "$outdir/files.json") + local orig_content + orig_content="$(cat "$orig_file")" + assert_contains "$orig_content" "some content here" "original should have file content" || return 1 + + # Proposed should be empty + local prop_file + prop_file=$(jq -r '.[0].prop' "$outdir/files.json") + local prop_size + prop_size=$(wc -c < "$prop_file" | tr -d ' ') + assert_eq "0" "$prop_size" "proposed should be empty for deleted file" || return 1 +} + +# ── Test: Multi-file patch ─────────────────────────────────────── + +test_patch_multi_file() { + create_test_file "file_a.txt" "alpha +beta +gamma" >/dev/null + + local patch + patch=$(printf '%s\n' \ + "*** Begin Patch" \ + "*** Update File: file_a.txt" \ + "@@" \ + " alpha" \ + "-beta" \ + "+beta updated" \ + " gamma" \ + "*** Add File: file_b.txt" \ + "@@" \ + "+new file content" \ + "*** End Patch") + + local outdir + outdir="$(run_apply_patch "$patch")" + + assert_file_exists "$outdir/files.json" "files.json should exist" || return 1 + + local count + count=$(jq 'length' "$outdir/files.json") + assert_eq "2" "$count" "should have 2 file entries" || return 1 + + local action0 action1 + action0=$(jq -r '.[0].action' "$outdir/files.json") + action1=$(jq -r '.[1].action' "$outdir/files.json") + assert_eq "update" "$action0" "first file should be update" || return 1 + assert_eq "add" "$action1" "second file should be add" || return 1 + + # Verify update content + local prop0 + prop0=$(jq -r '.[0].prop' "$outdir/files.json") + assert_contains "$(cat "$prop0")" "beta updated" "first file proposed should have modification" || return 1 + + # Verify add content + local prop1 + prop1=$(jq -r '.[1].prop' "$outdir/files.json") + assert_contains "$(cat "$prop1")" "new file content" "second file proposed should have new content" || return 1 +} + +# ── Test: Multiple hunks in same file ──────────────────────────── + +test_patch_multiple_hunks() { + create_test_file "multi_hunk.txt" "line 1 +line 2 +line 3 +line 4 +line 5 +line 6" >/dev/null + + local patch + patch=$(printf '%s\n' \ + "*** Begin Patch" \ + "*** Update File: multi_hunk.txt" \ + "@@" \ + " line 1" \ + "-line 2" \ + "+line 2 changed" \ + " line 3" \ + "@@" \ + " line 5" \ + "-line 6" \ + "+line 6 changed" \ + "*** End Patch") + + local outdir + outdir="$(run_apply_patch "$patch")" + + assert_file_exists "$outdir/files.json" "files.json should exist" || return 1 + + local prop_file + prop_file=$(jq -r '.[0].prop' "$outdir/files.json") + local prop_content + prop_content="$(cat "$prop_file")" + + assert_contains "$prop_content" "line 2 changed" "proposed should have first hunk change" || return 1 + assert_contains "$prop_content" "line 6 changed" "proposed should have second hunk change" || return 1 + assert_contains "$prop_content" "line 4" "proposed should preserve lines between hunks" || return 1 +} + +# ── Run all tests ──────────────────────────────────────────────── + +run_test "apply-patch.lua parses Update File correctly" test_patch_update_file +run_test "apply-patch.lua parses Add File correctly" test_patch_add_file +run_test "apply-patch.lua parses Delete File correctly" test_patch_delete_file +run_test "apply-patch.lua handles multi-file patches" test_patch_multi_file +run_test "apply-patch.lua handles multiple hunks in same file" test_patch_multiple_hunks + +# ── Teardown ───────────────────────────────────────────────────── + +rm -rf "$TEST_OUTDIR" +cleanup_test_project