From b15c8b49d333431541f12640548d11111df99021 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Tue, 8 Sep 2026 23:18:00 +1000 Subject: [PATCH] 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 --- .config/nvim/lua/func/worktree.lua | 105 ++++++++++++++++++++------- .config/nvim/tests/worktree_spec.lua | 86 +++++++++++++++++++++- 2 files changed, 162 insertions(+), 29 deletions(-) diff --git a/.config/nvim/lua/func/worktree.lua b/.config/nvim/lua/func/worktree.lua index 3fe9a01..ee67d53 100644 --- a/.config/nvim/lua/func/worktree.lua +++ b/.config/nvim/lua/func/worktree.lua @@ -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 diff --git a/.config/nvim/tests/worktree_spec.lua b/.config/nvim/tests/worktree_spec.lua index efb7229..ffbf9f7 100644 --- a/.config/nvim/tests/worktree_spec.lua +++ b/.config/nvim/tests/worktree_spec.lua @@ -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)