feat(git): worktree-aware branch switching (Telescope), repo conversion, explorer branch chip #9
@@ -186,12 +186,39 @@ debug symbols first, then run (`Launch (zig build)` prompts for the exe under
|
|||||||
|
|
||||||
### Git (`<leader>g`)
|
### Git (`<leader>g`)
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
| ------------ | ------------------------- |
|
| ------------ | ------------------------------------------------------------------------ |
|
||||||
| `<leader>gg` | Lazygit (LazyVim default) |
|
| `<leader>gg` | Lazygit (LazyVim default) |
|
||||||
| `<leader>gn` | Neogit |
|
| `<leader>gn` | Neogit |
|
||||||
| `<leader>gl` | Lazygit log |
|
| `<leader>gl` | Lazygit log |
|
||||||
| `<leader>gf` | Lazygit file history |
|
| `<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`)
|
### 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
|
│ │ ├── lazy.lua # lazy.nvim bootstrap, LazyVim + extras
|
||||||
│ │ ├── options.lua # Neovim options
|
│ │ ├── options.lua # Neovim options
|
||||||
│ │ ├── keymaps.lua # Custom keymaps
|
│ │ ├── keymaps.lua # Custom keymaps
|
||||||
|
│ │ ├── git-worktree.lua # Worktree-aware branch switching + convert
|
||||||
│ │ └── autocmds.lua # Autocommands (yank hl, ft tweaks, SQL no-autoformat…)
|
│ │ └── autocmds.lua # Autocommands (yank hl, ft tweaks, SQL no-autoformat…)
|
||||||
│ ├── plugins/
|
│ ├── plugins/
|
||||||
│ │ ├── mason.lua # Tooling installs (LSP/linters/formatters/DAP)
|
│ │ ├── 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
|
│ │ ├── ai.lua # opencode.nvim
|
||||||
│ │ ├── dadbod.lua # Database
|
│ │ ├── dadbod.lua # Database
|
||||||
│ │ ├── git.lua # Neogit + diffview
|
│ │ ├── git.lua # Neogit + diffview
|
||||||
|
│ │ ├── worktree.lua # Git worktree keymaps (`<leader>gw/gW/gC`)
|
||||||
│ │ ├── editor.lua # snacks tweaks + which-key groups
|
│ │ ├── editor.lua # snacks tweaks + which-key groups
|
||||||
│ │ ├── symbols.lua # aerial
|
│ │ ├── symbols.lua # aerial
|
||||||
│ │ ├── dashboard.lua # trouble opts
|
│ │ ├── dashboard.lua # trouble opts
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -22,7 +22,10 @@ map("t", "<Esc><Esc>", "<C-\\><C-n>", { desc = "Exit terminal mode" })
|
|||||||
-- Explorer (Snacks)
|
-- Explorer (Snacks)
|
||||||
-- ─────────────────────────────────────────────────────────────────────────────
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
map({ "n", "i" }, "<C-e>", function()
|
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" })
|
end, { desc = "Toggle Explorer" })
|
||||||
|
|
||||||
-- ─────────────────────────────────────────────────────────────────────────────
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user