fix(nvim): carry buffers from every sibling worktree

Buffer migration keyed off the active buffer's repo, so a switch made from
an unrelated buffer moved nothing and said nothing.

- Match each buffer against the target's own worktree list, longest root first.
- Warn when open buffers all live outside the repository.
- Capture and restore the cursor per window, not once from the first one.
- Clamp restored cursors to a line the destination buffer has.

Signed-off-by: unkin-agent <unkin-agent@unkin.net>
This commit is contained in:
2026-09-08 23:18:00 +10:00
parent 05a642b7a9
commit b15c8b49d3
2 changed files with 162 additions and 29 deletions
+77 -28
View File
@@ -124,10 +124,51 @@ local function windows_for(buf)
return wins
end
local function restore_cursor(win, buf, cursor)
local row = math.min(cursor[1], vim.api.nvim_buf_line_count(buf))
--- Fit `cursor` to `buf`, which may be shorter or empty. Rows are 1-based.
function M.clamp_cursor(buf, cursor)
local row = math.max(1, math.min(cursor[1], vim.api.nvim_buf_line_count(buf)))
local line = vim.api.nvim_buf_get_lines(buf, row - 1, row, false)[1] or ''
pcall(vim.api.nvim_win_set_cursor, win, { row, math.min(cursor[2], #line) })
return { row, math.max(0, math.min(cursor[2], #line)) }
end
local function restore_cursor(win, buf, cursor)
pcall(vim.api.nvim_win_set_cursor, win, M.clamp_cursor(buf, cursor))
end
--- Every worktree a buffer could be carried away from, longest path first so a
--- worktree nested inside another one wins the prefix match.
local function source_roots(new_root, from_root)
if from_root then
return { from_root }
end
local roots = {}
for _, tree in ipairs(M.list(new_root) or {}) do
if tree.path and not tree.bare and tree.path ~= new_root then
table.insert(roots, tree.path)
end
end
if #roots == 0 then
local fallback = detect_root(context_dir()) or normalise(vim.fn.getcwd())
if fallback and fallback ~= new_root then
roots = { fallback }
end
end
table.sort(roots, function(a, b)
return #a > #b
end)
return roots
end
local function root_of(path, roots)
for _, root in ipairs(roots) do
if path ~= root and contains(root, path) then
return root
end
end
return nil
end
local function names(list)
@@ -138,7 +179,7 @@ local function names(list)
return out
end
--- Switch the session to `target` and re-point buffers living under the old root.
--- Switch the session to `target` and re-point buffers living in its sibling worktrees.
--- Returns a summary table, or nil when the switch was refused.
function M.switch(target, opts)
opts = opts or {}
@@ -148,20 +189,15 @@ function M.switch(target, opts)
return nil
end
local old_root = normalise(opts.from_root)
or detect_root(context_dir())
or normalise(vim.fn.getcwd())
if old_root == new_root then
notify('already in ' .. new_root)
return { root = new_root, moved = {}, missing = {}, kept = {} }
end
local roots = source_roots(new_root, normalise(opts.from_root))
local bufs = listed_file_buffers()
local moved, missing, blocked, kept = {}, {}, {}, {}
for _, entry in ipairs(listed_file_buffers()) do
if contains(old_root, entry.name) and entry.name ~= old_root then
local rel = entry.name:sub(#old_root + 2)
local item = { buf = entry.buf, rel = rel, dest = new_root .. '/' .. rel }
for _, entry in ipairs(bufs) do
local root = root_of(entry.name, roots)
if root then
local rel = entry.name:sub(#root + 2)
local item = { buf = entry.buf, root = root, rel = rel, dest = new_root .. '/' .. rel }
local exists = vim.fn.filereadable(item.dest) == 1
if vim.bo[entry.buf].modified then
table.insert(exists and blocked or kept, item)
@@ -173,6 +209,12 @@ function M.switch(target, opts)
end
end
local matched = #moved + #missing + #blocked + #kept
if matched == 0 and normalise(vim.fn.getcwd()) == new_root then
notify('already in ' .. new_root)
return { root = new_root, moved = {}, missing = {}, kept = {} }
end
if #blocked > 0 then
notify(
'unsaved changes would be replaced; write them first: ' .. table.concat(names(blocked), ', '),
@@ -185,13 +227,18 @@ function M.switch(target, opts)
for _, item in ipairs(moved) do
local wins = windows_for(item.buf)
local cursor
if #wins > 0 then
cursor = vim.api.nvim_win_get_cursor(wins[1])
local cursors = {}
for _, win in ipairs(wins) do
cursors[win] = vim.api.nvim_win_get_cursor(win)
end
local last
if wins[1] then
last = cursors[wins[1]]
else
local mark = vim.api.nvim_buf_get_mark(item.buf, '"')
if mark[1] > 0 then
cursor = mark
last = mark
end
end
@@ -200,16 +247,14 @@ function M.switch(target, opts)
vim.bo[newbuf].buflisted = true
-- The `"` mark is what a hidden buffer is re-opened at, so carry it too.
if cursor then
local row = math.min(cursor[1], vim.api.nvim_buf_line_count(newbuf))
pcall(vim.api.nvim_buf_set_mark, newbuf, '"', row, cursor[2], {})
if last then
local pos = M.clamp_cursor(newbuf, last)
pcall(vim.api.nvim_buf_set_mark, newbuf, '"', pos[1], pos[2], {})
end
for _, win in ipairs(wins) do
vim.api.nvim_win_set_buf(win, newbuf)
if cursor then
restore_cursor(win, newbuf, cursor)
end
restore_cursor(win, newbuf, cursors[win])
end
if newbuf ~= item.buf and vim.api.nvim_buf_is_valid(item.buf) then
@@ -222,14 +267,14 @@ function M.switch(target, opts)
for _, list in ipairs({ missing, kept }) do
for _, item in ipairs(list) do
if vim.api.nvim_buf_is_valid(item.buf) then
vim.b[item.buf].worktree_orphan = old_root
vim.b[item.buf].worktree_orphan = item.root
end
end
end
local summary = {
root = new_root,
previous = old_root,
sources = roots,
moved = names(moved),
missing = names(missing),
kept = names(kept),
@@ -237,6 +282,10 @@ function M.switch(target, opts)
local msg = ('switched to %s (%d buffer(s) moved)'):format(new_root, #moved)
local level = vim.log.levels.INFO
if matched == 0 and #bufs > 0 then
msg = msg .. ('; %d open buffer(s) live outside this repository'):format(#bufs)
level = vim.log.levels.WARN
end
if #missing > 0 then
msg = msg .. '; no counterpart there: ' .. table.concat(summary.missing, ', ')
level = vim.log.levels.WARN