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..ee67d53 --- /dev/null +++ b/.config/nvim/lua/func/worktree.lua @@ -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. +-- +-- 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', '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..5d0bc2f --- /dev/null +++ b/.config/nvim/tests/worktree_spec.lua @@ -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)