feat(git): worktree-aware branch switching (Telescope), repo conversion, explorer branch chip #9

Merged
sam_oneal merged 1 commits from feat/git-worktree into main 2026-09-20 19:25:51 +00:00
4 changed files with 942 additions and 7 deletions
+30 -1
View File
@@ -187,11 +187,38 @@ debug symbols first, then run (`Launch (zig build)` prompts for the exe under
### Git (`<leader>g`)
| Key | Action |
| ------------ | ------------------------- |
| ------------ | ------------------------------------------------------------------------ |
| `<leader>gg` | Lazygit (LazyVim default) |
| `<leader>gn` | Neogit |
| `<leader>gl` | Lazygit log |
| `<leader>gf` | Lazygit file history |
| `<leader>gw` | Git branches (worktree switcher, Telescope) |
| `<leader>gW` | Git worktrees (list/jump, Telescope) |
| `<leader>gC` | Convert current repo to worktree layout |
#### Git worktrees
`<leader>gw` opens a Telescope picker of **every branch** in the current project
(local + remote) and shows at a glance which ones are checked out on this
machine: the current branch is bold, branches with a worktree are highlighted
green with their path, and remote-only branches are dimmed with a globe icon.
- `<CR>` **switch** — jumps to the branch's worktree, creating it on demand.
Remote-only branches are pulled down as tracking local branches first.
- `<c-f>` **fetch remotes** — runs `git fetch --all --prune` so new remote
branches (e.g. `renovate/*`) appear in the list, then refreshes.
- `<c-d>` **remove** — deletes the selected worktree (or local-only branch).
- `<c-n>` **new** — prompts for a branch name (+ base) and creates its worktree.
- `<c-r>` **refresh** — re-reads local/remote state.
`:GitWorktreeConvert` (or `<leader>gC`) turns a plain single-checkout repo into
a worktree layout without losing anything: every local branch gets its own
linked worktree under `<parent>/.worktrees/<project>/<branch>` (main keeps its
place), so all branches that were on the machine stay available. Pass
`--keep=<branch>` to choose which branch remains at the main worktree.
The snacks explorer (`<C-e>`) shows a chip in its title with the current
project, branch, and worktree count when inside a git repo.
### Search / Symbols (`<leader>s`)
@@ -268,6 +295,7 @@ debug symbols first, then run (`Launch (zig build)` prompts for the exe under
│ │ ├── lazy.lua # lazy.nvim bootstrap, LazyVim + extras
│ │ ├── options.lua # Neovim options
│ │ ├── keymaps.lua # Custom keymaps
│ │ ├── git-worktree.lua # Worktree-aware branch switching + convert
│ │ └── autocmds.lua # Autocommands (yank hl, ft tweaks, SQL no-autoformat…)
│ ├── plugins/
│ │ ├── mason.lua # Tooling installs (LSP/linters/formatters/DAP)
@@ -281,6 +309,7 @@ debug symbols first, then run (`Launch (zig build)` prompts for the exe under
│ │ ├── ai.lua # opencode.nvim
│ │ ├── dadbod.lua # Database
│ │ ├── git.lua # Neogit + diffview
│ │ ├── worktree.lua # Git worktree keymaps (`<leader>gw/gW/gC`)
│ │ ├── editor.lua # snacks tweaks + which-key groups
│ │ ├── symbols.lua # aerial
│ │ ├── dashboard.lua # trouble opts
+867
View File
@@ -0,0 +1,867 @@
-- Worktree-aware git project switching.
--
-- Turns a plain single-worktree checkout into a worktree-style layout where
-- every local branch is checked out in its own linked worktree, and provides a
-- Telescope picker to switch between branches (creating / fetching worktrees
-- on demand).
--
-- Loaded lazily: the module is only required when a keymap, command or the
-- explorer title calls into it (see lua/plugins/worktree.lua and
-- config/keymaps.lua), so startup stays untouched.
local M = {}
-- ─────────────────────────────────────────────────────────────────────────────
-- Configuration
-- ─────────────────────────────────────────────────────────────────────────────
---@class git-worktree.Opts
M.config = {
-- Where linked worktrees are created. Defaults to `<parent>/.worktrees/<project>/<branch>`
-- (e.g. `~/src/.worktrees/desi_explorer/feat-api_connection`). Branch names
-- are sanitised so `/` maps to `-`: any valid branch becomes a valid path.
worktree_dir = function(branch, repo)
local parent = vim.fs.dirname(repo)
local project = vim.fs.basename(repo)
return parent .. "/.worktrees/" .. project .. "/" .. branch:gsub("/", "-")
end,
icons = {
-- Generated via nr2char so the raw PUA code points never hit the file.
current = vim.fn.nr2char(0xf0418), -- branch glyph: you are sitting in this worktree
checked = vim.fn.nr2char(0xf0418), -- branch glyph: checked out on this machine (another worktree)
branch = vim.fn.nr2char(0xf0418), -- branch glyph: local branch, no worktree yet
remote = vim.fn.nr2char(0xf0ac), -- globe glyph: remote-tracking branch, not on this machine yet
chip = vim.fn.nr2char(0xeb17), -- source-branch glyph: explorer title chip
},
highlights = {
current = "GitWorktreeCurrent",
checked = "GitWorktreeChecked",
branch = "GitWorktreeLocal",
remote = "GitWorktreeRemote",
path = "GitWorktreePath",
upstream = "GitWorktreeUpstream",
},
}
-- ─────────────────────────────────────────────────────────────────────────────
-- Git plumbing (all sync & cheap; the only async call is the `fetch`)
-- ─────────────────────────────────────────────────────────────────────────────
---Run git and wait for the result (used for fast reads).
---@param args string[]
---@param opts? {cwd?: string, timeout?: integer}
---@return {code: integer, stdout: string, stderr: string}
local function git(args, opts)
opts = opts or {}
local res = vim
.system(vim.list_extend({ "git" }, args), {
text = true,
cwd = opts.cwd or vim.uv.cwd(),
timeout = opts.timeout or 10000,
})
:wait()
return {
code = res.code,
stdout = (res.stdout or ""):gsub("%s+$", ""),
stderr = (res.stderr or ""):gsub("%s+$", ""),
}
end
---Run git in the background; `cb` receives {code, stdout, stderr}.
---@param args string[]
---@param cb fun(res: {code: integer, stdout: string, stderr: string})
local function git_async(args, cb)
vim.system(vim.list_extend({ "git" }, args), { text = true, cwd = vim.uv.cwd(), timeout = 150000 }, function(res)
cb({ code = res.code, stdout = (res.stdout or ""):gsub("%s+$", ""), stderr = (res.stderr or ""):gsub("%s+$", "") })
end)
end
---Absolute path to the git root of the cwd, or nil when not in a repo.
---@return string?
function M.git_root()
local res = git({ "rev-parse", "--show-toplevel" })
if res.code ~= 0 or res.stdout == "" then
return nil
end
return res.stdout -- already absolute; drop any trailing slash
end
---Name of the branch checked out in the current worktree (nil when detached).
---@return string?
function M.current_branch()
local res = git({ "symbolic-ref", "-q", "--short", "HEAD" })
if res.code ~= 0 then
return nil
end
return res.stdout
end
---@class git-worktree.Worktree
---@field path string
---@field branch? string
---@field detached? boolean
---@field head string abbreviated HEAD sha
---@field locked? boolean
---Parse `git worktree list --porcelain` into records.
---@return git-worktree.Worktree[]
function M.worktrees()
local res = git({ "worktree", "list", "--porcelain" })
if res.code ~= 0 then
return {}
end
local wts = {} ---@type git-worktree.Worktree[]
local cur ---@type git-worktree.Worktree?
for line in vim.gsplit(res.stdout, "\n", { plain = true }) do
if line == "" then
cur = nil
elseif line:sub(1, 8) == "worktree" then
cur = { path = vim.fn.fnamemodify(line:sub(10), ":p"):gsub("/+$", "") }
table.insert(wts, cur)
elseif line:sub(1, 6) == "branch" and cur then
cur.branch = line:sub(8):gsub("^refs/heads/", "")
elseif line:sub(1, 4) == "HEAD" and cur then
cur.head = line:sub(6):sub(1, 8)
elseif line == "detached" and cur then
cur.detached = true
elseif line == "locked" and cur then
cur.locked = true
end
end
return wts
end
---Names of all local branches.
---@return string[]
function M.local_branches()
local res = git({ "for-each-ref", "--format=%(refname:short)", "refs/heads" })
if res.code ~= 0 then
return {}
end
local names = {}
for line in vim.gsplit(res.stdout, "\n", { plain = true }) do
if line ~= "" then
names[#names + 1] = line
end
end
return names
end
---@class git-worktree.Branch
---@field name string
---@field current boolean checked out in the current worktree
---@field worktree string? path where this branch is checked out (nil = not on this machine yet)
---@field remote_only boolean only refs/remotes/* exists for this branch
---@field remote string? the remote-tracking ref, e.g. `origin/feat/x` (locals with an upstream / remote-only)
---@field track string upstream ahead/behind info, e.g. `[ahead 1]`
---@field date string relative committer date for ordering/display
---Merged list of every branch (local + remote-tracking), date-sorted.
---A branch that exists both locally and on a remote is a single local entry
---carrying its upstream; remote-tracking branches without a local twin are
---marked `remote_only` so the picker can offer to pull them down.
---@return git-worktree.Branch[]
function M.branches()
local fmt =
table.concat({ "%(refname)", "%(upstream:short)", "%(upstream:track)", "%(committerdate:relative)" }, "\x1f")
local res = git({
"for-each-ref",
"--format=" .. fmt,
"--sort=-committerdate",
"refs/heads",
"refs/remotes",
})
if res.code ~= 0 then
return {}
end
-- branch -> worktree path for everything checked out on this machine
local checked = {} ---@type table<string, string>
for _, wt in ipairs(M.worktrees()) do
if wt.branch then
checked[wt.branch] = wt.path
end
end
local current = M.current_branch()
-- Preserve for-each-ref's date ordering (newest first) across both namespaces
local ordered = {} ---@type {name: string, info: string[], remote: boolean}[]
for line in vim.gsplit(res.stdout, "\n", { plain = true }) do
if line ~= "" then
local parts = vim.split(line, "\x1f", { plain = true })
local ref, up, track, date = parts[1], parts[2], parts[3], parts[4]
if ref:sub(1, 11) == "refs/heads/" then
ordered[#ordered + 1] = { name = ref:sub(12), info = { up, track, date }, remote = false }
elseif ref:sub(1, 13) == "refs/remotes/" then
local name = ref:sub(14)
if not name:match("/HEAD$") then
ordered[#ordered + 1] = { name = name, info = { up, track, date }, remote = true }
end
end
end
end
local result = {} ---@type git-worktree.Branch[]
local seen = {} ---@type table<string, boolean>
local function push(b)
result[#result + 1] = b
seen[b.name] = true
end
for _, e in ipairs(ordered) do
if not e.remote then
push({
name = e.name,
current = e.name == current,
worktree = checked[e.name],
remote_only = false,
remote = e.info[1] ~= "" and e.info[1] or nil,
track = e.info[2],
date = e.info[3],
})
end
end
for _, e in ipairs(ordered) do
if e.remote then
-- A remote-tracking ref is the same branch as its local twin, so dedupe
-- against it and only surface the branches not on this machine yet.
local base = e.name:match("^[^/]+/(.*)$") or e.name
if not seen[base] then
push({
name = base,
current = false,
worktree = nil,
remote_only = true,
remote = e.name,
track = "",
date = e.info[3],
})
end
end
end
return result
end
-- ─────────────────────────────────────────────────────────────────────────────
-- Worktree operations
-- ─────────────────────────────────────────────────────────────────────────────
---Target directory for a branch's linked worktree.
---@param branch string
---@param repo string git root
---@return string
function M.worktree_dir(branch, repo)
return (M.config.worktree_dir or function(b, r)
local parent = vim.fs.dirname(r)
local project = vim.fs.basename(r)
return parent .. "/.worktrees/" .. project .. "/" .. b:gsub("/", "-")
end)(branch, repo)
end
---Materialise `entry` as a linked worktree (creating a tracking local branch
---for remote-only refs). No-op when the entry already has a worktree.
---@param entry git-worktree.Branch
---@return {ok: boolean, path: string?, err: string?}
function M.ensure_worktree(entry)
if entry.worktree then
return { ok = true, path = entry.worktree, err = nil }
end
local repo = M.git_root()
if not repo then
return { ok = false, path = nil, err = "Not inside a git repo" }
end
local dir = M.worktree_dir(entry.name, repo)
if vim.fn.isdirectory(dir) == 1 then
return { ok = false, path = nil, err = "Target exists and is not a registered worktree: " .. dir }
end
vim.fn.mkdir(vim.fn.fnamemodify(dir, ":h"), "p")
if entry.remote_only and entry.remote then
-- Refresh the remote-tracking ref, then create a tracking local branch
local remote = entry.remote:match("^([^/]+)/") or "origin"
git({ "fetch", "--quiet", remote, "refs/heads/" .. entry.name .. ":refs/remotes/" .. entry.remote })
local res = git({ "worktree", "add", "--track", "-b", entry.name, dir, "--", entry.remote })
if res.code ~= 0 then
return { ok = false, path = nil, err = res.stderr ~= "" and res.stderr or res.stdout }
end
else
local res = git({ "worktree", "add", dir, "--", entry.name })
if res.code ~= 0 then
return { ok = false, path = nil, err = res.stderr ~= "" and res.stderr or res.stdout }
end
end
return { ok = true, path = dir, err = nil }
end
---Remove a worktree (and its branch when merged) or a local-only branch.
---@param entry git-worktree.Branch
function M.remove_entry(entry)
if entry.remote_only then
vim.notify(("Remote-only branch %s has no local worktree to remove"):format(entry.name), vim.log.levels.INFO)
return
end
if entry.worktree then
if entry.worktree == M.git_root() then
vim.notify("Cannot remove the main worktree (" .. entry.worktree .. ")", vim.log.levels.WARN)
return
end
local choice = vim.fn.confirm(("Remove worktree for %s?\n%s"):format(entry.name, entry.worktree), "&Yes\n&No", 2)
if choice ~= 1 then
return
end
local res = git({ "worktree", "remove", entry.worktree })
if res.code ~= 0 then
-- worktrees with uncommitted changes refuse removal; offer a force remove
local force = vim.fn.confirm(
("Could not remove worktree:\n%s\nForce remove (discards changes)?"):format(res.stderr or res.stdout),
"&Force\n&Cancel",
2
)
if force ~= 1 then
vim.notify("Worktree removal cancelled", vim.log.levels.INFO)
return
end
res = git({ "worktree", "remove", "--force", entry.worktree })
if res.code ~= 0 then
vim.notify("git worktree remove failed:\n" .. res.stderr, vim.log.levels.ERROR)
return
end
vim.notify(("Force-removed worktree for %s"):format(entry.name), vim.log.levels.WARN)
end
local del = git({ "branch", "-d", entry.name })
if del.code ~= 0 then
-- not merged into the current branch; prune anyway, reporting it
del = git({ "branch", "-D", entry.name })
vim.notify(("Removed worktree for %s; branch not merged, force-deleted"):format(entry.name), vim.log.levels.WARN)
else
vim.notify(("Removed worktree and branch %s"):format(entry.name), vim.log.levels.INFO)
end
else
local choice = vim.fn.confirm(("Delete local branch %s?"):format(entry.name), "&Yes\n&No", 2)
if choice ~= 1 then
return
end
local res = git({ "branch", "-d", entry.name })
if res.code ~= 0 then
res = git({ "branch", "-D", entry.name })
end
if res.code == 0 then
vim.notify(("Deleted branch %s"):format(entry.name), vim.log.levels.INFO)
else
vim.notify(("Could not delete %s:\n%s"):format(entry.name, res.stderr), vim.log.levels.ERROR)
end
end
end
---Prompt for a new branch name (+ optional base) and materialise it as a worktree.
---@param entry? git-worktree.Branch base for the new branch (current HEAD when nil)
function M.new_worktree(entry)
vim.ui.input({ prompt = "New branch name: " }, function(name)
if not name or name == "" then
return
end
if name:match("^%s*$") or name:match("[ ~^:?*[\\]") then
vim.notify("Invalid branch name: " .. name, vim.log.levels.ERROR)
return
end
local base = entry and (entry.remote_only and entry.remote or entry.name) or nil
vim.ui.input({ prompt = ("Base branch (default: %s): "):format(base or "current HEAD") }, function(base_name)
if base_name ~= nil and (base_name == "" or base_name:sub(1, 1) == ":") then
base_name = nil -- empty input → default
end
local repo = M.git_root()
if not repo then
return
end
local dir = M.worktree_dir(name, repo)
if vim.fn.isdirectory(dir) == 1 then
vim.notify("Target already exists: " .. dir, vim.log.levels.ERROR)
return
end
vim.fn.mkdir(vim.fn.fnamemodify(dir, ":h"), "p")
local cmd = { "worktree", "add", "--track", "-b", name, dir }
if base_name then
cmd[#cmd + 1] = base_name
end
local res = git(cmd)
if res.code == 0 then
M.open_worktree(dir, name)
else
vim.notify(("git worktree add failed:\n%s"):format(res.stderr), vim.log.levels.ERROR)
end
end)
end)
end
---Fetch every remote (pruning deleted branches), then notify.
---@param cb? fun() called after the fetch finishes
function M.fetch_all(cb)
vim.notify("Fetching remotes…", vim.log.levels.INFO)
git_async({ "fetch", "--all", "--prune" }, function(res)
vim.schedule(function()
if res.code == 0 then
vim.notify("Remotes fetched — new remote branches are now available", vim.log.levels.INFO)
else
vim.notify("git fetch failed:\n" .. res.stderr, vim.log.levels.WARN)
end
if cb then
cb()
end
end)
end)
end
-- ─────────────────────────────────────────────────────────────────────────────
-- Switching
-- ─────────────────────────────────────────────────────────────────────────────
---Open a worktree in the explorer (skipped when nvim has no UI yet).
---@param path string
---@param branch? string
function M.open_worktree(path, branch)
if vim.v.vim_did_enter == 0 then
return -- called during startup / headless runs; nothing to show yet
end
vim.schedule(function()
if vim.fn.isdirectory(path) ~= 1 then
vim.notify("Worktree not found: " .. path, vim.log.levels.ERROR)
return
end
vim.api.nvim_set_current_dir(path)
local label = branch and ("[" .. branch .. "] ") or ""
vim.notify(("Now in worktree %s%s"):format(label, path), vim.log.levels.INFO)
if package.loaded["snacks"] then
Snacks.explorer({ title = M.explorer_title(path) })
end
end)
end
---Switch to `entry`: jump to its worktree, create it if missing (pulling
---remote-only branches down first), then open the worktree in the explorer.
---@param entry git-worktree.Branch
function M.switch(entry)
if entry.current then
vim.notify(("Already on branch %s in the current worktree"):format(entry.name), vim.log.levels.INFO)
return
end
if entry.remote_only then
vim.notify(("Pulling remote branch %s into a worktree…"):format(entry.remote), vim.log.levels.INFO)
end
local res = M.ensure_worktree(entry)
if not res.ok then
vim.notify(("Could not switch to %s:\n%s"):format(entry.name, res.err or "unknown error"), vim.log.levels.ERROR)
return
end
if entry.remote_only then
vim.notify(("Branch %s now tracks %s"):format(entry.name, entry.remote), vim.log.levels.INFO)
end
M.open_worktree(res.path, entry.name)
end
---Convert the current repo from a single-worktree checkout into a worktree
---layout: every local branch gets its own linked worktree, so no branch that
---was on the machine is lost. The branch in `keep` (default: the current one)
---stays checked out in the main worktree.
---@param opts? {keep?: string}
function M.convert(opts)
opts = opts or {}
local repo = M.git_root()
if not repo then
vim.notify("Not inside a git repo", vim.log.levels.ERROR)
return
end
local project = vim.fs.basename(repo)
local cur = M.current_branch()
local keep = opts.keep or cur
if not keep then
vim.notify(
("Detached HEAD — pass --keep=<branch> to choose which branch stays at %s"):format(repo),
vim.log.levels.WARN
)
return
end
if keep ~= cur then
local res = git({ "switch", "--", keep })
if res.code ~= 0 then
vim.notify(("Could not switch to --keep branch %s:\n%s"):format(keep, res.stderr), vim.log.levels.ERROR)
return
end
cur = keep
end
local branches = M.local_branches()
local todo = {}
for _, b in ipairs(branches) do
if b ~= keep then
todo[#todo + 1] = b
end
end
if #todo == 0 then
vim.notify(
("%s only has local branch %s — nothing else to materialise"):format(project, keep),
vim.log.levels.INFO
)
return
end
-- Branches already checked out somewhere (idempotent re-runs)
local existing = {} ---@type table<string, string>
for _, wt in ipairs(M.worktrees()) do
if wt.branch then
existing[wt.branch] = wt.path
end
end
-- Confirm if the main worktree is dirty (only affects `--keep` switching)
local dirty = git({ "status", "--porcelain", "--untracked-files=no" })
if dirty.code == 0 and dirty.stdout ~= "" then
local choice = vim.fn.confirm(
("Uncommitted changes in %s.\nConvert anyway? (they stay in the %s worktree)"):format(project, keep),
"&Yes\n&No",
2
)
if choice ~= 1 then
vim.notify("Convert aborted", vim.log.levels.INFO)
return
end
end
local created, skipped, errors = {}, {}, {}
for _, b in ipairs(todo) do
if existing[b] then
skipped[#skipped + 1] = { b, existing[b] }
else
local dir = M.worktree_dir(b, repo)
if vim.fn.isdirectory(dir) == 1 then
errors[#errors + 1] = { b, "target exists and is not a worktree: " .. dir }
else
vim.fn.mkdir(vim.fn.fnamemodify(dir, ":h"), "p")
local res = git({ "worktree", "add", dir, "--", b })
if res.code == 0 then
created[#created + 1] = { b, dir }
else
errors[#errors + 1] = { b, res.stderr ~= "" and res.stderr or res.stdout }
end
end
end
end
local lines = { ("%s now uses a worktree layout (%s stays at the main worktree)"):format(project, keep) }
if #created > 0 then
lines[#lines + 1] = "Created " .. #created .. " worktree(s):"
for _, c in ipairs(created) do
lines[#lines + 1] = " " .. c[1] .. " → " .. c[2]
end
end
if #skipped > 0 then
lines[#lines + 1] = "Already materialised: "
.. table.concat(
vim.tbl_map(function(e)
return e[1]
end, skipped),
", "
)
end
if #errors > 0 then
vim.notify("Some worktrees could not be created:\n" .. table.concat(
vim.tbl_map(function(e)
return " " .. e[1] .. ": " .. e[2]
end, errors),
"\n"
), vim.log.levels.WARN)
end
vim.notify(table.concat(lines, "\n") .. "\nSwitch branches anytime with <leader>gw", vim.log.levels.INFO)
end
---Parse `GitWorktreeConvert` command args (--keep=<branch>).
---@param fargs string[]
function M.convert_prompt(fargs)
local keep
for _, a in ipairs(fargs or {}) do
local v = a:match("^%-%-keep=(.*)$")
if v then
keep = v
end
end
M.convert({ keep = keep })
end
---Title for the explorer window: current project + checked-out branch.
---@param path? string dir to label (defaults to cwd)
---@return string?
function M.explorer_title(path)
local cwd = path or vim.uv.cwd()
local repo = git({ "rev-parse", "--show-toplevel" }, { cwd = cwd })
if repo.code ~= 0 or repo.stdout == "" then
return nil -- not a git dir; let the explorer use its default title
end
local project = vim.fs.basename(repo.stdout:gsub("/+$", ""))
local branch = git({ "symbolic-ref", "-q", "--short", "HEAD" }, { cwd = cwd })
local branch_label = branch.code == 0 and branch.stdout or "detached"
local wts = git({ "worktree", "list", "--porcelain" }, { cwd = cwd })
local count = 0
if wts.code == 0 then
for line in vim.gsplit(wts.stdout, "\n", { plain = true }) do
if line:sub(1, 8) == "worktree" then
count = count + 1
end
end
end
local suffix = count > 1 and (" %d worktrees"):format(count) or ""
return (" %s %s %s %s%s "):format(M.config.icons.chip, project, M.config.icons.current, branch_label, suffix)
end
-- ─────────────────────────────────────────────────────────────────────────────
-- Telescope pickers
-- ─────────────────────────────────────────────────────────────────────────────
---Build the Telescope picker for branch switching / worktree jumping.
---@param kind "branch"|"worktree"
---@param opts? table telescope opts
function M.pick(kind, opts)
opts = opts or {}
local repo = M.git_root()
if not repo then
vim.notify("Not inside a git repo — open nvim from a project to switch branches", vim.log.levels.ERROR)
return
end
local actions = require("telescope.actions")
local action_state = require("telescope.actions.state")
local conf = require("telescope.config").values
local entry_display = require("telescope.pickers.entry_display")
local finders = require("telescope.finders")
local pickers = require("telescope.pickers")
local h = M.config.highlights
local project = vim.fs.basename(repo)
local function reopen()
vim.schedule(function()
M.pick(kind)
end)
end
local function entries(kind)
local items = {}
local function displayer_for(list)
local widths = { name = 0, remote = 0, path = 0 }
for _, e in ipairs(list) do
widths.name = math.max(widths.name, vim.fn.strdisplaywidth(e.name))
if e.remote then
widths.remote = math.max(widths.remote, vim.fn.strdisplaywidth(e.remote))
end
if e.worktree then
widths.path = math.max(widths.path, vim.fn.strdisplaywidth(e.worktree))
end
end
return entry_display.create({
separator = " ",
items = {
{ width = 1 },
{ width = widths.name },
{ width = widths.remote },
{ width = widths.path },
},
})
end
if kind == "worktree" then
local wts = M.worktrees()
for _, wt in ipairs(wts) do
local label = wt.branch or ("(detached " .. (wt.head or "") .. ")")
items[#items + 1] = {
name = label,
worktree = wt.path,
remote = nil,
remote_only = false,
current = wt.path == repo,
}
end
local d = displayer_for(items)
return vim.tbl_map(function(e)
local icon, hl = "", nil
if e.current then
icon, hl = M.config.icons.current, h.current
else
icon, hl = M.config.icons.chip, nil
end
-- display resolves lazily, at render time, once the picker window exists
local display = function()
return d({
{ icon, hl },
{ e.name },
{ "", nil },
{ e.worktree, h.path },
})
end
return { value = e, ordinal = e.name, display = display }
end, items)
end
local branches = M.branches()
local list = {}
for _, b in ipairs(branches) do
list[#list + 1] = {
name = b.name,
remote = b.remote,
worktree = b.worktree,
remote_only = b.remote_only,
current = b.current,
track = b.track,
date = b.date,
}
end
local d = displayer_for(list)
return vim.tbl_map(function(e)
local icon, hl = "", nil
if e.current then
icon, hl = M.config.icons.current, h.current
elseif e.worktree then
icon, hl = M.config.icons.checked, h.checked
elseif e.remote_only then
icon, hl = M.config.icons.remote, h.remote
else
icon, hl = M.config.icons.branch, h.branch
end
-- display resolves lazily, at render time, once the picker window exists
local display = function()
return d({
{ icon, hl },
{ e.name, hl },
{ e.remote and (e.remote .. (e.track ~= "" and " " .. e.track or "")) or "", h.upstream },
{ e.worktree or "", h.path },
})
end
return {
value = e,
ordinal = e.name,
display = display,
}
end, list)
end
local list = entries(kind)
if #list == 0 then
vim.notify(
kind == "branch" and "No branches found" or "No worktrees yet — convert the repo or switch to a branch first",
vim.log.levels.INFO
)
return
end
local prompt
if kind == "branch" then
prompt = (" Git Branches · %s "):format(project)
else
prompt = (" Git Worktrees · %s "):format(project)
end
local picker = pickers.new(opts, {
prompt_title = prompt,
finder = finders.new_table({
results = list,
-- entries are already fully-built (value/ordinal/display); identity
-- entry_maker stops finders.new_table from re-wrapping them as strings
entry_maker = function(entry)
return entry
end,
}),
sorter = conf.generic_sorter(opts),
attach_mappings = function(prompt_bufnr, map)
actions.select_default:replace(function()
local selection = action_state.get_selected_entry()
if not selection then
return
end
actions.close(prompt_bufnr)
if kind == "worktree" then
M.open_worktree(selection.value.worktree, selection.value.name)
else
M.switch(selection.value)
end
end)
local function remap(mode, key, fn, desc)
map(mode, key, fn, { desc = desc })
end
local function selected()
local sel = action_state.get_selected_entry()
return sel and sel.value or nil
end
if kind == "branch" then
remap({ "i", "n" }, "<c-f>", function()
actions.close(prompt_bufnr)
M.fetch_all(reopen)
end, "Fetch remotes")
remap({ "i", "n" }, "<c-r>", function()
actions.close(prompt_bufnr)
reopen()
end, "Refresh")
remap({ "i", "n" }, "<c-d>", function()
if selected() then
M.remove_entry(selected())
actions.close(prompt_bufnr)
reopen()
end
end, "Remove worktree/branch")
remap({ "i", "n" }, "<c-n>", function()
actions.close(prompt_bufnr)
M.new_worktree(selected())
end, "New branch worktree")
else
remap({ "i", "n" }, "<c-d>", function()
if selected() then
M.remove_entry(selected())
actions.close(prompt_bufnr)
reopen()
end
end, "Remove worktree")
end
return true
end,
})
picker:find()
end
-- ─────────────────────────────────────────────────────────────────────────────
-- Setup (highlight groups + user commands) — runs once when the module loads
-- ─────────────────────────────────────────────────────────────────────────────
---Create highlight groups (linked so any colorscheme keeps them coherent).
local function define_highlights()
local function link(name, linked, bold)
vim.api.nvim_set_hl(0, name, { link = linked, bold = bold })
end
link(M.config.highlights.current, "Title", true)
link(M.config.highlights.checked, "DiagnosticOk")
link(M.config.highlights.branch, "TelescopeResultsIdentifier")
link(M.config.highlights.remote, "Comment")
link(M.config.highlights.path, "Comment")
link(M.config.highlights.upstream, "NonText")
end
function M.setup()
local group = vim.api.nvim_create_augroup("git_worktree", { clear = true })
vim.api.nvim_create_autocmd("ColorScheme", { group = group, callback = define_highlights })
define_highlights()
if not vim.g.git_worktree_commands then
vim.g.git_worktree_commands = true
vim.api.nvim_create_user_command("GitWorktree", function()
M.pick("branch")
end, { desc = "Switch branches / worktrees (Telescope)" })
vim.api.nvim_create_user_command("GitWorktrees", function()
M.pick("worktree")
end, { desc = "List git worktrees" })
vim.api.nvim_create_user_command("GitWorktreeConvert", function(args)
M.convert_prompt(args.fargs)
end, { nargs = "*", desc = "Convert the current repo to a worktree layout" })
end
end
M.setup()
return M
+4 -1
View File
@@ -22,7 +22,10 @@ map("t", "<Esc><Esc>", "<C-\\><C-n>", { desc = "Exit terminal mode" })
-- Explorer (Snacks)
-- ─────────────────────────────────────────────────────────────────────────────
map({ "n", "i" }, "<C-e>", function()
Snacks.explorer()
-- Title the explorer with the current project + branch when in a git repo
local ok = pcall(require, "config.git-worktree")
local title = ok and require("config.git-worktree").explorer_title() or nil
Snacks.explorer({ title = title })
end, { desc = "Toggle Explorer" })
-- ─────────────────────────────────────────────────────────────────────────────
+36
View File
@@ -0,0 +1,36 @@
-- Worktree-aware git branch switching (Telescope).
--
-- The implementation lives in `config.git-worktree` (data collection, branch
-- switching, repo → worktree conversion); this file only wires up the
-- Telescope keybindings. Everything is lazy-loaded at keypress so startup is
-- unaffected.
return {
{
"nvim-telescope/telescope.nvim",
optional = true,
keys = {
{
"<leader>gw",
function()
require("config.git-worktree").pick("branch")
end,
desc = "Git Branches (worktree switch)",
},
{
"<leader>gW",
function()
require("config.git-worktree").pick("worktree")
end,
desc = "Git Worktrees",
},
{
"<leader>gC",
function()
require("config.git-worktree").convert({})
end,
desc = "Convert repo to worktrees",
},
},
},
}