From 05a642b7a92a14006dcbbf15ca95068b7d0ee2ac Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 5 Sep 2026 11:19:42 +1000 Subject: [PATCH 1/3] feat(nvim): add git worktree picker that follows open buffers Adds a telescope picker over 'git worktree list --porcelain'. Selecting an entry cds to that worktree and re-points every open buffer at the same relative path there, keeping cursor position and refusing to clobber unsaved work. Signed-off-by: unkin-agent --- .config/nvim/lua/func.lua | 1 + .config/nvim/lua/func/worktree.lua | 359 ++++++++++++++++++ .../lua/telescope/_extensions/worktree.lua | 14 + .config/nvim/tests/minimal.lua | 15 + .config/nvim/tests/worktree_spec.lua | 175 +++++++++ 5 files changed, 564 insertions(+) create mode 100644 .config/nvim/lua/func/worktree.lua create mode 100644 .config/nvim/lua/telescope/_extensions/worktree.lua create mode 100644 .config/nvim/tests/minimal.lua create mode 100644 .config/nvim/tests/worktree_spec.lua diff --git a/.config/nvim/lua/func.lua b/.config/nvim/lua/func.lua index 1ea8a25..bc63334 100644 --- a/.config/nvim/lua/func.lua +++ b/.config/nvim/lua/func.lua @@ -6,6 +6,7 @@ require('func.tabline') require('func.popterm') require('func.sessions') require('func.findfile') +require('func.worktree') -- vim.cmd [[ -- augroup lang_all diff --git a/.config/nvim/lua/func/worktree.lua b/.config/nvim/lua/func/worktree.lua new file mode 100644 index 0000000..3fe9a01 --- /dev/null +++ b/.config/nvim/lua/func/worktree.lua @@ -0,0 +1,359 @@ +--[[ func/worktree.lua ]] +-- +-- Telescope picker over `git worktree list`. Picking an entry cds to that +-- worktree and re-points every open buffer at the same relative path inside it. +-- +-- gw pick a worktree +-- :Worktree [path] +-- :Telescope worktree + +local M = {} + +local uv = vim.uv or vim.loop + +local function notify(msg, level) + vim.notify(msg, level or vim.log.levels.INFO, { title = 'worktree' }) +end + +local function normalise(path) + if path == nil or path == '' then + return nil + end + local resolved = uv.fs_realpath(path) or path + local trimmed = resolved:gsub('/+$', '') + return trimmed +end + +local function contains(root, path) + return path == root or path:sub(1, #root + 1) == root .. '/' +end + +-- git commands run against the current buffer's directory, not just the cwd, +-- so the picker still works after wandering into another repo. +local function context_dir() + local name = vim.api.nvim_buf_get_name(0) + if name ~= '' and vim.bo.buftype == '' then + local dir = vim.fn.fnamemodify(name, ':p:h') + if vim.fn.isdirectory(dir) == 1 then + return dir + end + end + return vim.fn.getcwd() +end + +local function git(dir, args) + local cmd = { 'git', '-C', dir } + vim.list_extend(cmd, args) + local out = vim.fn.systemlist(cmd) + if vim.v.shell_error ~= 0 then + return nil, table.concat(out, ' ') + end + return out +end + +function M.list(dir) + dir = dir or context_dir() + local out, err = git(dir, { 'worktree', 'list', '--porcelain' }) + if not out then + return nil, err + end + + local trees, current = {}, nil + for _, line in ipairs(out) do + if line:match('^worktree ') then + current = { path = normalise(line:sub(10)) } + table.insert(trees, current) + elseif current then + if line == 'bare' then + current.bare = true + elseif line == 'detached' then + current.detached = true + elseif line:match('^HEAD ') then + current.head = line:sub(6) + elseif line:match('^branch ') then + current.branch = line:sub(8):gsub('^refs/heads/', '') + elseif line:match('^locked') then + current.locked = true + end + end + end + return trees +end + +function M.label(tree) + if tree.bare then + return '(bare)' + end + if tree.branch then + return tree.branch + end + if tree.head then + return '(detached ' .. tree.head:sub(1, 7) .. ')' + end + return '(unknown)' +end + +local function detect_root(dir) + local out = git(dir, { 'rev-parse', '--show-toplevel' }) + if out and out[1] and out[1] ~= '' then + return normalise(out[1]) + end + return nil +end + +local function listed_file_buffers() + local bufs = {} + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + if vim.api.nvim_buf_is_valid(buf) and vim.bo[buf].buflisted and vim.bo[buf].buftype == '' then + local name = vim.api.nvim_buf_get_name(buf) + if name ~= '' then + table.insert(bufs, { buf = buf, name = normalise(name) or name }) + end + end + end + return bufs +end + +local function windows_for(buf) + local wins = {} + for _, win in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == buf then + table.insert(wins, win) + end + end + return wins +end + +local function restore_cursor(win, buf, cursor) + local row = 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) }) +end + +local function names(list) + local out = {} + for _, item in ipairs(list) do + table.insert(out, item.rel) + end + return out +end + +--- Switch the session to `target` and re-point buffers living under the old root. +--- Returns a summary table, or nil when the switch was refused. +function M.switch(target, opts) + opts = opts or {} + local new_root = normalise(target) + if not new_root or vim.fn.isdirectory(new_root) == 0 then + notify('not a directory: ' .. tostring(target), vim.log.levels.ERROR) + 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 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 } + local exists = vim.fn.filereadable(item.dest) == 1 + if vim.bo[entry.buf].modified then + table.insert(exists and blocked or kept, item) + elseif exists then + table.insert(moved, item) + else + table.insert(missing, item) + end + end + end + + if #blocked > 0 then + notify( + 'unsaved changes would be replaced; write them first: ' .. table.concat(names(blocked), ', '), + vim.log.levels.ERROR + ) + return nil + end + + vim.cmd('cd ' .. vim.fn.fnameescape(new_root)) + + 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]) + else + local mark = vim.api.nvim_buf_get_mark(item.buf, '"') + if mark[1] > 0 then + cursor = mark + end + end + + local newbuf = vim.fn.bufadd(item.dest) + vim.fn.bufload(newbuf) + 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], {}) + end + + for _, win in ipairs(wins) do + vim.api.nvim_win_set_buf(win, newbuf) + if cursor then + restore_cursor(win, newbuf, cursor) + end + end + + if newbuf ~= item.buf and vim.api.nvim_buf_is_valid(item.buf) then + pcall(vim.api.nvim_buf_delete, item.buf, { force = false }) + end + end + + -- Buffers with no counterpart keep pointing at the old worktree; flag them + -- so it is obvious why they did not follow. + 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 + end + end + end + + local summary = { + root = new_root, + previous = old_root, + moved = names(moved), + missing = names(missing), + kept = names(kept), + } + + local msg = ('switched to %s (%d buffer(s) moved)'):format(new_root, #moved) + local level = vim.log.levels.INFO + if #missing > 0 then + msg = msg .. '; no counterpart there: ' .. table.concat(summary.missing, ', ') + level = vim.log.levels.WARN + end + if #kept > 0 then + msg = msg .. '; left unsaved on old worktree: ' .. table.concat(summary.kept, ', ') + level = vim.log.levels.WARN + end + notify(msg, level) + + return summary +end + +local function selectable(dir) + local trees, err = M.list(dir) + if not trees then + notify('no git worktrees here: ' .. (err or 'not a git repository'), vim.log.levels.ERROR) + return nil + end + local usable = {} + for _, tree in ipairs(trees) do + if not tree.bare and tree.path and vim.fn.isdirectory(tree.path) == 1 then + table.insert(usable, tree) + end + end + if #usable == 0 then + notify('no checked-out worktrees to switch to', vim.log.levels.WARN) + return nil + end + return usable +end + +function M.pick(opts) + opts = opts or {} + local usable = selectable(opts.cwd or context_dir()) + if not usable then + return + end + + local ok, pickers = pcall(require, 'telescope.pickers') + if not ok then + vim.ui.select(usable, { + prompt = 'Git worktree', + format_item = function(tree) + return M.label(tree) .. ' ' .. vim.fn.fnamemodify(tree.path, ':~') + end, + }, function(choice) + if choice then + M.switch(choice.path) + end + end) + return + end + + local finders = require('telescope.finders') + local conf = require('telescope.config').values + local actions = require('telescope.actions') + local action_state = require('telescope.actions.state') + local entry_display = require('telescope.pickers.entry_display') + + local width = 0 + for _, tree in ipairs(usable) do + width = math.max(width, #M.label(tree)) + end + local displayer = entry_display.create({ + separator = ' ', + items = { { width = width }, { remaining = true } }, + }) + + pickers + .new(opts, { + prompt_title = 'Git Worktrees', + finder = finders.new_table({ + results = usable, + entry_maker = function(tree) + return { + value = tree, + path = tree.path, + ordinal = M.label(tree) .. ' ' .. tree.path, + display = function(entry) + return displayer({ + M.label(entry.value), + vim.fn.fnamemodify(entry.value.path, ':~'), + }) + end, + } + end, + }), + sorter = conf.generic_sorter(opts), + attach_mappings = function(prompt_bufnr) + actions.select_default:replace(function() + local entry = action_state.get_selected_entry() + actions.close(prompt_bufnr) + if entry then + M.switch(entry.value.path) + end + end) + return true + end, + }) + :find() +end + +vim.api.nvim_create_user_command('Worktree', function(cmd) + if cmd.args ~= '' then + M.switch(cmd.args) + else + M.pick() + end +end, { nargs = '?', complete = 'dir', desc = 'Switch git worktree and follow open buffers' }) + +vim.keymap.set('n', 'gw', function() + M.pick() +end, { silent = true, desc = 'Git worktree picker' }) + +pcall(function() + require('telescope').load_extension('worktree') +end) + +return M diff --git a/.config/nvim/lua/telescope/_extensions/worktree.lua b/.config/nvim/lua/telescope/_extensions/worktree.lua new file mode 100644 index 0000000..e21fd67 --- /dev/null +++ b/.config/nvim/lua/telescope/_extensions/worktree.lua @@ -0,0 +1,14 @@ +--[[ telescope/_extensions/worktree.lua ]] + +local telescope = require('telescope') + +return telescope.register_extension({ + exports = { + worktree = function(opts) + require('func.worktree').pick(opts) + end, + list = function(opts) + require('func.worktree').pick(opts) + end, + }, +}) diff --git a/.config/nvim/tests/minimal.lua b/.config/nvim/tests/minimal.lua new file mode 100644 index 0000000..b69f78c --- /dev/null +++ b/.config/nvim/tests/minimal.lua @@ -0,0 +1,15 @@ +--[[ tests/minimal.lua ]] +-- +-- nvim --headless -u tests/minimal.lua -c "PlenaryBustedDirectory tests/" + +local here = debug.getinfo(1, 'S').source:sub(2) +local config = vim.fn.fnamemodify(here, ':h:h') + +vim.opt.runtimepath:prepend(config) + +local plenary = vim.fn.expand('~/.local/share/nvim/site/pack/packer/start/plenary.nvim') +if vim.fn.isdirectory(plenary) == 1 then + vim.opt.runtimepath:append(plenary) +end + +vim.cmd('runtime plugin/plenary.vim') diff --git a/.config/nvim/tests/worktree_spec.lua b/.config/nvim/tests/worktree_spec.lua new file mode 100644 index 0000000..efb7229 --- /dev/null +++ b/.config/nvim/tests/worktree_spec.lua @@ -0,0 +1,175 @@ +--[[ tests/worktree_spec.lua ]] + +local worktree = require('func.worktree') + +local function sh(cmd) + local out = vim.fn.system(cmd) + assert(vim.v.shell_error == 0, table.concat(cmd, ' ') .. ' failed: ' .. out) + return out +end + +local function write(path, text) + vim.fn.mkdir(vim.fn.fnamemodify(path, ':h'), 'p') + vim.fn.writefile(vim.split(text, '\n'), path) +end + +local function realpath(path) + return (vim.uv or vim.loop).fs_realpath(path) +end + +local function open(path) + vim.cmd('edit ' .. vim.fn.fnameescape(path)) + return vim.api.nvim_get_current_buf() +end + +local function wipe_all() + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + pcall(vim.api.nvim_buf_delete, buf, { force = true }) + end +end + +describe('func.worktree', function() + local tmp, main, other + + before_each(function() + tmp = vim.fn.tempname() + vim.fn.mkdir(tmp, 'p') + tmp = realpath(tmp) + main = tmp .. '/main' + other = tmp .. '/other' + + vim.fn.mkdir(main, 'p') + sh({ 'git', '-C', main, 'init', '-q', '-b', 'main' }) + sh({ 'git', '-C', main, 'config', 'user.email', 'test@example.com' }) + sh({ 'git', '-C', main, 'config', 'user.name', 'test' }) + write(main .. '/one.txt', 'alpha\nbravo\ncharlie\ndelta') + write(main .. '/sub/two.txt', 'two') + sh({ 'git', '-C', main, 'add', '-A' }) + sh({ 'git', '-C', main, 'commit', '-qm', 'init' }) + sh({ 'git', '-C', main, 'worktree', 'add', '-q', '-b', 'feature', other }) + + write(main .. '/main-only.txt', 'only in main') + sh({ 'git', '-C', main, 'add', '-A' }) + sh({ 'git', '-C', main, 'commit', '-qm', 'main only' }) + + wipe_all() + vim.cmd('cd ' .. vim.fn.fnameescape(main)) + end) + + after_each(function() + wipe_all() + vim.cmd('cd /') + vim.fn.delete(tmp, 'rf') + end) + + it('lists every worktree of the repo', function() + local trees = worktree.list(main) + assert.equals(2, #trees) + + local branches = {} + for _, tree in ipairs(trees) do + branches[tree.branch] = tree.path + end + assert.equals(realpath(main), branches['main']) + assert.equals(realpath(other), branches['feature']) + end) + + it('reports an error outside a repository', function() + local trees, err = worktree.list(tmp) + assert.is_nil(trees) + assert.is_true(#err > 0) + end) + + it('labels detached and bare entries', function() + assert.equals('feature', worktree.label({ branch = 'feature' })) + assert.equals('(bare)', worktree.label({ bare = true })) + assert.equals('(detached abcdef1)', worktree.label({ head = 'abcdef1234567890' })) + end) + + it('re-points buffers at the same relative path', function() + local one = open(main .. '/one.txt') + open(main .. '/sub/two.txt') + + local summary = worktree.switch(other) + + assert.is_not_nil(summary) + assert.equals(realpath(other), summary.root) + assert.equals(realpath(other), realpath(vim.fn.getcwd())) + table.sort(summary.moved) + assert.same({ 'one.txt', 'sub/two.txt' }, summary.moved) + assert.is_false(vim.api.nvim_buf_is_valid(one)) + + local open_names = {} + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + if vim.bo[buf].buflisted then + open_names[vim.api.nvim_buf_get_name(buf)] = true + end + end + assert.is_true(open_names[realpath(other) .. '/one.txt']) + assert.is_true(open_names[realpath(other) .. '/sub/two.txt']) + end) + + it('keeps the cursor of a displayed buffer', function() + open(main .. '/one.txt') + vim.api.nvim_win_set_cursor(0, { 3, 2 }) + local one_win = vim.api.nvim_get_current_win() + vim.cmd('split') + open(main .. '/sub/two.txt') + + worktree.switch(other) + + assert.equals(realpath(other) .. '/one.txt', vim.api.nvim_buf_get_name(vim.api.nvim_win_get_buf(one_win))) + assert.same({ 3, 2 }, vim.api.nvim_win_get_cursor(one_win)) + end) + + it('carries the last position of a hidden buffer', function() + open(main .. '/one.txt') + vim.api.nvim_win_set_cursor(0, { 3, 2 }) + open(main .. '/sub/two.txt') + + worktree.switch(other) + + local mark = vim.api.nvim_buf_get_mark(vim.fn.bufnr(realpath(other) .. '/one.txt'), '"') + assert.equals(3, mark[1]) + end) + + it('leaves and flags buffers with no counterpart', function() + local only = open(main .. '/main-only.txt') + + local summary = worktree.switch(other) + + assert.same({ 'main-only.txt' }, summary.missing) + assert.is_true(vim.api.nvim_buf_is_valid(only)) + assert.equals(realpath(main) .. '/main-only.txt', vim.api.nvim_buf_get_name(only)) + assert.equals(realpath(main), vim.b[only].worktree_orphan) + end) + + it('refuses to switch when an unsaved buffer would be replaced', function() + open(main .. '/one.txt') + vim.api.nvim_buf_set_lines(0, 0, 1, false, { 'dirty' }) + + local summary = worktree.switch(other) + + assert.is_nil(summary) + assert.equals(realpath(main), realpath(vim.fn.getcwd())) + assert.is_true(vim.bo.modified) + end) + + it('keeps unsaved buffers that have no counterpart', function() + local only = open(main .. '/main-only.txt') + vim.api.nvim_buf_set_lines(only, 0, 1, false, { 'dirty' }) + + local summary = worktree.switch(other) + + assert.is_not_nil(summary) + assert.same({ 'main-only.txt' }, summary.kept) + assert.is_true(vim.bo[only].modified) + assert.equals(realpath(main) .. '/main-only.txt', vim.api.nvim_buf_get_name(only)) + end) + + it('is a no-op when the target is the current worktree', function() + local summary = worktree.switch(main) + assert.same({}, summary.moved) + assert.equals(realpath(main), summary.root) + end) +end) -- 2.47.3 From b15c8b49d333431541f12640548d11111df99021 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Tue, 8 Sep 2026 23:18:00 +1000 Subject: [PATCH 2/3] 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) -- 2.47.3 From c4dbc24916caed1d7f6c96a8925e8d6b7220fd9f Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Tue, 8 Sep 2026 23:24:33 +1000 Subject: [PATCH 3/3] test(nvim): cover nested worktree resolution --- .config/nvim/tests/worktree_spec.lua | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.config/nvim/tests/worktree_spec.lua b/.config/nvim/tests/worktree_spec.lua index ffbf9f7..5d0bc2f 100644 --- a/.config/nvim/tests/worktree_spec.lua +++ b/.config/nvim/tests/worktree_spec.lua @@ -245,6 +245,19 @@ describe('func.worktree', function() assert.same({ 2, 1 }, worktree.clamp_cursor(buf, { 2, 1 })) end) + it('carries a buffer from the innermost worktree containing it', function() + local nested = main .. '/nested' + sh({ 'git', '-C', main, 'worktree', 'add', '-q', '-b', 'nested', nested }) + open(nested .. '/one.txt') + + local summary = worktree.switch(other) + + assert.is_not_nil(summary) + assert.same({ 'one.txt' }, summary.moved) + assert.same({}, summary.missing) + assert.equals(realpath(other) .. '/one.txt', vim.api.nvim_buf_get_name(vim.fn.bufnr(realpath(other) .. '/one.txt'))) + end) + it('lands on a valid line when the destination file is empty', function() vim.fn.writefile({}, other .. '/one.txt') open(main .. '/one.txt') -- 2.47.3