diff --git a/.gitignore b/.gitignore index 4a7e10c..cba10a8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,8 @@ # OpenCode local plugins (installed by :CodePreviewInstallOpenCodeHooks) .opencode/ +# Copilot CLI local hooks (installed by :CodePreviewInstallCopilotCliHooks) +.github/hooks/code-preview.json + # Test dependencies (plenary.nvim, installed by tests/run_lua.sh) deps/ diff --git a/README.md b/README.md index e4a37d0..5885c25 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,20 @@ A Neovim plugin that shows a **diff preview before your AI coding agent applies any file change** — letting you review exactly what's changing before accepting. -Supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code) and [OpenCode](https://opencode.ai) as backends. +Supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), and [GitHub Copilot CLI](https://github.com/github/copilot-cli) as backends. --- ## Demo ### Claude Code -![Claude Code demo](docs/claude-preview-demo.gif) +![Claude Code demo](docs/code-preview-claudecode.gif) ### OpenCode -![OpenCode demo](docs/claude-preview-opencode.gif) +![OpenCode demo](docs/code-preview-opencode.gif) + +### GitHub Copilot CLI +![GitHub Copilot CLI demo](docs/code-preview-copilot.gif) --- @@ -24,6 +27,7 @@ Supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code) and [Open - [Quick Start](#quick-start) - [Claude Code](#claude-code) - [OpenCode](#opencode) + - [GitHub Copilot CLI](#github-copilot-cli) - [How it works](#how-it-works) - [Configuration](#configuration) - [Commands](#commands) @@ -55,6 +59,10 @@ Supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code) and [Open **For OpenCode backend:** - [OpenCode](https://opencode.ai) >= 1.3.0 +**For GitHub Copilot CLI backend:** +- [GitHub Copilot CLI](https://github.com/github/copilot-cli) (generally available since Feb 2026) +- [jq](https://jqlang.github.io/jq/) — for hook payload translation + --- ## Installation @@ -110,6 +118,18 @@ require("code-preview").setup() 7. Accept/reject in OpenCode; the diff closes automatically on accept 8. If rejected, press `dq` to close the diff manually +### GitHub Copilot CLI + +1. Install the plugin and call `setup()` +2. Open a project in Neovim +3. Run `:CodePreviewInstallCopilotCliHooks` — writes `.github/hooks/code-preview.json` +4. Start Copilot CLI in the project directory +5. Ask Copilot to edit a file — a diff opens automatically in Neovim +6. Accept/reject in the CLI; the diff closes automatically on accept +7. If rejected, press `dq` to close the diff manually + +> **Note:** Copilot CLI does not fire post-tool hooks on rejection, so rejected diffs remain open until you dismiss them (same as Claude Code). + --- ## How it works @@ -132,7 +152,9 @@ AI Agent (terminal) Neovim **OpenCode** uses a TypeScript plugin (`tool.execute.before`/`tool.execute.after`) loaded from `.opencode/plugins/`. -Both backends communicate with Neovim via RPC (`nvim --server --remote-send`). +**GitHub Copilot CLI** uses shell-based hooks (`preToolUse`/`postToolUse`) configured in `.github/hooks/code-preview.json`. The adapter translates Copilot's tool vocabulary (`apply_patch`, `edit`, `create`, `bash`) into the same normalized format used by the other backends. + +All backends communicate with Neovim via RPC (`nvim --server --remote-send`). --- @@ -184,6 +206,8 @@ require("code-preview").setup({ | `:CodePreviewUninstallClaudeCodeHooks` | Remove Claude Code hooks (leaves other hooks intact) | | `:CodePreviewInstallOpenCodeHooks` | Install OpenCode plugin to `.opencode/plugins/` | | `:CodePreviewUninstallOpenCodeHooks` | Remove OpenCode plugin | +| `:CodePreviewInstallCopilotCliHooks` | Install Copilot CLI hooks to `.github/hooks/code-preview.json` | +| `:CodePreviewUninstallCopilotCliHooks` | Remove Copilot CLI hooks | | `:CodePreviewCloseDiff` | Manually close the diff (use after rejecting a change) | | `:CodePreviewStatus` | Show socket path, hook status, and dependency check | | `:CodePreviewToggleVisibleOnly` | Toggle visible_only — show diffs only for open buffers | @@ -230,7 +254,7 @@ require("code-preview").setup({ If you use [neo-tree.nvim](https://github.com/nvim-neo-tree/neo-tree.nvim), code-preview will automatically decorate your file tree with visual indicators when changes are proposed. No extra configuration is required — it works out of the box. -![neo-tree integration demo](docs/claude-preview-neotree-integration.gif) +![neo-tree integration demo](docs/code-preview-neotree-integration.gif) ### What you get @@ -301,10 +325,13 @@ code-preview.nvim/ │ ├── claudecode/ Claude Code adapter │ │ ├── code-preview-diff.sh PreToolUse hook entry point │ │ └── code-close-diff.sh PostToolUse hook entry point -│ └── opencode/ OpenCode adapter -│ ├── index.ts tool.execute.before/after hooks -│ ├── package.json -│ └── tsconfig.json +│ ├── opencode/ OpenCode adapter +│ │ ├── index.ts tool.execute.before/after hooks +│ │ ├── package.json +│ │ └── tsconfig.json +│ └── copilot/ GitHub Copilot CLI adapter +│ ├── code-preview-diff.sh preToolUse hook — translates Copilot JSON → core +│ └── code-close-diff.sh postToolUse hook — same for close ``` --- @@ -357,6 +384,12 @@ vim.api.nvim_create_autocmd({ "FocusGained", "BufEnter", "CursorHold" }, { - Ensure `"permission": { "edit": "ask" }` is set in `~/.config/opencode/opencode.json` - Restart OpenCode +**Copilot CLI hooks not firing** +- Run `:CodePreviewInstallCopilotCliHooks` in the project root +- Verify `.github/hooks/code-preview.json` exists +- Ensure `jq` is in PATH +- Restart Copilot CLI (hooks are loaded at session start) + **Diff doesn't close after rejecting** - Press `dq` or run `:CodePreviewCloseDiff` — the post hook only fires on accept diff --git a/backends/copilot/code-close-diff.sh b/backends/copilot/code-close-diff.sh new file mode 100755 index 0000000..66dc30b --- /dev/null +++ b/backends/copilot/code-close-diff.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# code-close-diff.sh — PostToolUse hook adapter for GitHub Copilot CLI. +# +# Mirrors the translation in code-preview-diff.sh and delegates to +# bin/core-post-tool.sh. Only the fields core-post-tool.sh reads are +# populated (tool_name, cwd, file_path or patch_text). + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +BIN_DIR="$SCRIPT_DIR/../../bin" +export CODE_PREVIEW_BACKEND="copilot" + +INPUT="$(cat)" + +TOOL="$(printf '%s' "$INPUT" | jq -r '.toolName // ""')" +CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // ""')" + +case "$TOOL" in + ""|view|glob|grep|ls|report_intent) exit 0 ;; +esac + +# Logging — gated on `debug = true` in setup(). +log() { :; } +# shellcheck source=/dev/null +source "$BIN_DIR/nvim-socket.sh" "$CWD" 2>/dev/null || true +# shellcheck source=/dev/null +source "$BIN_DIR/nvim-send.sh" 2>/dev/null || true +if [[ -n "${NVIM_SOCKET:-}" ]]; then + _CTX=$(nvim --server "$NVIM_SOCKET" --remote-expr "luaeval(\"vim.json.encode({debug=require('code-preview.log').is_enabled(),log_file=require('code-preview.log').get_log_path() or ''})\")" 2>/dev/null || echo '{}') + _DBG=$(echo "$_CTX" | jq -r '.debug // false' 2>/dev/null) + _LOG=$(echo "$_CTX" | jq -r '.log_file // ""' 2>/dev/null) + if [[ "$_DBG" == "true" && -n "$_LOG" ]]; then + log() { printf '[%s] [INFO] copilot/post: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$_LOG"; } + fi +fi + +log "tool=$TOOL" + +RAW_ARGS="$(printf '%s' "$INPUT" | jq -r '.toolArgs // "" | if type == "string" then . else tojson end')" + +# Bind the key as data via --arg, not interpolated into the jq program. +# Supports single-key lookup only (no dotted paths) — all current callers +# pass a single field like `.path`, `.command`, etc. +arg() { printf '%s' "$RAW_ARGS" | jq -r --arg k "${1#.}" '.[$k] // ""'; } + +resolve_path() { + local p="$1" + if [[ -z "$p" ]]; then printf ''; return; fi + if [[ "$p" != /* ]]; then printf '%s/%s' "$CWD" "$p"; else printf '%s' "$p"; fi +} + +case "$TOOL" in + apply_patch) + NORMALIZED="$(jq -n --arg cwd "$CWD" --arg patch "$RAW_ARGS" \ + '{tool_name:"ApplyPatch", cwd:$cwd, tool_input:{patch_text:$patch}}')" + ;; + + edit|str_replace) + FP="$(resolve_path "$(arg .path)")" + NORMALIZED="$(jq -n --arg cwd "$CWD" --arg fp "$FP" \ + '{tool_name:"Edit", cwd:$cwd, tool_input:{file_path:$fp}}')" + ;; + + create|write) + FP="$(resolve_path "$(arg .path)")" + NORMALIZED="$(jq -n --arg cwd "$CWD" --arg fp "$FP" \ + '{tool_name:"Write", cwd:$cwd, tool_input:{file_path:$fp}}')" + ;; + + bash) + CMD="$(arg .command)" + NORMALIZED="$(jq -n --arg cwd "$CWD" --arg cmd "$CMD" \ + '{tool_name:"Bash", cwd:$cwd, tool_input:{command:$cmd}}')" + ;; + + *) + log "unhandled tool=$TOOL — exiting" + exit 0 + ;; +esac + +log "translated tool=$TOOL → closing" + +printf '%s' "$NORMALIZED" | "$BIN_DIR/core-post-tool.sh" diff --git a/backends/copilot/code-preview-diff.sh b/backends/copilot/code-preview-diff.sh new file mode 100755 index 0000000..fdf59b3 --- /dev/null +++ b/backends/copilot/code-preview-diff.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# code-preview-diff.sh — PreToolUse hook adapter for GitHub Copilot CLI. +# +# Translates Copilot's hook payload (stdin JSON with toolName/toolArgs) into +# the normalized {tool_name, cwd, tool_input} format consumed by +# bin/core-pre-tool.sh, then delegates to it. +# +# Field mapping: +# apply_patch → ApplyPatch (toolArgs is raw patch text) +# edit/str_replace → Edit ({path, old_str, new_str}) +# create/write → Write ({path, file_text | content}) +# bash → Bash ({command, description}) +# view/glob/... → ignored +# +# Note: toolArgs is a JSON-encoded string in preToolUse and an object in +# postToolUse; we normalize both to a string so downstream parsing is uniform. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +BIN_DIR="$SCRIPT_DIR/../../bin" +export CODE_PREVIEW_BACKEND="copilot" + +INPUT="$(cat)" + +TOOL="$(printf '%s' "$INPUT" | jq -r '.toolName // ""')" +CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // ""')" + +# Noise tools never produce a preview — bail out before the expensive +# socket/log-setup RPC so the log stays clean. +case "$TOOL" in + ""|view|glob|grep|ls|report_intent) exit 0 ;; +esac + +# Logging — mirrors core-pre-tool.sh. Gated on `debug = true` in setup(). +log() { :; } +# shellcheck source=/dev/null +source "$BIN_DIR/nvim-socket.sh" "$CWD" 2>/dev/null || true +# shellcheck source=/dev/null +source "$BIN_DIR/nvim-send.sh" 2>/dev/null || true +_NVIM_SERVERNAME="" +_NVIM_CWD="" +if [[ -n "${NVIM_SOCKET:-}" ]]; then + _CTX=$(nvim --server "$NVIM_SOCKET" --remote-expr "luaeval(\"vim.json.encode({debug=require('code-preview.log').is_enabled(),log_file=require('code-preview.log').get_log_path() or '',servername=vim.v.servername,cwd=vim.fn.getcwd()})\")" 2>/dev/null || echo '{}') + _DBG=$(echo "$_CTX" | jq -r '.debug // false' 2>/dev/null) + _LOG=$(echo "$_CTX" | jq -r '.log_file // ""' 2>/dev/null) + _NVIM_SERVERNAME=$(echo "$_CTX" | jq -r '.servername // ""' 2>/dev/null) + _NVIM_CWD=$(echo "$_CTX" | jq -r '.cwd // ""' 2>/dev/null) + if [[ "$_DBG" == "true" && -n "$_LOG" ]]; then + log() { printf '[%s] [INFO] copilot/pre: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$_LOG"; } + fi +fi + +log "tool=$TOOL servername=${_NVIM_SERVERNAME:-} nvim_cwd=${_NVIM_CWD:-} hook_cwd=$CWD" + +# Normalize toolArgs to a raw string. For JSON-object tools this becomes the +# stringified JSON; for apply_patch it's the raw patch text. +RAW_ARGS="$(printf '%s' "$INPUT" | jq -r '.toolArgs // "" | if type == "string" then . else tojson end')" + +# Bind the key as data via --arg, not interpolated into the jq program. +# Supports single-key lookup only (no dotted paths) — all current callers +# pass a single field like `.path`, `.command`, etc. +arg() { printf '%s' "$RAW_ARGS" | jq -r --arg k "${1#.}" '.[$k] // ""'; } + +resolve_path() { + local p="$1" + if [[ -z "$p" ]]; then printf ''; return; fi + if [[ "$p" != /* ]]; then printf '%s/%s' "$CWD" "$p"; else printf '%s' "$p"; fi +} + +case "$TOOL" in + apply_patch) + NORMALIZED="$(jq -n --arg cwd "$CWD" --arg patch "$RAW_ARGS" \ + '{tool_name:"ApplyPatch", cwd:$cwd, tool_input:{patch_text:$patch}}')" + ;; + + edit|str_replace) + FP="$(resolve_path "$(arg .path)")" + NORMALIZED="$(jq -n \ + --arg cwd "$CWD" \ + --arg fp "$FP" \ + --arg os "$(arg .old_str)" \ + --arg ns "$(arg .new_str)" \ + '{tool_name:"Edit", cwd:$cwd, + tool_input:{file_path:$fp, old_string:$os, new_string:$ns, replace_all:false}}')" + ;; + + create|write) + FP="$(resolve_path "$(arg .path)")" + # Copilot's create uses file_text; fall back to content for other models. + CONTENT="$(printf '%s' "$RAW_ARGS" | jq -r '.file_text // .content // ""')" + NORMALIZED="$(jq -n --arg cwd "$CWD" --arg fp "$FP" --arg c "$CONTENT" \ + '{tool_name:"Write", cwd:$cwd, tool_input:{file_path:$fp, content:$c}}')" + ;; + + bash) + CMD="$(arg .command)" + NORMALIZED="$(jq -n --arg cwd "$CWD" --arg cmd "$CMD" \ + '{tool_name:"Bash", cwd:$cwd, tool_input:{command:$cmd}}')" + ;; + + *) + log "unhandled tool=$TOOL — exiting" + exit 0 + ;; +esac + +# Guard against malformed payloads (missing toolArgs fields). Sending an +# empty path or command downstream produces a broken/empty diff; a clean +# skip is preferable. apply_patch is already resilient — apply-patch.lua +# parses zero files from an empty patch and exits cleanly. +case "$TOOL" in + edit|str_replace|create|write) + if [[ -z "$FP" ]]; then + log "empty file path for tool=$TOOL — skipping" + exit 0 + fi + ;; + bash) + if [[ -z "$CMD" ]]; then + log "empty command for bash — skipping" + exit 0 + fi + ;; +esac + +log "translated tool=$TOOL → $(printf '%s' "$NORMALIZED" | jq -c '{tool_name, file: .tool_input.file_path // "", has_patch: (.tool_input.patch_text != null)}' 2>/dev/null || echo 'parse-error')" + +printf '%s' "$NORMALIZED" | "$BIN_DIR/core-pre-tool.sh" diff --git a/bin/apply-patch.lua b/bin/apply-patch.lua index 14686ee..eda519e 100644 --- a/bin/apply-patch.lua +++ b/bin/apply-patch.lua @@ -76,8 +76,18 @@ for line in (patch_text .. "\n"):gmatch("([^\n]*)\n") do 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) + elseif current_file then + -- `*** Add File:` in the GPT patch format has no `@@` marker — content + -- lines follow directly. Lazy-create a hunk on the first content line + -- so those lines aren't dropped, without leaving an empty leading hunk + -- when `@@` *is* present. + if not current_file.current_hunk and current_action == "add" then + current_file.current_hunk = { lines = {} } + table.insert(current_file.hunks, current_file.current_hunk) + end + if current_file.current_hunk then + table.insert(current_file.current_hunk.lines, line) + end end end diff --git a/bin/core-post-tool.sh b/bin/core-post-tool.sh index 067d71b..2aa7a18 100755 --- a/bin/core-post-tool.sh +++ b/bin/core-post-tool.sh @@ -9,7 +9,9 @@ # "tool_input": { "file_path": "...", ... } } # # Environment: -# CODE_PREVIEW_BACKEND — "claudecode" or "opencode" (currently unused, reserved) +# CODE_PREVIEW_BACKEND — "claudecode" | "opencode" | "copilot". Not read +# by this script; kept set by adapters for symmetry +# with core-pre-tool.sh, which does gate on it. SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -56,8 +58,8 @@ if [[ "$TOOL_NAME" == "ApplyPatch" ]]; then [[ "$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:]]*$//' + echo "$1" | grep -E '^\*\*\* (Update|Add|Delete) File:' | while IFS= read -r line; do + echo "$line" | sed -E 's/^\*\*\* (Update|Add|Delete) File:[[:space:]]*//' | sed 's/[[:space:]]*$//' done } diff --git a/bin/core-pre-tool.sh b/bin/core-pre-tool.sh index c8ec18b..4b3480f 100755 --- a/bin/core-pre-tool.sh +++ b/bin/core-pre-tool.sh @@ -10,7 +10,9 @@ # "tool_input": { "file_path": "...", ... } } # # Environment: -# CODE_PREVIEW_BACKEND — "claudecode" or "opencode" (gates output format) +# CODE_PREVIEW_BACKEND — "claudecode" | "opencode" | "copilot". Only +# "claudecode" emits the permissionDecision JSON +# on stdout; other values suppress it. set -euo pipefail diff --git a/docs/claude-preview-demo.gif b/docs/code-preview-claudecode.gif similarity index 100% rename from docs/claude-preview-demo.gif rename to docs/code-preview-claudecode.gif diff --git a/docs/code-preview-copilot.gif b/docs/code-preview-copilot.gif new file mode 100644 index 0000000..82022ec Binary files /dev/null and b/docs/code-preview-copilot.gif differ diff --git a/docs/claude-preview-neotree-integration.gif b/docs/code-preview-neotree-integration.gif similarity index 100% rename from docs/claude-preview-neotree-integration.gif rename to docs/code-preview-neotree-integration.gif diff --git a/docs/claude-preview-opencode.gif b/docs/code-preview-opencode.gif similarity index 100% rename from docs/claude-preview-opencode.gif rename to docs/code-preview-opencode.gif diff --git a/lua/code-preview/backends/copilot.lua b/lua/code-preview/backends/copilot.lua new file mode 100644 index 0000000..89c0a75 --- /dev/null +++ b/lua/code-preview/backends/copilot.lua @@ -0,0 +1,88 @@ +local M = {} + +-- Resolve plugin root from this file's location +local function plugin_root() + local src = debug.getinfo(1, "S").source + local lua_file = src:sub(2) + local lua_dir = vim.fn.fnamemodify(lua_file, ":h") + -- Go up three levels: backends/ → code-preview/ → lua/ → plugin root + return vim.fn.fnamemodify(lua_dir, ":h:h:h") +end + +local function scripts_dir() return plugin_root() .. "/backends/copilot" end +local function pre_script() return scripts_dir() .. "/code-preview-diff.sh" end +local function post_script() return scripts_dir() .. "/code-close-diff.sh" end + +local function hooks_dir() return vim.fn.getcwd() .. "/.github/hooks" end +local function config_path() return hooks_dir() .. "/code-preview.json" end + +-- Shell-quote a path for use inside the `bash` field of hooks.json. +local function shquote(s) + return "'" .. s:gsub("'", "'\\''") .. "'" +end + +-- True iff `path` looks like a code-preview.json our installer produced. We +-- match on the pre-tool adapter script name — every install() invocation +-- writes it verbatim, and it's specific enough that user-authored hook +-- files are unlikely to collide. Guards status display and uninstall from +-- misidentifying a user-owned file with the same name. +function M.is_our_config(path) + if vim.fn.filereadable(path) == 0 then return false end + local f = io.open(path, "r") + if not f then return false end + local content = f:read("*a") + f:close() + return content and content:find("code-preview-diff.sh", 1, true) ~= nil +end + +local function ensure_executable(path) + if vim.fn.filereadable(path) == 0 then + vim.notify("[code-preview] script not found: " .. path, vim.log.levels.ERROR) + return false + end + vim.fn.system({ "chmod", "+x", path }) + return true +end + +function M.install() + local pre, post = pre_script(), post_script() + if not (ensure_executable(pre) and ensure_executable(post)) then return end + + vim.fn.mkdir(hooks_dir(), "p") + + local data = { + version = 1, + hooks = { + preToolUse = { { type = "command", bash = shquote(pre), timeoutSec = 30 } }, + postToolUse = { { type = "command", bash = shquote(post), timeoutSec = 30 } }, + }, + } + + local path = config_path() + local f = assert(io.open(path, "w"), "Cannot write to " .. path) + f:write(vim.json.encode(data)) + f:close() + + vim.notify("[code-preview] Copilot CLI hooks installed → " .. path, vim.log.levels.INFO) +end + +function M.uninstall() + local path = config_path() + if vim.fn.filereadable(path) == 0 then + vim.notify("[code-preview] No Copilot hooks found at " .. path, vim.log.levels.WARN) + return + end + if not M.is_our_config(path) then + vim.notify( + "[code-preview] Refusing to remove " .. path .. ": not produced by code-preview install. Delete it manually if intentional.", + vim.log.levels.WARN + ) + return + end + vim.fn.delete(path) + -- Try to prune the hooks dir if it became empty (don't touch parents). + pcall(vim.fn.delete, hooks_dir(), "d") + vim.notify("[code-preview] Copilot CLI hooks uninstalled", vim.log.levels.INFO) +end + +return M diff --git a/lua/code-preview/health.lua b/lua/code-preview/health.lua index d2696b5..c23322a 100644 --- a/lua/code-preview/health.lua +++ b/lua/code-preview/health.lua @@ -132,6 +132,38 @@ function M.check() else warn("OpenCode plugin not installed — run :CodePreviewInstallOpenCodeHooks") end + + -- ── Copilot CLI backend ─────────────────────────────────────── + + start("GitHub Copilot CLI backend") + + -- copilot binary + if vim.fn.executable("copilot") == 1 then + ok("copilot CLI is available in PATH") + else + warn("copilot not found in PATH (install from https://github.com/github/copilot-cli)") + end + + -- Adapter scripts + local copilot_dir = plugin_root .. "/backends/copilot" + for _, script in ipairs({ "code-preview-diff.sh", "code-close-diff.sh" }) do + local path = copilot_dir .. "/" .. script + if vim.fn.filereadable(path) == 1 and vim.fn.executable(path) == 1 then + ok(script .. " is executable") + elseif vim.fn.filereadable(path) == 1 then + warn(script .. " exists but is not executable (run: chmod +x " .. path .. ")") + else + error(script .. " not found at " .. path) + end + end + + -- hooks.json installed + local copilot_hooks = vim.fn.getcwd() .. "/.github/hooks/code-preview.json" + if vim.fn.filereadable(copilot_hooks) == 1 then + ok("Copilot CLI hooks are installed (.github/hooks/code-preview.json)") + else + warn("Copilot CLI hooks not installed — run :CodePreviewInstallCopilotCliHooks") + end end return M diff --git a/lua/code-preview/init.lua b/lua/code-preview/init.lua index 55c1352..23f4d59 100644 --- a/lua/code-preview/init.lua +++ b/lua/code-preview/init.lua @@ -102,6 +102,14 @@ function M.setup(user_config) require("code-preview.backends.opencode").uninstall() end, { desc = "Uninstall code-preview plugin from OpenCode" }) + vim.api.nvim_create_user_command("CodePreviewInstallCopilotCliHooks", function() + require("code-preview.backends.copilot").install() + end, { desc = "Install code-preview hooks for GitHub Copilot CLI" }) + + vim.api.nvim_create_user_command("CodePreviewUninstallCopilotCliHooks", function() + require("code-preview.backends.copilot").uninstall() + end, { desc = "Uninstall code-preview hooks for GitHub Copilot CLI" }) + vim.api.nvim_create_user_command("CodePreviewCloseDiff", function() require("code-preview.diff").close_diff_and_clear() end, { desc = "Manually close code-preview diff (use after rejecting a change)" }) @@ -229,6 +237,17 @@ function M.status() table.insert(lines, " OpenCode : not installed -> :CodePreviewInstallOpenCodeHooks") end + -- Copilot CLI — check file contents, not just existence, so a user-authored + -- hook file that happens to share the name isn't reported as "installed". + local copilot_ok = require("code-preview.backends.copilot").is_our_config( + vim.fn.getcwd() .. "/.github/hooks/code-preview.json" + ) + if copilot_ok then + table.insert(lines, " Copilot CLI : installed") + else + table.insert(lines, " Copilot CLI : not installed -> :CodePreviewInstallCopilotCliHooks") + end + vim.notify(table.concat(lines, "\n"), vim.log.levels.INFO, { title = "code-preview" }) end diff --git a/tests/backends/copilot/test_apply_patch.sh b/tests/backends/copilot/test_apply_patch.sh new file mode 100644 index 0000000..641ad8c --- /dev/null +++ b/tests/backends/copilot/test_apply_patch.sh @@ -0,0 +1,194 @@ +#!/usr/bin/env bash +# test_apply_patch.sh — E2E tests for Copilot CLI apply_patch workflow +# +# Drives the full pipeline for GPT-style apply_patch tool calls: +# raw patch text as toolArgs → backends/copilot/code-preview-diff.sh +# → bin/core-pre-tool.sh → bin/apply-patch.lua +# → Neovim diff previews for all files in the patch +# And the mirror post path: +# → backends/copilot/code-close-diff.sh +# → bin/core-post-tool.sh +# → close_for_file for every Update/Add/Delete directive. +# +# Distinct from tests/backends/opencode/test_apply_patch.sh, which exercises +# only bin/apply-patch.lua in isolation (parser-level). + +COPILOT_PRE="$REPO_ROOT/backends/copilot/code-preview-diff.sh" +COPILOT_POST="$REPO_ROOT/backends/copilot/code-close-diff.sh" + +# apply_patch's toolArgs is the raw patch text, not a JSON object. jq will +# still encode it as a JSON string when we build the outer payload, and the +# adapter's `if type == "string"` branch passes it through untouched. +run_copilot_pre_patch() { + local patch_text="$1" + local payload + payload=$(jq -n \ + --arg cwd "$TEST_PROJECT_DIR" \ + --arg ta "$patch_text" \ + '{toolName:"apply_patch", cwd:$cwd, toolArgs:$ta}') + echo "$payload" | \ + NVIM_LISTEN_ADDRESS="$TEST_SOCKET" \ + bash "$COPILOT_PRE" 2>/dev/null || true +} + +run_copilot_post_patch() { + local patch_text="$1" + local payload + payload=$(jq -n \ + --arg cwd "$TEST_PROJECT_DIR" \ + --arg ta "$patch_text" \ + '{toolName:"apply_patch", cwd:$cwd, toolArgs:$ta}') + echo "$payload" | \ + NVIM_LISTEN_ADDRESS="$TEST_SOCKET" \ + bash "$COPILOT_POST" 2>/dev/null || true +} + +# ── Setup ──────────────────────────────────────────────────────── + +setup_test_project +start_nvim + +# ── Test: single-file update via apply_patch ──────────────────── + +test_copilot_apply_patch_update() { + reset_test_state + local test_file + test_file="$(create_test_file "hello.txt" "line one +line two +line three")" + + local patch + patch=$(printf '%s\n' \ + "*** Begin Patch" \ + "*** Update File: hello.txt" \ + "@@" \ + " line one" \ + "-line two" \ + "+line two modified" \ + " line three" \ + "*** End Patch") + + run_copilot_pre_patch "$patch" + sleep 0.5 + + local is_open + is_open="$(nvim_eval "require('code-preview.diff').is_open()")" + assert_eq "true" "$is_open" "diff should open after apply_patch update" || return 1 + + local change_status + change_status="$(nvim_eval "require('code-preview.changes').get('$test_file')")" + assert_eq "modified" "$change_status" "file should be marked as modified" || return 1 + + run_copilot_post_patch "$patch" + sleep 0.5 + + local is_open_after + is_open_after="$(nvim_eval "require('code-preview.diff').is_open()")" + assert_eq "false" "$is_open_after" "diff should close after post-hook" || return 1 +} + +# ── Test: apply_patch with Add File marks as created ──────────── + +test_copilot_apply_patch_add() { + reset_test_state + local new_file="$TEST_PROJECT_DIR/src/new.lua" + + local patch + patch=$(printf '%s\n' \ + "*** Begin Patch" \ + "*** Add File: src/new.lua" \ + "+local M = {}" \ + "+return M" \ + "*** End Patch") + + run_copilot_pre_patch "$patch" + sleep 0.5 + + local change_status + change_status="$(nvim_eval "require('code-preview.changes').get('$new_file')")" + assert_eq "created" "$change_status" "Add File should mark as created" || return 1 + + run_copilot_post_patch "$patch" + sleep 0.5 + + local is_open_after + is_open_after="$(nvim_eval "require('code-preview.diff').is_open()")" + assert_eq "false" "$is_open_after" "diff should close after Add File post-hook" || return 1 + + local changes_count + changes_count="$(nvim_eval "vim.tbl_count(require('code-preview.changes').get_all())")" + assert_eq "0" "$changes_count" "changes registry should be empty after Add File cycle" || return 1 +} + +# ── Test: mixed Update+Add+Delete — all open, all close ───────── + +# This is the integration-level twin of tests/core/test_post_tool_patch_paths.sh. +# The core test stubs close_for_file; here we drive the full pipeline and +# confirm the real diff/changes state lines up end-to-end. +test_copilot_apply_patch_mixed() { + reset_test_state + + local f_update f_delete1 f_delete2 f_add + f_update="$(create_test_file "README.md" "existing line +old text +tail")" + f_delete1="$(create_test_file "old1.txt" "bye1")" + f_delete2="$(create_test_file "old2.txt" "bye2")" + f_add="$TEST_PROJECT_DIR/brand_new.txt" + + local patch + patch=$(printf '%s\n' \ + "*** Begin Patch" \ + "*** Update File: README.md" \ + "@@" \ + " existing line" \ + "-old text" \ + "+new text" \ + " tail" \ + "*** Add File: brand_new.txt" \ + "+hello from new file" \ + "*** Delete File: old1.txt" \ + "*** Delete File: old2.txt" \ + "*** End Patch") + + run_copilot_pre_patch "$patch" + sleep 0.6 + + # All four files should appear in the changes registry. + local changes_count + changes_count="$(nvim_eval "vim.tbl_count(require('code-preview.changes').get_all())")" + assert_eq "4" "$changes_count" "4 files should be tracked after pre-hook" || return 1 + + # Registry status per file type. + assert_eq "modified" "$(nvim_eval "require('code-preview.changes').get('$f_update')")" \ + "Update File should be modified" || return 1 + assert_eq "created" "$(nvim_eval "require('code-preview.changes').get('$f_add')")" \ + "Add File should be created" || return 1 + + # A diff should be open. + assert_eq "true" "$(nvim_eval "require('code-preview.diff').is_open()")" \ + "diff should be open during mixed patch" || return 1 + + # Post-hook must close ALL four — the regression fixed in bin/core-post-tool.sh. + run_copilot_post_patch "$patch" + sleep 0.6 + + local is_open_after + is_open_after="$(nvim_eval "require('code-preview.diff').is_open()")" + assert_eq "false" "$is_open_after" "all diffs should close after post-hook" || return 1 + + local changes_after + changes_after="$(nvim_eval "vim.tbl_count(require('code-preview.changes').get_all())")" + assert_eq "0" "$changes_after" "changes registry should be empty after post-hook" || return 1 +} + +# ── Run all tests ──────────────────────────────────────────────── + +run_test "Copilot apply_patch Update File opens and closes diff" test_copilot_apply_patch_update +run_test "Copilot apply_patch Add File marks as created" test_copilot_apply_patch_add +run_test "Copilot apply_patch mixed Update+Add+Delete closes all" test_copilot_apply_patch_mixed + +# ── Teardown ───────────────────────────────────────────────────── + +stop_nvim +cleanup_test_project diff --git a/tests/backends/copilot/test_edit.sh b/tests/backends/copilot/test_edit.sh new file mode 100644 index 0000000..ea99049 --- /dev/null +++ b/tests/backends/copilot/test_edit.sh @@ -0,0 +1,279 @@ +#!/usr/bin/env bash +# test_edit.sh — E2E tests for GitHub Copilot CLI edit/create/bash workflows +# +# Drives Copilot's native hook payload shape ({toolName, cwd, toolArgs}) through +# backends/copilot/code-preview-diff.sh (pre) and code-close-diff.sh (post), +# then verifies Neovim state via RPC. +# +# Copilot quirk: toolArgs is a stringified JSON object for most tools, and the +# raw patch text for apply_patch. The adapter normalizes both to the shared +# {tool_name, cwd, tool_input} format consumed by bin/core-pre-tool.sh. + +COPILOT_PRE="$REPO_ROOT/backends/copilot/code-preview-diff.sh" +COPILOT_POST="$REPO_ROOT/backends/copilot/code-close-diff.sh" + +# Feed a Copilot-shaped payload to the pre-tool adapter. +# $1 = toolName, $2 = toolArgs (JSON-encoded string OR raw text for apply_patch) +run_copilot_pre() { + local tool_name="$1" + local tool_args="$2" + local payload + payload=$(jq -n \ + --arg tn "$tool_name" \ + --arg cwd "$TEST_PROJECT_DIR" \ + --arg ta "$tool_args" \ + '{toolName:$tn, cwd:$cwd, toolArgs:$ta}') + echo "$payload" | \ + NVIM_LISTEN_ADDRESS="$TEST_SOCKET" \ + bash "$COPILOT_PRE" 2>/dev/null || true +} + +run_copilot_post() { + local tool_name="$1" + local tool_args="$2" + local payload + payload=$(jq -n \ + --arg tn "$tool_name" \ + --arg cwd "$TEST_PROJECT_DIR" \ + --arg ta "$tool_args" \ + '{toolName:$tn, cwd:$cwd, toolArgs:$ta}') + echo "$payload" | \ + NVIM_LISTEN_ADDRESS="$TEST_SOCKET" \ + bash "$COPILOT_POST" 2>/dev/null || true +} + +# ── Setup ──────────────────────────────────────────────────────── + +setup_test_project +start_nvim + +# ── Test: edit tool opens diff, post closes it ────────────────── + +test_copilot_edit() { + reset_test_state + local test_file + test_file="$(create_test_file "src/cp_edit.lua" 'local x = 1')" + + local tool_args + tool_args=$(jq -nc \ + --arg p "$test_file" \ + --arg o "local x = 1" \ + --arg n "local x = 99" \ + '{path:$p, old_str:$o, new_str:$n}') + + run_copilot_pre "edit" "$tool_args" + sleep 0.5 + + local is_open + is_open="$(nvim_eval "require('code-preview.diff').is_open()")" + assert_eq "true" "$is_open" "diff should open after Copilot edit" || return 1 + + local change_status + change_status="$(nvim_eval "require('code-preview.changes').get('$test_file')")" + assert_eq "modified" "$change_status" "file should be marked as modified" || return 1 + + run_copilot_post "edit" "$tool_args" + sleep 0.5 + + local is_open_after + is_open_after="$(nvim_eval "require('code-preview.diff').is_open()")" + assert_eq "false" "$is_open_after" "diff should close after post-hook" || return 1 +} + +# ── Test: str_replace is aliased to edit ──────────────────────── + +# Some Copilot models emit `str_replace` instead of `edit` for the same +# {path, old_str, new_str} shape. The adapter must normalize both to Edit +# so they share the lifecycle. This test locks the alias contract. +test_copilot_str_replace_alias() { + reset_test_state + local test_file + test_file="$(create_test_file "src/cp_sr.lua" 'local y = 1')" + + local tool_args + tool_args=$(jq -nc \ + --arg p "$test_file" \ + --arg o "local y = 1" \ + --arg n "local y = 2" \ + '{path:$p, old_str:$o, new_str:$n}') + + run_copilot_pre "str_replace" "$tool_args" + sleep 0.5 + + local is_open + is_open="$(nvim_eval "require('code-preview.diff').is_open()")" + assert_eq "true" "$is_open" "str_replace should open a diff (aliased to Edit)" || return 1 + + local change_status + change_status="$(nvim_eval "require('code-preview.changes').get('$test_file')")" + assert_eq "modified" "$change_status" "str_replace should mark file as modified" || return 1 + + run_copilot_post "str_replace" "$tool_args" + sleep 0.5 + + local is_open_after + is_open_after="$(nvim_eval "require('code-preview.diff').is_open()")" + assert_eq "false" "$is_open_after" "str_replace post-hook should close diff" || return 1 +} + +# ── Test: create tool marks new file as created ───────────────── + +test_copilot_create() { + reset_test_state + local new_file="$TEST_PROJECT_DIR/src/cp_new.lua" + + local tool_args + tool_args=$(jq -nc \ + --arg p "$new_file" \ + --arg c "local M = {} +return M" \ + '{path:$p, file_text:$c}') + + run_copilot_pre "create" "$tool_args" + sleep 0.5 + + local is_open + is_open="$(nvim_eval "require('code-preview.diff').is_open()")" + assert_eq "true" "$is_open" "diff should open for Copilot create" || return 1 + + local change_status + change_status="$(nvim_eval "require('code-preview.changes').get('$new_file')")" + assert_eq "created" "$change_status" "new file should be marked as created" || return 1 + + run_copilot_post "create" "$tool_args" + sleep 0.5 + + local is_open_after + is_open_after="$(nvim_eval "require('code-preview.diff').is_open()")" + assert_eq "false" "$is_open_after" "diff should close after create post-hook" || return 1 + + local changes_count + changes_count="$(nvim_eval "vim.tbl_count(require('code-preview.changes').get_all())")" + assert_eq "0" "$changes_count" "changes registry should be empty after create cycle" || return 1 +} + +# ── Test: bash rm marks target as deleted ─────────────────────── + +test_copilot_bash_rm() { + reset_test_state + local test_file + test_file="$(create_test_file "cp_delete_me.txt" 'goodbye')" + + local tool_args + tool_args=$(jq -nc \ + --arg cmd "rm $test_file" \ + --arg d "delete temp file" \ + '{command:$cmd, description:$d}') + + run_copilot_pre "bash" "$tool_args" + sleep 0.5 + + local change_status + change_status="$(nvim_eval "require('code-preview.changes').get('$test_file')")" + assert_eq "deleted" "$change_status" "rm target should be marked as deleted" || return 1 + + run_copilot_post "bash" "$tool_args" + sleep 0.5 + + local change_after + change_after="$(nvim_eval "require('code-preview.changes').get('$test_file') or 'nil'")" + assert_eq "nil" "$change_after" "deletion marker should be cleared" || return 1 +} + +# ── Test: relative path resolves against cwd ──────────────────── + +test_copilot_relative_path() { + reset_test_state + create_test_file "src/cp_rel.lua" 'local r = 1' >/dev/null + local abs_file="$TEST_PROJECT_DIR/src/cp_rel.lua" + + # Pass a relative path in toolArgs — adapter should resolve against cwd + local tool_args + tool_args=$(jq -nc \ + --arg p "src/cp_rel.lua" \ + --arg o "local r = 1" \ + --arg n "local r = 2" \ + '{path:$p, old_str:$o, new_str:$n}') + + run_copilot_pre "edit" "$tool_args" + sleep 0.5 + + local is_open + is_open="$(nvim_eval "require('code-preview.diff').is_open()")" + assert_eq "true" "$is_open" "diff should open from relative path" || return 1 + + # The absolute form is what ends up in the changes registry + local change_status + change_status="$(nvim_eval "require('code-preview.changes').get('$abs_file')")" + assert_eq "modified" "$change_status" "relative path should resolve to absolute" || return 1 + + run_copilot_post "edit" "$tool_args" + sleep 0.5 +} + +# ── Test: noise tools exit without opening a diff ─────────────── + +test_copilot_noise_tools_ignored() { + reset_test_state + + # view, glob, report_intent etc. must not trigger a diff preview. + run_copilot_pre "view" '{"path":"/tmp/whatever"}' + run_copilot_pre "report_intent" '{"intent":"just looking"}' + run_copilot_pre "glob" '{"pattern":"**/*.lua"}' + sleep 0.3 + + local is_open + is_open="$(nvim_eval "require('code-preview.diff').is_open()")" + assert_eq "false" "$is_open" "noise tools should not open a diff" || return 1 + + local changes_count + changes_count="$(nvim_eval "vim.tbl_count(require('code-preview.changes').get_all())")" + assert_eq "0" "$changes_count" "noise tools should not mark changes" || return 1 +} + +# ── Test: malformed payloads skip cleanly (no broken diff) ────── + +# If Copilot ever sends an edit/create/bash with missing toolArgs fields, +# the adapter must exit 0 rather than push an empty-path diff downstream. +# Regression guard for the stdin-dispatch foot-gun. +test_copilot_malformed_payloads_skip() { + reset_test_state + + # edit with empty path + run_copilot_pre "edit" '{"old_str":"a","new_str":"b"}' + # create with missing path + run_copilot_pre "create" '{"file_text":"hello"}' + # bash with empty command + run_copilot_pre "bash" '{"description":"noop"}' + # toolArgs entirely absent on a non-noise tool + local payload + payload=$(jq -n --arg cwd "$TEST_PROJECT_DIR" '{toolName:"edit", cwd:$cwd}') + echo "$payload" | \ + NVIM_LISTEN_ADDRESS="$TEST_SOCKET" \ + bash "$COPILOT_PRE" 2>/dev/null || true + + sleep 0.3 + + local is_open + is_open="$(nvim_eval "require('code-preview.diff').is_open()")" + assert_eq "false" "$is_open" "malformed payloads should not open a diff" || return 1 + + local changes_count + changes_count="$(nvim_eval "vim.tbl_count(require('code-preview.changes').get_all())")" + assert_eq "0" "$changes_count" "malformed payloads should not mark changes" || return 1 +} + +# ── Run all tests ──────────────────────────────────────────────── + +run_test "Copilot edit opens and closes diff" test_copilot_edit +run_test "Copilot str_replace aliases to edit" test_copilot_str_replace_alias +run_test "Copilot create marks new file as created" test_copilot_create +run_test "Copilot bash rm marks target as deleted" test_copilot_bash_rm +run_test "Copilot resolves relative file paths" test_copilot_relative_path +run_test "Copilot noise tools (view/glob/etc) ignored" test_copilot_noise_tools_ignored +run_test "Copilot malformed payloads skip cleanly" test_copilot_malformed_payloads_skip + +# ── Teardown ───────────────────────────────────────────────────── + +stop_nvim +cleanup_test_project diff --git a/tests/backends/copilot/test_install.sh b/tests/backends/copilot/test_install.sh new file mode 100644 index 0000000..3acac4b --- /dev/null +++ b/tests/backends/copilot/test_install.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# test_install.sh — GitHub Copilot CLI hook install/uninstall tests +# +# Copilot auto-discovers every *.json under .github/hooks/, so the installer +# writes a standalone code-preview.json rather than merging into a shared +# settings file. These tests assert that contract — and, critically, that we +# never touch sibling files in .github/hooks/ (user hooks stay intact). + +# ── Setup ──────────────────────────────────────────────────────── + +setup_test_project +start_nvim + +# Change Neovim's cwd to the test project so backend module writes hooks there +nvim_exec "vim.cmd('cd $TEST_PROJECT_DIR')" + +HOOKS_FILE="$TEST_PROJECT_DIR/.github/hooks/code-preview.json" + +# ── Test: Install writes the correct hook file ────────────────── + +test_install_copilot_hooks() { + rm -rf "$TEST_PROJECT_DIR/.github" + nvim_exec "require('code-preview.backends.copilot').install()" + sleep 0.3 + + assert_file_exists "$HOOKS_FILE" "code-preview.json should be created" || return 1 + + local content + content="$(cat "$HOOKS_FILE")" + + # Version field must be present and set to 1 + local version + version="$(jq -r '.version' "$HOOKS_FILE")" + assert_eq "1" "$version" "version should be 1" || return 1 + + # Both hook events are registered with the right adapter scripts + assert_contains "$content" "preToolUse" "should have preToolUse hook" || return 1 + assert_contains "$content" "postToolUse" "should have postToolUse hook" || return 1 + assert_contains "$content" "code-preview-diff.sh" "should reference pre-tool script" || return 1 + assert_contains "$content" "code-close-diff.sh" "should reference post-tool script" || return 1 + + # Each event should have exactly one entry (no accidental duplication) + local pre_count post_count + pre_count="$(jq '.hooks.preToolUse | length' "$HOOKS_FILE")" + post_count="$(jq '.hooks.postToolUse | length' "$HOOKS_FILE")" + assert_eq "1" "$pre_count" "preToolUse should have 1 entry" || return 1 + assert_eq "1" "$post_count" "postToolUse should have 1 entry" || return 1 +} + +# ── Test: Uninstall removes the hook file ─────────────────────── + +test_uninstall_copilot_hooks() { + nvim_exec "require('code-preview.backends.copilot').install()" + sleep 0.2 + assert_file_exists "$HOOKS_FILE" "precondition: file should exist" || return 1 + + nvim_exec "require('code-preview.backends.copilot').uninstall()" + sleep 0.2 + + assert_file_not_exists "$HOOKS_FILE" "code-preview.json should be removed" || return 1 +} + +# ── Test: Install is idempotent ───────────────────────────────── + +test_install_idempotent() { + rm -rf "$TEST_PROJECT_DIR/.github" + nvim_exec "require('code-preview.backends.copilot').install()" + nvim_exec "require('code-preview.backends.copilot').install()" + sleep 0.2 + + # Re-running must still produce exactly one entry per event, not append. + local pre_count post_count + pre_count="$(jq '.hooks.preToolUse | length' "$HOOKS_FILE")" + post_count="$(jq '.hooks.postToolUse | length' "$HOOKS_FILE")" + assert_eq "1" "$pre_count" "preToolUse should still have 1 entry after re-install" || return 1 + assert_eq "1" "$post_count" "postToolUse should still have 1 entry after re-install" || return 1 +} + +# ── Test: Install preserves sibling hook files ────────────────── + +# Copilot aggregates every *.json under .github/hooks/, so a user may keep +# their own policy.json alongside ours. Install/uninstall must never touch it. +test_install_preserves_sibling_hooks() { + rm -rf "$TEST_PROJECT_DIR/.github" + mkdir -p "$TEST_PROJECT_DIR/.github/hooks" + local sibling="$TEST_PROJECT_DIR/.github/hooks/user-policy.json" + printf '%s\n' '{"version":1,"hooks":{"preToolUse":[{"type":"command","bash":"echo user"}]}}' > "$sibling" + + nvim_exec "require('code-preview.backends.copilot').install()" + sleep 0.2 + + assert_file_exists "$sibling" "sibling hook file should still exist after install" || return 1 + assert_file_exists "$HOOKS_FILE" "our hook file should also be present" || return 1 + + local sibling_content + sibling_content="$(cat "$sibling")" + assert_contains "$sibling_content" "echo user" "sibling file contents should be untouched" || return 1 + + nvim_exec "require('code-preview.backends.copilot').uninstall()" + sleep 0.2 + + assert_file_not_exists "$HOOKS_FILE" "our file should be removed on uninstall" || return 1 + assert_file_exists "$sibling" "sibling hook file must survive uninstall" || return 1 +} + +# ── Test: Uninstall refuses to delete foreign code-preview.json ─ + +# If a user happens to have their own .github/hooks/code-preview.json that +# wasn't produced by our installer, uninstall must leave it alone. We +# identify our file by the presence of the adapter script name. +test_uninstall_refuses_foreign_file() { + rm -rf "$TEST_PROJECT_DIR/.github" + mkdir -p "$TEST_PROJECT_DIR/.github/hooks" + # User-owned file that happens to share the name but references a + # different script — our installer would never produce this. + printf '%s\n' '{"version":1,"hooks":{"preToolUse":[{"type":"command","bash":"echo user-owned"}]}}' \ + > "$HOOKS_FILE" + + nvim_exec "require('code-preview.backends.copilot').uninstall()" + sleep 0.2 + + assert_file_exists "$HOOKS_FILE" "foreign code-preview.json must survive uninstall" || return 1 + + local content + content="$(cat "$HOOKS_FILE")" + assert_contains "$content" "echo user-owned" "foreign file contents must be untouched" || return 1 +} + +# ── Run all tests ──────────────────────────────────────────────── + +run_test "Install Copilot CLI hooks writes correct config" test_install_copilot_hooks +run_test "Uninstall Copilot CLI hooks removes config file" test_uninstall_copilot_hooks +run_test "Install is idempotent (no duplicate entries)" test_install_idempotent +run_test "Install/uninstall preserves sibling hook files" test_install_preserves_sibling_hooks +run_test "Uninstall refuses to delete foreign code-preview.json" test_uninstall_refuses_foreign_file + +# ── Teardown ───────────────────────────────────────────────────── + +stop_nvim +cleanup_test_project diff --git a/tests/backends/opencode/test_apply_patch.sh b/tests/backends/opencode/test_apply_patch.sh index a474473..c144265 100644 --- a/tests/backends/opencode/test_apply_patch.sh +++ b/tests/backends/opencode/test_apply_patch.sh @@ -116,6 +116,37 @@ test_patch_add_file() { assert_contains "$prop_content" "return M" "proposed should have second line" || return 1 } +# ── Test: Add new file (GPT shape — no @@ marker) ──────────────── + +# GPT's apply_patch format emits `*** Add File:` with content lines directly +# following, no `@@` hunk header. Parser must lazy-create a hunk for these +# lines; regression target for the original empty-diff-on-create bug. +test_patch_add_file_no_hunk_marker() { + local patch + patch=$(printf '%s\n' \ + "*** Begin Patch" \ + "*** Add File: src/gpt_style.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 + + 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 = {}" "no-@@ proposed should capture first line" || return 1 + assert_contains "$prop_content" "return M" "no-@@ proposed should capture second line" || return 1 +} + # ── Test: Delete file ──────────────────────────────────────────── test_patch_delete_file() { @@ -242,6 +273,7 @@ line 6" >/dev/null 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 Add File without @@ (GPT shape)" test_patch_add_file_no_hunk_marker 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 diff --git a/tests/core/test_post_tool_patch_paths.sh b/tests/core/test_post_tool_patch_paths.sh new file mode 100644 index 0000000..624b8b3 --- /dev/null +++ b/tests/core/test_post_tool_patch_paths.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# test_post_tool_patch_paths.sh — Regression test for bin/core-post-tool.sh +# +# Verifies that the patch-path extractor for ApplyPatch calls close_for_file +# for every file referenced in the patch — Update, Add, AND Delete. +# +# Regression: Delete File: directives were previously skipped by the extractor +# regex, leaving delete-diff tabs lingering after accept. + +# ── Setup ──────────────────────────────────────────────────────── + +setup_test_project +start_nvim + +# Install a stub close_for_file that records every path it's called with into +# a global table. We don't care about actual diff lifecycle here — just that +# the hook script extracted the right paths from the patch. +install_stub() { + nvim_exec " + _G.__closed_paths = {} + package.loaded['code-preview.diff'] = package.loaded['code-preview.diff'] or {} + package.loaded['code-preview.diff'].close_for_file = function(p) + table.insert(_G.__closed_paths, p) + end + " +} + +reset_stub() { + nvim_exec "_G.__closed_paths = {}" +} + +# Pipe-joined string of paths. Avoids vim.json.encode, whose forward-slash +# escaping behavior varies across Neovim builds and breaks substring matching +# on paths like "src/new.lua". +closed_paths() { + nvim_eval "table.concat(_G.__closed_paths or {}, '|')" +} + +# Feed a normalized ApplyPatch JSON payload to core-post-tool.sh +run_post_apply_patch() { + local patch_text="$1" + local payload + payload=$(jq -n \ + --arg cwd "$TEST_PROJECT_DIR" \ + --arg patch "$patch_text" \ + '{tool_name:"ApplyPatch", cwd:$cwd, tool_input:{patch_text:$patch}}') + echo "$payload" | \ + NVIM_LISTEN_ADDRESS="$TEST_SOCKET" \ + bash "$REPO_ROOT/bin/core-post-tool.sh" 2>/dev/null || true + # Give nvim time to process async RPC + sleep 0.3 +} + +# ── Test: Delete File directive triggers close_for_file ────────── + +test_delete_file_closes_diff() { + install_stub + reset_stub + + local patch + patch=$(printf '%s\n' \ + "*** Begin Patch" \ + "*** Delete File: to_remove.txt" \ + "*** End Patch") + + run_post_apply_patch "$patch" + + local closed + closed="$(closed_paths)" + assert_contains "$closed" "to_remove.txt" "Delete File path should be passed to close_for_file" || return 1 +} + +# ── Test: Mixed Update + Add + Delete all close ────────────────── + +test_mixed_patch_closes_all_diffs() { + install_stub + reset_stub + + local patch + patch=$(printf '%s\n' \ + "*** Begin Patch" \ + "*** Update File: README.md" \ + "@@" \ + " existing line" \ + "-old text" \ + "+new text" \ + "*** Add File: src/new.lua" \ + "@@" \ + "+local M = {}" \ + "+return M" \ + "*** Delete File: old.txt" \ + "*** End Patch") + + run_post_apply_patch "$patch" + + local closed + closed="$(closed_paths)" + assert_contains "$closed" "README.md" "Update File path should be closed" || return 1 + assert_contains "$closed" "src/new.lua" "Add File path should be closed" || return 1 + assert_contains "$closed" "old.txt" "Delete File path should be closed" || return 1 + + # Confirm exactly three paths were closed — no duplicates, no drops. + local count + count="$(nvim_eval "#(_G.__closed_paths or {})")" + assert_eq "3" "$count" "should close exactly 3 paths for 3-file patch" || return 1 +} + +# ── Test: Update-only patch (sanity — pre-existing behavior) ───── + +test_update_only_closes_diff() { + install_stub + reset_stub + + local patch + patch=$(printf '%s\n' \ + "*** Begin Patch" \ + "*** Update File: a.txt" \ + "@@" \ + " ctx" \ + "-x" \ + "+y" \ + "*** End Patch") + + run_post_apply_patch "$patch" + + local closed + closed="$(closed_paths)" + assert_contains "$closed" "a.txt" "Update File path should be closed" || return 1 +} + +# ── Run all tests ──────────────────────────────────────────────── + +run_test "core-post-tool.sh closes diff for Delete File directive" test_delete_file_closes_diff +run_test "core-post-tool.sh closes diffs for mixed Update+Add+Delete patch" test_mixed_patch_closes_all_diffs +run_test "core-post-tool.sh closes diff for Update File directive" test_update_only_closes_diff + +# ── Teardown ───────────────────────────────────────────────────── + +stop_nvim +cleanup_test_project diff --git a/tests/run.sh b/tests/run.sh index bae316f..35f104c 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -7,6 +7,7 @@ # ./tests/run.sh backends # run all backend tests # ./tests/run.sh backends/claude # run Claude Code backend tests only # ./tests/run.sh backends/opencode # run OpenCode backend tests only +# ./tests/run.sh core # run core shell tests (bin/ scripts) # ./tests/run.sh edit # run any backend test file matching "edit" set -euo pipefail @@ -51,6 +52,11 @@ discover_backend_tests() { done < <(find "$backend_dir" -name 'test_*.sh' -type f 2>/dev/null | sort) fi ;; + core) + while IFS= read -r f; do + test_files+=("$f") + done < <(find "$SCRIPT_DIR/core" -name 'test_*.sh' -type f 2>/dev/null | sort) + ;; *) # Fuzzy match: find any test file whose name contains the filter while IFS= read -r f; do @@ -59,7 +65,7 @@ discover_backend_tests() { if [[ "$base" == *"$filter"* ]]; then test_files+=("$f") fi - done < <(find "$SCRIPT_DIR/backends" -name 'test_*.sh' -type f 2>/dev/null | sort) + done < <(find "$SCRIPT_DIR/backends" "$SCRIPT_DIR/core" -name 'test_*.sh' -type f 2>/dev/null | sort) ;; esac @@ -126,12 +132,14 @@ main() { all) run_plugin_tests echo "" + run_backend_tests "core" + echo "" run_backend_tests "backends" ;; plugin) run_plugin_tests ;; - backends|backends/*) + backends|backends/*|core) run_backend_tests "$filter" ;; *)