diff --git a/lua/config/git-worktree.lua b/lua/config/git-worktree.lua index c328d2c..1f6028f 100644 --- a/lua/config/git-worktree.lua +++ b/lua/config/git-worktree.lua @@ -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 `/.worktrees/`. 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 `/.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 `/.worktrees//` - -- (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 `/.worktrees//` (e.g. + -- `~/src/.worktrees/desi_explorer/feat-api_connection`). For the bare-home + -- layout the worktrees always live under `/.worktrees/` 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) - local parent = vim.fs.dirname(r) - local project = vim.fs.basename(r) - return parent .. "/.worktrees/" .. project .. "/" .. b:gsub("/", "-") - end)(branch, repo) +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 + 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,84 +517,152 @@ 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 +--- `/.worktrees/`, 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= 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 + local git_entry = cwd .. "/.git" + if vim.fn.isdirectory(git_entry) ~= 1 then + if vim.fn.filereadable(git_entry) == 1 then + vim.notify( + "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 - 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 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() + + 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( + ("Turn %s into a bare home repo?\n"):format(project) + .. "\n" + .. (" %s becomes a bare repo (no working tree)\n"):format(cwd) + .. (" %s/.worktrees/ 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 + ) + if choice ~= 1 then + 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 + + -- 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 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 + 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 gw", vim.log.levels.INFO) + lines[#lines + 1] = "Backup: " .. backup + lines[#lines + 1] = "Open nvim in " .. cwd .. " and switch branches with 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=). @@ -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(home) + local branch_label + if M.git_root(cwd) then + local branch = git({ "symbolic-ref", "-q", "--short", "HEAD" }, { cwd = cwd }) + 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 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 @@ -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" }, "", function() actions.close(prompt_bufnr) M.new_worktree(selected()) - end, "New branch worktree") + end, "New branch worktree (from selected)") + remap({ "i", "n" }, "", function() + actions.close(prompt_bufnr) + M.pick("worktree") + end, "Switch to worktree picker") else remap({ "i", "n" }, "", function() if selected() then @@ -819,6 +985,14 @@ function M.pick(kind, opts) reopen() end end, "Remove worktree") + remap({ "i", "n" }, "", function() + actions.close(prompt_bufnr) + M.new_worktree() + end, "New branch worktree (from current)") + remap({ "i", "n" }, "", function() + actions.close(prompt_bufnr) + M.pick("branch") + end, "Switch to branch picker") end return true end, diff --git a/lua/plugins/worktree.lua b/lua/plugins/worktree.lua index 8b32e4d..161c2f8 100644 --- a/lua/plugins/worktree.lua +++ b/lua/plugins/worktree.lua @@ -24,6 +24,13 @@ return { end, desc = "Git Worktrees", }, + { + "gN", + function() + require("config.git-worktree").new_worktree() + end, + desc = "New branch worktree (from current)", + }, { "gC", function()