updated how convert works; added few commands to make worktrees a bit easier to use #12

Merged
sam_oneal merged 1 commits from worktree-fix into main 2026-09-21 21:26:41 +00:00
2 changed files with 285 additions and 104 deletions
+267 -93
View File
@@ -1,9 +1,17 @@
-- 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).
-- Two layouts are supported, detected automatically from `git worktree list`:
--
-- • Bare-home layout (created by `convert`): the repo's main directory IS a
-- bare repo (no working tree) and every local branch is checked out in a
-- linked worktree under `<home>/.worktrees/<branch>`. nvim can be opened
-- in the bare home itself to switch branches / create worktrees.
-- • Plain layout: the usual single-checkout repo, where switching between
-- branches materialises linked worktrees in `<parent>/.worktrees/…`.
--
-- The Telescope picker switches between branches (creating / fetching
-- worktrees on demand) and offers creating a new branch from the worktree
-- that is currently open.
--
-- Loaded lazily: the module is only required when a keymap, command or the
-- explorer title calls into it (see lua/plugins/worktree.lua and
@@ -17,9 +25,12 @@ local M = {}
---@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.
-- Where linked worktrees are created for a non-bare (plain) checkout.
-- Defaults to `<parent>/.worktrees/<project>/<branch>` (e.g.
-- `~/src/.worktrees/desi_explorer/feat-api_connection`). For the bare-home
-- layout the worktrees always live under `<home>/.worktrees/<branch>` instead
-- (see git-worktree.worktree_dir). 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)
@@ -76,20 +87,56 @@ local function git_async(args, cb)
end)
end
---Absolute path to the git root of the cwd, or nil when not in a repo.
---Normalise a path: make it absolute and drop trailing slashes.
---@param p string
---@return string
local function norm(p)
return vim.fn.fnamemodify(p, ":p"):gsub("/+$", "")
end
---Absolute path to the git root of `cwd`: the top-level of the worktree being
---worked in. Returns nil when `cwd` is not inside a working tree (which is
---normally the case when nvim sits in a bare repo home).
---@param cwd? string
---@return string?
function M.git_root()
local res = git({ "rev-parse", "--show-toplevel" })
function M.git_root(cwd)
local res = git({ "rev-parse", "--show-toplevel" }, { cwd = cwd })
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).
---Root of the whole worktree layout for `cwd`: the bare home directory when the
---repo uses the bare-home layout, or the main (plain) checkout otherwise.
---Both are the first `worktree` entry of `git worktree list --porcelain`.
---@param cwd? string
---@return string?
function M.current_branch()
local res = git({ "symbolic-ref", "-q", "--short", "HEAD" })
function M.home(cwd)
local res = git({ "worktree", "list", "--porcelain" }, { cwd = cwd })
if res.code ~= 0 then
return nil
end
local path = res.stdout:match("^worktree ([^\n]+)")
if not path then
return nil
end
return norm(path)
end
---Whether the repo at `cwd` is bare (i.e. it has no working tree).
---@param cwd? string
---@return boolean
function M.is_bare(cwd)
local res = git({ "rev-parse", "--is-bare-repository" }, { cwd = cwd })
return res.code == 0 and res.stdout == "true"
end
---Name of the branch checked out in the current worktree (nil when detached).
---@param cwd? string
---@return string?
function M.current_branch(cwd)
local res = git({ "symbolic-ref", "-q", "--short", "HEAD" }, { cwd = cwd })
if res.code ~= 0 then
return nil
end
@@ -104,9 +151,10 @@ end
---@field locked? boolean
---Parse `git worktree list --porcelain` into records.
---@param cwd? string
---@return git-worktree.Worktree[]
function M.worktrees()
local res = git({ "worktree", "list", "--porcelain" })
function M.worktrees(cwd)
local res = git({ "worktree", "list", "--porcelain" }, { cwd = cwd })
if res.code ~= 0 then
return {}
end
@@ -116,7 +164,7 @@ function M.worktrees()
if line == "" then
cur = nil
elseif line:sub(1, 8) == "worktree" then
cur = { path = vim.fn.fnamemodify(line:sub(10), ":p"):gsub("/+$", "") }
cur = { path = norm(line:sub(10)) }
table.insert(wts, cur)
elseif line:sub(1, 6) == "branch" and cur then
cur.branch = line:sub(8):gsub("^refs/heads/", "")
@@ -132,9 +180,10 @@ function M.worktrees()
end
---Names of all local branches.
---@param cwd? string
---@return string[]
function M.local_branches()
local res = git({ "for-each-ref", "--format=%(refname:short)", "refs/heads" })
function M.local_branches(cwd)
local res = git({ "for-each-ref", "--format=%(refname:short)", "refs/heads" }, { cwd = cwd })
if res.code ~= 0 then
return {}
end
@@ -183,7 +232,10 @@ function M.branches()
end
end
local current = M.current_branch()
-- Current branch = the one checked out in the worktree we are sitting in. A
-- bare home has no working tree, so nothing is "current" there (symbolic-ref
-- would just report the bare repo's default HEAD).
local current = M.git_root() and M.current_branch() or nil
-- Preserve for-each-ref's date ordering (newest first) across both namespaces
local ordered = {} ---@type {name: string, info: string[], remote: boolean}[]
@@ -247,16 +299,23 @@ end
-- Worktree operations
-- ─────────────────────────────────────────────────────────────────────────────
---Target directory for a branch's linked worktree.
---Target directory for a branch's linked worktree, rooted at the layout home.
---For a bare home the linked worktrees live inside it, under `.worktrees/`; for
---a plain checkout they stay next to it (`.worktrees/` beside the repo).
---@param branch string
---@param repo string git root
---@param home string worktree-layout home directory (see M.home())
---@return string
function M.worktree_dir(branch, repo)
return (M.config.worktree_dir or function(b, r)
function M.worktree_dir(branch, home)
if M.is_bare(home) then
return vim.fs.joinpath(home, ".worktrees", (branch:gsub("/", "-")))
end
local fn = 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
return fn(branch, home)
end
---Materialise `entry` as a linked worktree (creating a tracking local branch
@@ -267,11 +326,11 @@ 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
local home = M.home()
if not home then
return { ok = false, path = nil, err = "Not inside a git repo" }
end
local dir = M.worktree_dir(entry.name, repo)
local dir = M.worktree_dir(entry.name, home)
if vim.fn.isdirectory(dir) == 1 then
return { ok = false, path = nil, err = "Target exists and is not a registered worktree: " .. dir }
end
@@ -301,8 +360,8 @@ function M.remove_entry(entry)
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)
if entry.worktree == M.home() then
vim.notify("Cannot remove the home 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)
@@ -369,11 +428,11 @@ function M.new_worktree(entry)
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
local home = M.home()
if not home then
return
end
local dir = M.worktree_dir(name, repo)
local dir = M.worktree_dir(name, home)
if vim.fn.isdirectory(dir) == 1 then
vim.notify("Target already exists: " .. dir, vim.log.levels.ERROR)
return
@@ -458,65 +517,81 @@ function M.switch(entry)
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}
---Convert the current repo into a bare-home worktree layout:
---
--- 1. The whole directory (working tree + `.git`) is copied to a temp backup.
--- 2. The current directory (which must hold the repo's `.git`) is turned into
--- a bare repo — it becomes the layout's home with no working tree.
--- 3. Every local branch is materialised as a linked worktree under
--- `<home>/.worktrees/<branch>`, so nvim can be opened in the bare home and
--- switch branches / create worktrees from there.
---
---Any linked worktrees that already exist (e.g. from an earlier, legacy
---conversion in a sibling `.worktrees/` dir) are re-anchored and kept.
---
---Must be run from the repo's main checkout (the directory holding `.git`),
---on a repo that is not already bare.
---@param opts? {keep?: string} `keep` (default: the previously checked-out
---branch) is re-opened after conversion
function M.convert(opts)
opts = opts or {}
local repo = M.git_root()
if not repo then
local cwd = norm(vim.uv.cwd())
if M.is_bare(cwd) then
vim.notify("Already a bare repo — nothing to convert", vim.log.levels.INFO)
return
end
local root = M.git_root()
if not root 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
if norm(root) ~= cwd then
vim.notify(
("Detached HEAD — pass --keep=<branch> to choose which branch stays at %s"):format(repo),
("Convert must run from the repo's main directory (where `.git` lives).\nCurrent dir: %s\nRepo root: %s"):format(
cwd,
root
),
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
local git_entry = cwd .. "/.git"
if vim.fn.isdirectory(git_entry) ~= 1 then
if vim.fn.filereadable(git_entry) == 1 then
vim.notify(
("%s only has local branch %s — nothing else to materialise"):format(project, keep),
vim.log.levels.INFO
"You are inside a linked worktree of an already-converted repo.\n"
.. "Open nvim in its bare home ("
.. (M.home() or "?")
.. ") instead",
vim.log.levels.WARN
)
else
vim.notify("Expected a `.git` directory in " .. cwd .. " — nothing to convert", vim.log.levels.ERROR)
end
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
local project = vim.fs.basename(cwd)
local branches = M.local_branches()
if #branches == 0 then
vim.notify(("%s has no local branches to materialise"):format(project), vim.log.levels.WARN)
return
end
local cur = M.current_branch()
-- 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 dirty = git({ "status", "--porcelain" })
local dirty_msg = dirty.code == 0
and dirty.stdout ~= ""
and "\nUncommitted / untracked files are only preserved in the backup.\n"
or ""
local choice = vim.fn.confirm(
("Uncommitted changes in %s.\nConvert anyway? (they stay in the %s worktree)"):format(project, keep),
("Turn %s into a bare home repo?\n"):format(project)
.. "\n"
.. (" %s becomes a bare repo (no working tree)\n"):format(cwd)
.. (" %s/.worktrees/<branch> keeps one worktree per local branch\n"):format(cwd)
.. " the whole repo is copied to a temp backup first\n"
.. dirty_msg
.. "\nProceed?",
"&Yes\n&No",
2
)
@@ -524,18 +599,70 @@ function M.convert(opts)
vim.notify("Convert aborted", vim.log.levels.INFO)
return
end
-- 1. Full backup (working tree + .git). nvim's own temp dir is wiped on exit,
-- so use the OS tmp dir instead so the backup survives the session.
local tmp = vim.env.TMPDIR ~= nil and vim.env.TMPDIR ~= "" and vim.env.TMPDIR or "/tmp"
local backup = vim.fs.joinpath(tmp, project .. "-worktree-backup-" .. os.time())
local bump = 1
while vim.fn.isdirectory(backup) == 1 or vim.fn.filereadable(backup) == 1 do
bump = bump + 1
backup = vim.fs.joinpath(tmp, project .. "-worktree-backup-" .. os.time() .. "-" .. bump)
end
vim.fn.mkdir(backup, "p")
vim.notify(("Backing up %s to %s …"):format(project, backup), vim.log.levels.INFO)
local cp = vim.system({ "cp", "-a", cwd .. "/.", backup .. "/" }, { text = true, timeout = 300000 }):wait()
if cp.code ~= 0 then
vim.notify(("Backup failed:\n%s\nConversion aborted"):format(cp.stderr), vim.log.levels.ERROR)
return
end
local created, skipped, errors = {}, {}, {}
for _, b in ipairs(todo) do
-- 2. Mark the repo bare while `.git` is still discoverable. Drop any stale
-- `core.worktree` so git-dir discovery keys off the directory itself.
local bare = git({ "config", "core.bare", "true" })
if bare.code ~= 0 then
vim.notify("Could not mark the repo as bare:\n" .. bare.stderr, vim.log.levels.ERROR)
return
end
git({ "config", "--unset-all", "core.worktree" })
-- 3. Drop the old working tree, then hoist `.git/*` so the directory itself
-- becomes the bare git dir (backed up above, so nothing is lost).
local entries = {}
for e in vim.fs.dir(cwd) do
entries[#entries + 1] = e
end
for _, e in ipairs(entries) do
if e ~= ".git" then
vim.fn.delete(cwd .. "/" .. e, "rf")
end
end
for e in vim.fs.dir(cwd .. "/.git") do
vim.fn.rename(cwd .. "/.git/" .. e, cwd .. "/" .. e)
end
vim.fn.delete(cwd .. "/.git", "rf")
-- 4. Re-anchor any linked worktrees (from a previous layout) whose paths may
-- no longer point at the moved admin dirs.
git({ "worktree", "repair" })
-- 5. Materialise every local branch as a linked worktree under the bare home.
local existing = {} ---@type table<string, string>
for _, wt in ipairs(M.worktrees()) do
if wt.branch then
existing[wt.branch] = wt.path
end
end
vim.fn.mkdir(cwd .. "/.worktrees", "p")
local created, kept, errors = {}, {}, {}
for _, b in ipairs(branches) do
if existing[b] then
skipped[#skipped + 1] = { b, existing[b] }
kept[#kept + 1] = { b, existing[b] }
else
local dir = M.worktree_dir(b, repo)
local dir = M.worktree_dir(b, cwd)
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 }
@@ -546,19 +673,21 @@ function M.convert(opts)
end
end
local lines = { ("%s now uses a worktree layout (%s stays at the main worktree)"):format(project, keep) }
-- 6. Report; then move into the branch that was checked out before the
-- conversion.
local lines = { ("%s is now a bare home at %s (%d branch worktrees)"):format(project, cwd, #branches) }
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: "
if #kept > 0 then
lines[#lines + 1] = "Already checked out: "
.. table.concat(
vim.tbl_map(function(e)
return e[1]
end, skipped),
end, kept),
", "
)
end
@@ -570,7 +699,25 @@ function M.convert(opts)
"\n"
), vim.log.levels.WARN)
end
vim.notify(table.concat(lines, "\n") .. "\nSwitch branches anytime with <leader>gw", vim.log.levels.INFO)
lines[#lines + 1] = "Backup: " .. backup
lines[#lines + 1] = "Open nvim in " .. cwd .. " and switch branches with <leader>gw"
vim.notify(table.concat(lines, "\n"), vim.log.levels.INFO)
local open = opts.keep or cur
if open then
local dir = existing[open]
if not dir then
for _, c in ipairs(created) do
if c[1] == open then
dir = c[2]
break
end
end
end
if dir then
M.open_worktree(dir, open)
end
end
end
---Parse `GitWorktreeConvert` command args (--keep=<branch>).
@@ -586,18 +733,25 @@ function M.convert_prompt(fargs)
M.convert({ keep = keep })
end
---Title for the explorer window: current project + checked-out branch.
---Title for the explorer window: project + layout home + current 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
local home = M.home(cwd)
if not home then
return nil -- not a git repo; let the explorer use its default title
end
local project = vim.fs.basename(repo.stdout:gsub("/+$", ""))
local project = vim.fs.basename(home)
local branch_label
if M.git_root(cwd) then
local branch = git({ "symbolic-ref", "-q", "--short", "HEAD" }, { cwd = cwd })
local branch_label = branch.code == 0 and branch.stdout or "detached"
branch_label = branch.code == 0 and branch.stdout or "detached"
elseif M.is_bare(cwd) then
branch_label = "bare home"
else
return nil
end
local wts = git({ "worktree", "list", "--porcelain" }, { cwd = cwd })
local count = 0
if wts.code == 0 then
@@ -620,7 +774,7 @@ end
---@param opts? table telescope opts
function M.pick(kind, opts)
opts = opts or {}
local repo = M.git_root()
local repo = M.home()
if not repo then
vim.notify("Not inside a git repo — open nvim from a project to switch branches", vim.log.levels.ERROR)
return
@@ -668,14 +822,22 @@ function M.pick(kind, opts)
if kind == "worktree" then
local wts = M.worktrees()
local cur_wt = M.git_root()
for _, wt in ipairs(wts) do
local label = wt.branch or ("(detached " .. (wt.head or "") .. ")")
local label
if wt.branch then
label = wt.branch
elseif wt.path == repo then
label = project .. " (bare home)"
else
label = "(bare " .. (wt.head or "") .. ")"
end
items[#items + 1] = {
name = label,
worktree = wt.path,
remote = nil,
remote_only = false,
current = wt.path == repo,
current = wt.path == repo or wt.path == cur_wt,
}
end
local d = displayer_for(items)
@@ -810,7 +972,11 @@ function M.pick(kind, opts)
remap({ "i", "n" }, "<c-n>", function()
actions.close(prompt_bufnr)
M.new_worktree(selected())
end, "New branch worktree")
end, "New branch worktree (from selected)")
remap({ "i", "n" }, "<c-b>", function()
actions.close(prompt_bufnr)
M.pick("worktree")
end, "Switch to worktree picker")
else
remap({ "i", "n" }, "<c-d>", function()
if selected() then
@@ -819,6 +985,14 @@ function M.pick(kind, opts)
reopen()
end
end, "Remove worktree")
remap({ "i", "n" }, "<c-n>", function()
actions.close(prompt_bufnr)
M.new_worktree()
end, "New branch worktree (from current)")
remap({ "i", "n" }, "<c-b>", function()
actions.close(prompt_bufnr)
M.pick("branch")
end, "Switch to branch picker")
end
return true
end,
+7
View File
@@ -24,6 +24,13 @@ return {
end,
desc = "Git Worktrees",
},
{
"<leader>gN",
function()
require("config.git-worktree").new_worktree()
end,
desc = "New branch worktree (from current)",
},
{
"<leader>gC",
function()