Merge pull request #11486 from omacom/fix-remote-neovim-yanks

Migrate remote Neovim providers to reliable clipboard yanks and paste
This commit is contained in:
Ryan Hughes authored and GitHub committed 2026-09-25 14:57:16 -05:00
commit 7b336b1b0d
6 files changed
+819

No files matched your search

+51
View File
@@ -0,0 +1,51 @@
echo "Repair remote Neovim clipboard yanks and paste"
nvim_provider="$HOME/.config/nvim/lua/config/remote_clipboard.lua"
provider_source="/usr/share/omarchy-nvim/config/lua/config/remote_clipboard.lua"
# Dotfile managers may own either the file or a parent directory through a
# symlink. Preserve that layout rather than detaching or editing its target.
provider_path="$nvim_provider"
while [[ $provider_path != "$HOME" && $provider_path != / ]]; do
if [[ -L $provider_path ]]; then
echo "Preserving symlink-managed Neovim provider: $nvim_provider"
echo "Review remote clipboard settings in your dotfile configuration manually."
exit 0
fi
provider_path=$(dirname "$provider_path")
done
[[ -f $nvim_provider ]] || exit 0
# Replace only known Omarchy versions, including the June file-backed provider
# and the two earlier proposed fixes. Preserve all user-authored changes.
provider_hash=$(sha256sum "$nvim_provider")
case ${provider_hash%% *} in
c0c15941ed7cf97a1a3d3c60f0b13875e85f641bdb7431c3283e6f86fbc95f4e|bac8268c3dde4e747772467d6671e626aa4982478551d8fc8ebe1259a4854f2c|7f012a235c05e3c559b48211c1534af2f4c919750b76db88ec3d5b7814259d30|8b371d0f271e522b27696f1b71083d0a6ba7896cd3ff7b0a938b7327fcb06a46) ;;
*)
if [[ -f $provider_source ]] && cmp -s "$nvim_provider" "$provider_source"; then
exit 0
fi
echo "Preserving customized Neovim provider: $nvim_provider"
echo "Review remote clipboard settings manually, or use omarchy-nvim-refresh to reset them."
exit 0
;;
esac
# Updates install packages before migrations. Refuse an older package so this
# migration remains pending instead of installing the provider it must repair.
nvim_package=$(pacman -Q omarchy-nvim)
if [[ $(vercmp "${nvim_package#* }" "2026.9.21-2") == -* ]] || [[ ! -f $provider_source ]]; then
echo "Update omarchy-nvim to 2026.9.21-2 or newer before rerunning this migration." >&2
exit 1
fi
provider_backup=$(mktemp "$nvim_provider.bak.XXXXXX")
cp -p "$nvim_provider" "$provider_backup"
provider_staged=$(mktemp "$nvim_provider.new.XXXXXX")
trap 'rm -f -- "$provider_staged"' EXIT
install -m 0644 "$provider_source" "$provider_staged"
# A failed write leaves the recognized original live, so retries can repair it.
# The temporary file is on the same filesystem for an atomic replacement.
mv -fT -- "$provider_staged" "$nvim_provider"
echo "Previous Neovim provider saved to $provider_backup"
@@ -0,0 +1,102 @@
-- Clipboard for sessions whose yanks may need to reach another machine:
-- every copy is emitted as OSC 52 (inside tmux this becomes a tmux buffer,
-- rebroadcast to every attached client, local or SSH). Paste prefers the
-- local Wayland clipboard when one is available, so content copied in other
-- apps remains pasteable; without a display, paste is an OSC 52 query that
-- tmux (or the terminal) answers.
local M = {}
local function proc_lines(pid, file)
local ok, lines = pcall(vim.fn.readfile, "/proc/" .. pid .. "/" .. file)
return ok and lines or {}
end
local function proc_ppid(pid)
for _, line in ipairs(proc_lines(pid, "status")) do
local ppid = line:match("^PPid:%s+(%d+)")
if ppid then
return tonumber(ppid)
end
end
end
local function ancestor_process_named(name)
local pid = vim.fn.getpid()
for _ = 1, 16 do
local ppid = proc_ppid(pid)
if not ppid or ppid <= 1 then
return false
end
local comm = proc_lines(ppid, "comm")[1] or ""
if comm:find(name, 1, true) then
return true
end
pid = ppid
end
return false
end
function M.setup()
local in_tmux = vim.env.TMUX ~= nil
local in_ssh = vim.env.SSH_TTY ~= nil or vim.env.SSH_CONNECTION ~= nil
local in_herdr = vim.env.HERDR_PANE_ID ~= nil or ancestor_process_named("herdr")
if not (in_tmux or in_ssh or in_herdr) then
return
end
local osc52 = require("vim.ui.clipboard.osc52")
local has_wayland = vim.env.WAYLAND_DISPLAY ~= nil
and vim.fn.executable("wl-copy") == 1
and vim.fn.executable("wl-paste") == 1
local function copy(register)
local emit = osc52.copy(register)
return function(lines)
if has_wayland then
local cmd = { "wl-copy", "--sensitive", "--type", "text/plain" }
if register == "*" then
cmd[#cmd + 1] = "--primary"
end
vim.fn.system(cmd, lines)
end
if vim.g.omarchy_remote_clipboard_osc52 ~= false then
emit(lines)
end
end
end
local function paste(register)
if not has_wayland then
return osc52.paste(register)
end
return function()
local cmd = { "wl-paste", "--no-newline" }
if register == "*" then
cmd[#cmd + 1] = "--primary"
end
local lines = vim.fn.systemlist(cmd, "", 1)
return vim.v.shell_error == 0 and lines or {}
end
end
-- LazyVim disables clipboard syncing over SSH; our provider supports it.
vim.opt.clipboard = "unnamedplus"
vim.g.clipboard = {
name = "OmarchyRemoteClipboard",
copy = { ["+"] = copy("+"), ["*"] = copy("*") },
paste = { ["+"] = paste("+"), ["*"] = paste("*") },
cache_enabled = 0,
}
end
return M
@@ -0,0 +1,99 @@
-- Clipboard for sessions whose yanks may need to reach another machine:
-- every copy is emitted as OSC 52 (inside tmux this becomes a tmux buffer,
-- rebroadcast to every attached client, local or SSH). Paste prefers the
-- local Wayland clipboard when one is available, so content copied in other
-- apps remains pasteable; without a display, paste is an OSC 52 query that
-- tmux (or the terminal) answers.
local M = {}
local function proc_lines(pid, file)
local ok, lines = pcall(vim.fn.readfile, "/proc/" .. pid .. "/" .. file)
return ok and lines or {}
end
local function proc_ppid(pid)
for _, line in ipairs(proc_lines(pid, "status")) do
local ppid = line:match("^PPid:%s+(%d+)")
if ppid then
return tonumber(ppid)
end
end
end
local function ancestor_process_named(name)
local pid = vim.fn.getpid()
for _ = 1, 16 do
local ppid = proc_ppid(pid)
if not ppid or ppid <= 1 then
return false
end
local comm = proc_lines(ppid, "comm")[1] or ""
if comm:find(name, 1, true) then
return true
end
pid = ppid
end
return false
end
function M.setup()
local in_tmux = vim.env.TMUX ~= nil
local in_ssh = vim.env.SSH_TTY ~= nil or vim.env.SSH_CONNECTION ~= nil
local in_herdr = vim.env.HERDR_PANE_ID ~= nil or ancestor_process_named("herdr")
if not (in_tmux or in_ssh or in_herdr) then
return
end
local osc52 = require("vim.ui.clipboard.osc52")
local has_wayland = vim.env.WAYLAND_DISPLAY ~= nil
and vim.fn.executable("wl-copy") == 1
and vim.fn.executable("wl-paste") == 1
local function copy(register)
local emit = osc52.copy(register)
return function(lines)
if has_wayland then
local cmd = { "wl-copy", "--sensitive", "--type", "text/plain" }
if register == "*" then
cmd[#cmd + 1] = "--primary"
end
vim.fn.system(cmd, lines)
end
if vim.g.omarchy_remote_clipboard_osc52 ~= false then
emit(lines)
end
end
end
local function paste(register)
if not has_wayland then
return osc52.paste(register)
end
return function()
local cmd = { "wl-paste", "--no-newline" }
if register == "*" then
cmd[#cmd + 1] = "--primary"
end
local lines = vim.fn.systemlist(cmd, "", 1)
return vim.v.shell_error == 0 and lines or {}
end
end
vim.g.clipboard = {
name = "OmarchyRemoteClipboard",
copy = { ["+"] = copy("+"), ["*"] = copy("*") },
paste = { ["+"] = paste("+"), ["*"] = paste("*") },
cache_enabled = 0,
}
end
return M
@@ -0,0 +1,275 @@
local M = {}
local uv = vim.uv or vim.loop
local function secure_runtime_dir()
local uid = uv.getuid()
local candidates = {}
local function add(path)
if path and path ~= "" then
candidates[#candidates + 1] = path
end
end
add(vim.env.XDG_RUNTIME_DIR)
if uid then
add("/run/user/" .. uid)
add("/dev/shm/nvim-remote-clipboard-" .. uid)
end
for _, dir in ipairs(candidates) do
if dir:sub(1, 9) == "/dev/shm/" then
pcall(vim.fn.mkdir, dir, "p", 448)
pcall(vim.fn.setfperm, dir, "rwx------")
end
local stat = uv.fs_stat(dir)
if stat and stat.type == "directory" and stat.uid == uid and stat.mode % 512 == 448 then
return dir
end
end
end
local function wayland_connection(runtime_dir)
if vim.fn.executable("wl-copy") ~= 1 or vim.fn.executable("wl-paste") ~= 1 then
return nil
end
local function valid_socket(path)
local stat = uv.fs_stat(path)
return stat and stat.type == "socket"
end
if vim.env.WAYLAND_DISPLAY and vim.env.WAYLAND_DISPLAY ~= "" then
local display = vim.env.WAYLAND_DISPLAY
local socket_path = display:sub(1, 1) == "/" and display or (runtime_dir .. "/" .. display)
if valid_socket(socket_path) then
return display
end
end
for _, path in ipairs(vim.fn.glob(runtime_dir .. "/wayland-*", false, true)) do
local display = vim.fn.fnamemodify(path, ":t")
if not display:match("%.lock$") and valid_socket(path) then
return display
end
end
end
local function write_private_file(path, payload)
local tmp_path = path .. "." .. vim.fn.getpid() .. ".tmp"
vim.fn.writefile({ payload }, tmp_path, "b")
vim.fn.setfperm(tmp_path, "rw-------")
vim.fn.rename(tmp_path, path)
vim.fn.setfperm(path, "rw-------")
end
local function proc_lines(pid, file)
local ok, lines = pcall(vim.fn.readfile, "/proc/" .. pid .. "/" .. file)
return ok and lines or {}
end
local function proc_ppid(pid)
for _, line in ipairs(proc_lines(pid, "status")) do
local ppid = line:match("^PPid:%s+(%d+)")
if ppid then
return tonumber(ppid)
end
end
end
local function ancestor_process_named(name)
local pid = vim.fn.getpid()
for _ = 1, 16 do
local ppid = proc_ppid(pid)
if not ppid or ppid <= 1 then
return false
end
local comm = proc_lines(ppid, "comm")[1] or ""
if comm:find(name, 1, true) then
return true
end
pid = ppid
end
return false
end
local function copy_to_client_clipboard(register, lines)
if vim.g.omarchy_remote_clipboard_osc52 == false then
return
end
local ok, osc52 = pcall(require, "vim.ui.clipboard.osc52")
if ok then
pcall(osc52.copy(register), lines)
end
end
function M.setup()
local in_tmux = vim.env.TMUX ~= nil
local has_display = vim.env.WAYLAND_DISPLAY or vim.env.DISPLAY
local in_remote_session = vim.env.SSH_TTY or vim.env.SSH_CONNECTION
local in_herdr = vim.env.HERDR_PANE_ID ~= nil or ancestor_process_named("herdr")
local needs_remote_clipboard = not in_tmux and (in_herdr or (in_remote_session and not has_display))
if not needs_remote_clipboard then
return
end
local runtime_dir = secure_runtime_dir()
if not runtime_dir then
return
end
local wayland_display = wayland_connection(runtime_dir)
local ttl_seconds = 10 * 60
local function empty_clipboard()
return { {}, "v" }
end
local function read_regtype(path)
local stat = uv.fs_stat(path)
if not stat or stat.uid ~= uv.getuid() or stat.mode % 512 ~= 384 then
return "v"
end
local ok, file_lines = pcall(vim.fn.readfile, path)
if not ok or #file_lines == 0 then
return "v"
end
local ok_decode, data = pcall(vim.fn.json_decode, table.concat(file_lines, "\n"))
if not ok_decode or type(data) ~= "table" then
return "v"
end
if type(data.created_at) == "number" and os.time() - data.created_at > ttl_seconds then
vim.fn.delete(path)
return "v"
end
return type(data.regtype) == "string" and data.regtype or "v"
end
if wayland_display then
local regtype_path = runtime_dir .. "/nvim-remote-clipboard-regtype.json"
local env = {
"env",
"XDG_RUNTIME_DIR=" .. runtime_dir,
"WAYLAND_DISPLAY=" .. wayland_display,
}
local function write_regtype(regtype)
write_private_file(regtype_path, vim.fn.json_encode({
regtype = regtype,
created_at = os.time(),
}))
end
local function copy(register)
return function(lines, regtype)
write_regtype(regtype)
local cmd = vim.list_extend(vim.deepcopy(env), {
"wl-copy",
"--sensitive",
"--type",
"text/plain",
})
if register == "*" then
cmd[#cmd + 1] = "--primary"
end
vim.fn.system(cmd, lines)
copy_to_client_clipboard(register, lines)
end
end
local function paste(register)
return function()
local cmd = vim.list_extend(vim.deepcopy(env), { "wl-paste", "--no-newline" })
if register == "*" then
cmd[#cmd + 1] = "--primary"
end
local lines = vim.fn.systemlist(cmd, "", 1)
if vim.v.shell_error ~= 0 then
return empty_clipboard()
end
return { lines, read_regtype(regtype_path) }
end
end
vim.g.clipboard = {
name = "OmarchyWaylandClipboard",
copy = {
["+"] = copy("+"),
["*"] = copy("*"),
},
paste = {
["+"] = paste("+"),
["*"] = paste("*"),
},
cache_enabled = 0,
}
else
local clipboard_path = runtime_dir .. "/nvim-remote-clipboard.json"
local function copy(register)
return function(lines, regtype)
write_private_file(clipboard_path, vim.fn.json_encode({
lines = lines,
regtype = regtype,
created_at = os.time(),
}))
copy_to_client_clipboard(register, lines)
end
end
local function read_clipboard()
local stat = uv.fs_stat(clipboard_path)
if not stat or stat.uid ~= uv.getuid() or stat.mode % 512 ~= 384 then
return empty_clipboard()
end
local ok, file_lines = pcall(vim.fn.readfile, clipboard_path)
if not ok or #file_lines == 0 then
return empty_clipboard()
end
local ok_decode, data = pcall(vim.fn.json_decode, table.concat(file_lines, "\n"))
if not ok_decode or type(data) ~= "table" or type(data.lines) ~= "table" then
return empty_clipboard()
end
if type(data.created_at) == "number" and os.time() - data.created_at > ttl_seconds then
vim.fn.delete(clipboard_path)
return empty_clipboard()
end
return { data.lines, data.regtype or "v" }
end
vim.g.clipboard = {
name = "RemoteRuntimeClipboard",
copy = {
["+"] = copy("+"),
["*"] = copy("*"),
},
paste = {
["+"] = read_clipboard,
["*"] = read_clipboard,
},
cache_enabled = 0,
}
end
end
return M
@@ -0,0 +1,179 @@
-- Clipboard for sessions whose yanks may need to reach another machine, so that
-- yanking, deleting and putting behave the same whether Neovim is local, in
-- tmux, over SSH, or any combination of those. LazyVim empties 'clipboard'
-- whenever SSH_CONNECTION is set, which leaves a plain `y` in the unnamed
-- register; this restores 'unnamedplus' and backs it with a provider that has a
-- fast path in every one of those environments.
--
-- Writes go to whichever transports the session has: the local Wayland
-- clipboard when there is a display, and the terminal on the other end of the
-- connection via OSC 52 -- emitted by tmux itself where possible, since `tmux
-- load-buffer -w` keeps the payload out of tmux's own input parser and its size
-- limit. Reads prefer whichever clipboard the person is actually sitting in
-- front of, and fall back to the last value this Neovim copied rather than
-- blocking on an OSC 52 query that most terminals refuse to answer.
local M = {}
local function proc_lines(pid, file)
local ok, lines = pcall(vim.fn.readfile, "/proc/" .. pid .. "/" .. file)
return ok and lines or {}
end
local function proc_ppid(pid)
for _, line in ipairs(proc_lines(pid, "status")) do
local ppid = line:match("^PPid:%s+(%d+)")
if ppid then
return tonumber(ppid)
end
end
end
local function ancestor_process_named(name)
local pid = vim.fn.getpid()
for _ = 1, 16 do
local ppid = proc_ppid(pid)
if not ppid or ppid <= 1 then
return false
end
local comm = proc_lines(ppid, "comm")[1] or ""
if comm:find(name, 1, true) then
return true
end
pid = ppid
end
return false
end
local function empty(lines)
return lines == nil or #lines == 0 or (#lines == 1 and lines[1] == "")
end
function M.setup()
local in_tmux = vim.env.TMUX ~= nil
local in_ssh = vim.env.SSH_TTY ~= nil or vim.env.SSH_CONNECTION ~= nil
local in_herdr = vim.env.HERDR_PANE_ID ~= nil or ancestor_process_named("herdr")
if not (in_tmux or in_ssh or in_herdr) then
return
end
local osc52 = require("vim.ui.clipboard.osc52")
local has_wayland = vim.env.WAYLAND_DISPLAY ~= nil
and vim.fn.executable("wl-copy") == 1
and vim.fn.executable("wl-paste") == 1
local has_tmux = in_tmux and vim.fn.executable("tmux") == 1
-- The last value this Neovim put on the clipboard, per register, as
-- { lines, regtype }. A session with no readable clipboard still has to
-- answer a put, and answering it with our own last copy keeps `"+y` `"+p`
-- working instead of failing with E353.
local last_copy = {}
local function copy(register)
local emit = osc52.copy(register)
return function(lines, regtype)
last_copy[register] = { lines, regtype or "" }
if has_wayland then
local cmd = { "wl-copy", "--sensitive", "--type", "text/plain" }
if register == "*" then
cmd[#cmd + 1] = "--primary"
end
vim.fn.system(cmd, lines)
end
if vim.g.omarchy_remote_clipboard_osc52 == false then
return
end
-- tmux has no notion of a primary selection, so `*` keeps using the
-- escape sequence directly.
if has_tmux and register == "+" then
vim.fn.system({ "tmux", "load-buffer", "-w", "-" }, lines)
else
emit(lines)
end
end
end
local function read_wayland(register)
local cmd = { "wl-paste", "--no-newline" }
if register == "*" then
cmd[#cmd + 1] = "--primary"
end
local lines = vim.fn.systemlist(cmd, "", 1)
if vim.v.shell_error == 0 then
return lines
end
end
-- `refresh-client -l` asks the attached client for its clipboard; the short
-- sleep gives the reply time to land before the buffer is read. A terminal
-- that refuses simply leaves tmux's newest buffer in place, so this returns
-- something useful either way and never blocks the way an OSC 52 query does.
local function read_tmux()
local lines = vim.fn.systemlist(
{ "sh", "-c", "tmux refresh-client -l 2>/dev/null; sleep 0.05; tmux save-buffer -" },
"",
1
)
if vim.v.shell_error == 0 then
return lines
end
end
local function read_osc52(register)
if not (vim.g.termfeatures or {}).osc52 then
return
end
local result = osc52.paste(register)()
if type(result) == "table" then
return result
end
end
local function paste(register)
return function()
local lines
-- Over SSH the clipboard worth reading is the one on the machine the
-- person is sitting at, which is the far end of the connection, not the
-- display this Neovim happens to have.
if in_ssh and has_tmux then
lines = read_tmux()
elseif has_wayland then
lines = read_wayland(register)
elseif has_tmux then
lines = read_tmux()
else
lines = read_osc52(register)
end
if not empty(lines) then
return lines
end
return last_copy[register] or { vim.fn.getreg('"', 1, true), vim.fn.getregtype('"') }
end
end
vim.g.clipboard = {
name = "OmarchyRemoteClipboard",
copy = { ["+"] = copy("+"), ["*"] = copy("*") },
paste = { ["+"] = paste("+"), ["*"] = paste("*") },
cache_enabled = 0,
}
-- LazyVim empties this whenever SSH_CONNECTION is set. Restore it so yanking,
-- deleting and putting reach the clipboard exactly as they do locally.
vim.opt.clipboard = "unnamedplus"
end
return M
+113
View File
@@ -0,0 +1,113 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
test_home=$(mktemp -d)
trap 'rm -rf "$test_home"' EXIT
provider="$test_home/.config/nvim/lua/config/remote_clipboard.lua"
mkdir -p "$(dirname "$provider")" "$test_home/bin"
# Redirect only the packaged source path; run the actual migration logic.
sed "s@/usr/share/omarchy-nvim/config/lua/config/remote_clipboard.lua@$test_home/package.lua@" \
"$ROOT/migrations/1788996284.sh" >"$test_home/migration.sh"
printf '%s\n' '-- corrected packaged provider' >"$test_home/package.lua"
cat >"$test_home/bin/pacman" <<'STUB'
#!/bin/bash
[[ $* == '-Q omarchy-nvim' ]] || exit 1
printf 'omarchy-nvim %s\n' "${TEST_NVIM_VERSION:-2026.9.21-2}"
STUB
chmod +x "$test_home/bin/pacman"
run_migration() {
env HOME="$test_home" PATH="$test_home/bin:$PATH" bash -euo pipefail "$test_home/migration.sh"
}
run_migration
[[ ! -e $provider ]] || fail "missing provider is left alone"
pass "missing provider is left alone"
for fixture in "$SHELL_TEST_DIR/fixtures/neovim-clipboard/"*.lua; do
cp "$fixture" "$provider"
run_migration
cmp "$provider" "$test_home/package.lua" || fail "known provider is upgraded: $fixture"
backup=$(ls -t "$provider".bak.* | head -n1)
cmp "$backup" "$fixture" || fail "known provider is backed up: $fixture"
[[ $(stat -c %a "$provider") == "644" ]] || fail "provider is mode 0644"
before=$(ls "$provider".bak.*)
run_migration
[[ $(ls "$provider".bak.*) == "$before" ]] || fail "repeat migration does not create backups"
pass "known provider is backed up and upgraded idempotently: ${fixture##*/}"
done
cp "$SHELL_TEST_DIR/fixtures/neovim-clipboard/july.lua" "$provider"
printf '%s\n' '-- user customization' >>"$provider"
cp "$provider" "$test_home/custom.lua"
run_migration >"$test_home/output"
cmp "$provider" "$test_home/custom.lua" || fail "customized provider is preserved"
grep -q 'Preserving customized' "$test_home/output" || fail "customized provider receives guidance"
printf '%s\n' '-- unrelated provider' >"$provider"
cp "$provider" "$test_home/custom.lua"
run_migration
cmp "$provider" "$test_home/custom.lua" || fail "unrelated provider is preserved"
pass "customized and unrelated providers are preserved"
cp "$SHELL_TEST_DIR/fixtures/neovim-clipboard/june.lua" "$provider"
for TEST_NVIM_VERSION in 2026.8.13-1 2026.8.13-2 2026.9.21-1; do
export TEST_NVIM_VERSION
if run_migration; then fail "old package leaves migration pending: $TEST_NVIM_VERSION"; fi
cmp "$provider" "$SHELL_TEST_DIR/fixtures/neovim-clipboard/june.lua" || fail "old package leaves provider unchanged"
done
unset TEST_NVIM_VERSION
mv "$test_home/package.lua" "$test_home/package.saved"
if run_migration; then fail "missing package source leaves migration pending"; fi
cmp "$provider" "$SHELL_TEST_DIR/fixtures/neovim-clipboard/june.lua" || fail "missing source leaves provider unchanged"
pass "old or missing package cannot mark an unrepaired provider complete"
mv "$test_home/package.saved" "$test_home/package.lua"
# A write that fails after producing partial output must never damage the live
# provider. Exercise the real install destination with a failing replacement.
cat >"$test_home/bin/install" <<'STUB'
#!/bin/bash
printf '%s' '-- truncated replacement' >"${@: -1}"
exit 1
STUB
chmod +x "$test_home/bin/install"
if run_migration; then fail "failed staging write leaves migration pending"; fi
cmp "$provider" "$SHELL_TEST_DIR/fixtures/neovim-clipboard/june.lua" || fail "failed write preserves live provider"
if compgen -G "$provider.new.*" >/dev/null; then fail "failed staging file is cleaned up"; fi
rm "$test_home/bin/install"
run_migration
cmp "$provider" "$test_home/package.lua" || fail "retry repairs the recognized original"
pass "failed write preserves original and retry succeeds"
# Also fail the final rename, after successful staging.
cp "$SHELL_TEST_DIR/fixtures/neovim-clipboard/july.lua" "$provider"
printf '#!/bin/bash\nexit 1\n' >"$test_home/bin/mv"
chmod +x "$test_home/bin/mv"
if run_migration; then fail "failed rename leaves migration pending"; fi
cmp "$provider" "$SHELL_TEST_DIR/fixtures/neovim-clipboard/july.lua" || fail "failed rename preserves original"
if compgen -G "$provider.new.*" >/dev/null; then fail "failed rename staging file is cleaned up"; fi
rm "$test_home/bin/mv"
run_migration
cmp "$provider" "$test_home/package.lua" || fail "retry after rename failure succeeds"
pass "failed rename preserves original and retry succeeds"
cp "$SHELL_TEST_DIR/fixtures/neovim-clipboard/july.lua" "$test_home/dotfile.lua"
rm "$provider"
ln -s "$test_home/dotfile.lua" "$provider"
run_migration >"$test_home/output"
[[ -L $provider ]] || fail "provider symlink is preserved"
cmp "$test_home/dotfile.lua" "$SHELL_TEST_DIR/fixtures/neovim-clipboard/july.lua" || fail "symlink target is preserved"
grep -q 'symlink-managed' "$test_home/output" || fail "linked provider receives guidance"
rm "$provider"
ln -s "$test_home/absent.lua" "$provider"
run_migration
[[ -L $provider && ! -e $provider ]] || fail "dangling symlink is preserved"
rm "$provider"
mkdir "$test_home/dotfiles"
mv "$test_home/.config/nvim/lua/config" "$test_home/dotfiles/config"
ln -s "$test_home/dotfiles/config" "$test_home/.config/nvim/lua/config"
cp "$SHELL_TEST_DIR/fixtures/neovim-clipboard/july.lua" "$provider"
run_migration
[[ -L $test_home/.config/nvim/lua/config ]] || fail "linked config directory is preserved"
cmp "$provider" "$SHELL_TEST_DIR/fixtures/neovim-clipboard/july.lua" || fail "provider in linked directory is preserved"
pass "linked files, dangling links and linked directories are preserved"