feat(nvim): git worktree picker that follows open buffers #1
@@ -6,6 +6,7 @@ require('func.tabline')
|
||||
require('func.popterm')
|
||||
require('func.sessions')
|
||||
require('func.findfile')
|
||||
require('func.worktree')
|
||||
|
||||
-- vim.cmd [[
|
||||
-- augroup lang_all
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
--[[ 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.
|
||||
--
|
||||
-- <Leader>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
|
||||
|
||||
--- 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 ''
|
||||
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)
|
||||
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 in its sibling worktrees.
|
||||
--- 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 roots = source_roots(new_root, normalise(opts.from_root))
|
||||
local bufs = listed_file_buffers()
|
||||
|
||||
local moved, missing, blocked, kept = {}, {}, {}, {}
|
||||
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)
|
||||
elseif exists then
|
||||
table.insert(moved, item)
|
||||
else
|
||||
table.insert(missing, item)
|
||||
end
|
||||
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), ', '),
|
||||
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 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
|
||||
last = 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 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)
|
||||
restore_cursor(win, newbuf, cursors[win])
|
||||
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 = item.root
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local summary = {
|
||||
root = new_root,
|
||||
sources = roots,
|
||||
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 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
|
||||
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', '<Leader>gw', function()
|
||||
M.pick()
|
||||
end, { silent = true, desc = 'Git worktree picker' })
|
||||
|
||||
pcall(function()
|
||||
require('telescope').load_extension('worktree')
|
||||
end)
|
||||
|
||||
return M
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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')
|
||||
@@ -0,0 +1,272 @@
|
||||
--[[ 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
|
||||
|
||||
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, outside
|
||||
|
||||
before_each(function()
|
||||
tmp = vim.fn.tempname()
|
||||
vim.fn.mkdir(tmp, 'p')
|
||||
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' })
|
||||
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)
|
||||
|
||||
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('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')
|
||||
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