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:
@@ -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
|
||||
|
||||
@@ -28,8 +28,19 @@ local function wipe_all()
|
||||
end
|
||||
end
|
||||
|
||||
local function capture_notify(fn)
|
||||
local seen, orig = {}, vim.notify
|
||||
vim.notify = function(msg, level)
|
||||
table.insert(seen, { msg = msg, level = level })
|
||||
end
|
||||
local ok, err = pcall(fn)
|
||||
vim.notify = orig
|
||||
assert(ok, err)
|
||||
return seen
|
||||
end
|
||||
|
||||
describe('func.worktree', function()
|
||||
local tmp, main, other
|
||||
local tmp, main, other, outside
|
||||
|
||||
before_each(function()
|
||||
tmp = vim.fn.tempname()
|
||||
@@ -37,6 +48,11 @@ describe('func.worktree', function()
|
||||
tmp = realpath(tmp)
|
||||
main = tmp .. '/main'
|
||||
other = tmp .. '/other'
|
||||
outside = tmp .. '/outside'
|
||||
|
||||
vim.fn.mkdir(outside, 'p')
|
||||
sh({ 'git', '-C', outside, 'init', '-q', '-b', 'main' })
|
||||
write(outside .. '/note.txt', 'unrelated')
|
||||
|
||||
vim.fn.mkdir(main, 'p')
|
||||
sh({ 'git', '-C', main, 'init', '-q', '-b', 'main' })
|
||||
@@ -172,4 +188,72 @@ describe('func.worktree', function()
|
||||
assert.same({}, summary.moved)
|
||||
assert.equals(realpath(main), summary.root)
|
||||
end)
|
||||
|
||||
it('moves buffers when the active buffer belongs to another repository', function()
|
||||
open(main .. '/one.txt')
|
||||
local stray = open(outside .. '/note.txt')
|
||||
|
||||
local summary = worktree.switch(other)
|
||||
|
||||
assert.is_not_nil(summary)
|
||||
assert.same({ 'one.txt' }, summary.moved)
|
||||
assert.equals(realpath(other) .. '/one.txt', vim.api.nvim_buf_get_name(vim.fn.bufnr(realpath(other) .. '/one.txt')))
|
||||
assert.is_true(vim.api.nvim_buf_is_valid(stray))
|
||||
assert.equals(realpath(outside) .. '/note.txt', vim.api.nvim_buf_get_name(stray))
|
||||
end)
|
||||
|
||||
it('warns instead of silently moving nothing', function()
|
||||
open(outside .. '/note.txt')
|
||||
|
||||
local seen
|
||||
local summary = nil
|
||||
seen = capture_notify(function()
|
||||
summary = worktree.switch(other)
|
||||
end)
|
||||
|
||||
assert.same({}, summary.moved)
|
||||
assert.equals(1, #seen)
|
||||
assert.is_truthy(seen[1].msg:match('outside this repository'))
|
||||
assert.equals(vim.log.levels.WARN, seen[1].level)
|
||||
end)
|
||||
|
||||
it('keeps a per-window cursor when one buffer is in several windows', function()
|
||||
open(main .. '/one.txt')
|
||||
local top = vim.api.nvim_get_current_win()
|
||||
vim.api.nvim_win_set_cursor(top, { 2, 1 })
|
||||
vim.cmd('split')
|
||||
local bottom = vim.api.nvim_get_current_win()
|
||||
vim.api.nvim_win_set_cursor(bottom, { 4, 3 })
|
||||
|
||||
worktree.switch(other)
|
||||
|
||||
local dest = realpath(other) .. '/one.txt'
|
||||
assert.equals(dest, vim.api.nvim_buf_get_name(vim.api.nvim_win_get_buf(top)))
|
||||
assert.equals(dest, vim.api.nvim_buf_get_name(vim.api.nvim_win_get_buf(bottom)))
|
||||
assert.same({ 2, 1 }, vim.api.nvim_win_get_cursor(top))
|
||||
assert.same({ 4, 3 }, vim.api.nvim_win_get_cursor(bottom))
|
||||
end)
|
||||
|
||||
it('clamps a cursor to a position the buffer actually has', function()
|
||||
local buf = vim.api.nvim_create_buf(false, true)
|
||||
assert.same({ 1, 0 }, worktree.clamp_cursor(buf, { 0, 0 }))
|
||||
assert.same({ 1, 0 }, worktree.clamp_cursor(buf, { 9, 4 }))
|
||||
assert.same({ 1, 0 }, worktree.clamp_cursor(buf, { -3, -2 }))
|
||||
|
||||
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { 'alpha', 'bravo' })
|
||||
assert.same({ 2, 5 }, worktree.clamp_cursor(buf, { 7, 9 }))
|
||||
assert.same({ 2, 1 }, worktree.clamp_cursor(buf, { 2, 1 }))
|
||||
end)
|
||||
|
||||
it('lands on a valid line when the destination file is empty', function()
|
||||
vim.fn.writefile({}, other .. '/one.txt')
|
||||
open(main .. '/one.txt')
|
||||
local win = vim.api.nvim_get_current_win()
|
||||
vim.api.nvim_win_set_cursor(win, { 4, 2 })
|
||||
|
||||
worktree.switch(other)
|
||||
|
||||
assert.equals(realpath(other) .. '/one.txt', vim.api.nvim_buf_get_name(vim.api.nvim_win_get_buf(win)))
|
||||
assert.same({ 1, 0 }, vim.api.nvim_win_get_cursor(win))
|
||||
end)
|
||||
end)
|
||||
|
||||
Reference in New Issue
Block a user