diff --git a/README.md b/README.md index 6065499..c227427 100644 --- a/README.md +++ b/README.md @@ -186,12 +186,39 @@ debug symbols first, then run (`Launch (zig build)` prompts for the exe under ### Git (`g`) -| Key | Action | -| ------------ | ------------------------- | -| `gg` | Lazygit (LazyVim default) | -| `gn` | Neogit | -| `gl` | Lazygit log | -| `gf` | Lazygit file history | +| Key | Action | +| ------------ | ------------------------------------------------------------------------ | +| `gg` | Lazygit (LazyVim default) | +| `gn` | Neogit | +| `gl` | Lazygit log | +| `gf` | Lazygit file history | +| `gw` | Git branches (worktree switcher, Telescope) | +| `gW` | Git worktrees (list/jump, Telescope) | +| `gC` | Convert current repo to worktree layout | + +#### Git worktrees + +`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. + +- `` **switch** — jumps to the branch's worktree, creating it on demand. + Remote-only branches are pulled down as tracking local branches first. +- `` **fetch remotes** — runs `git fetch --all --prune` so new remote + branches (e.g. `renovate/*`) appear in the list, then refreshes. +- `` **remove** — deletes the selected worktree (or local-only branch). +- `` **new** — prompts for a branch name (+ base) and creates its worktree. +- `` **refresh** — re-reads local/remote state. + +`:GitWorktreeConvert` (or `gC`) turns a plain single-checkout repo into +a worktree layout without losing anything: every local branch gets its own +linked worktree under `/.worktrees//` (main keeps its +place), so all branches that were on the machine stay available. Pass +`--keep=` to choose which branch remains at the main worktree. + +The snacks explorer (``) shows a chip in its title with the current +project, branch, and worktree count when inside a git repo. ### Search / Symbols (`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 (`gw/gW/gC`) │ │ ├── editor.lua # snacks tweaks + which-key groups │ │ ├── symbols.lua # aerial │ │ ├── dashboard.lua # trouble opts diff --git a/lua/config/git-worktree.lua b/lua/config/git-worktree.lua new file mode 100644 index 0000000..c328d2c --- /dev/null +++ b/lua/config/git-worktree.lua @@ -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 `/.worktrees//` + -- (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 + 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 + 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= 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 + 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 gw", vim.log.levels.INFO) +end + +---Parse `GitWorktreeConvert` command args (--keep=). +---@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" }, "", function() + actions.close(prompt_bufnr) + M.fetch_all(reopen) + end, "Fetch remotes") + remap({ "i", "n" }, "", function() + actions.close(prompt_bufnr) + reopen() + end, "Refresh") + remap({ "i", "n" }, "", function() + if selected() then + M.remove_entry(selected()) + actions.close(prompt_bufnr) + reopen() + end + end, "Remove worktree/branch") + remap({ "i", "n" }, "", function() + actions.close(prompt_bufnr) + M.new_worktree(selected()) + end, "New branch worktree") + else + remap({ "i", "n" }, "", 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 diff --git a/lua/config/keymaps.lua b/lua/config/keymaps.lua index c573a3f..ac482ee 100644 --- a/lua/config/keymaps.lua +++ b/lua/config/keymaps.lua @@ -22,7 +22,10 @@ map("t", "", "", { desc = "Exit terminal mode" }) -- Explorer (Snacks) -- ───────────────────────────────────────────────────────────────────────────── map({ "n", "i" }, "", 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" }) -- ───────────────────────────────────────────────────────────────────────────── diff --git a/lua/plugins/worktree.lua b/lua/plugins/worktree.lua new file mode 100644 index 0000000..8b32e4d --- /dev/null +++ b/lua/plugins/worktree.lua @@ -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 = { + { + "gw", + function() + require("config.git-worktree").pick("branch") + end, + desc = "Git Branches (worktree switch)", + }, + { + "gW", + function() + require("config.git-worktree").pick("worktree") + end, + desc = "Git Worktrees", + }, + { + "gC", + function() + require("config.git-worktree").convert({}) + end, + desc = "Convert repo to worktrees", + }, + }, + }, +}