updated how convert works; added few commands to make worktrees a bit easier to use #12
+278
-104
@@ -1,9 +1,17 @@
|
|||||||
-- Worktree-aware git project switching.
|
-- Worktree-aware git project switching.
|
||||||
--
|
--
|
||||||
-- Turns a plain single-worktree checkout into a worktree-style layout where
|
-- Two layouts are supported, detected automatically from `git worktree list`:
|
||||||
-- every local branch is checked out in its own linked worktree, and provides a
|
--
|
||||||
-- Telescope picker to switch between branches (creating / fetching worktrees
|
-- • Bare-home layout (created by `convert`): the repo's main directory IS a
|
||||||
-- on demand).
|
-- 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
|
-- Loaded lazily: the module is only required when a keymap, command or the
|
||||||
-- explorer title calls into it (see lua/plugins/worktree.lua and
|
-- explorer title calls into it (see lua/plugins/worktree.lua and
|
||||||
@@ -17,9 +25,12 @@ local M = {}
|
|||||||
|
|
||||||
---@class git-worktree.Opts
|
---@class git-worktree.Opts
|
||||||
M.config = {
|
M.config = {
|
||||||
-- Where linked worktrees are created. Defaults to `<parent>/.worktrees/<project>/<branch>`
|
-- Where linked worktrees are created for a non-bare (plain) checkout.
|
||||||
-- (e.g. `~/src/.worktrees/desi_explorer/feat-api_connection`). Branch names
|
-- Defaults to `<parent>/.worktrees/<project>/<branch>` (e.g.
|
||||||
-- are sanitised so `/` maps to `-`: any valid branch becomes a valid path.
|
-- `~/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)
|
worktree_dir = function(branch, repo)
|
||||||
local parent = vim.fs.dirname(repo)
|
local parent = vim.fs.dirname(repo)
|
||||||
local project = vim.fs.basename(repo)
|
local project = vim.fs.basename(repo)
|
||||||
@@ -76,20 +87,56 @@ local function git_async(args, cb)
|
|||||||
end)
|
end)
|
||||||
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?
|
---@return string?
|
||||||
function M.git_root()
|
function M.git_root(cwd)
|
||||||
local res = git({ "rev-parse", "--show-toplevel" })
|
local res = git({ "rev-parse", "--show-toplevel" }, { cwd = cwd })
|
||||||
if res.code ~= 0 or res.stdout == "" then
|
if res.code ~= 0 or res.stdout == "" then
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
return res.stdout -- already absolute; drop any trailing slash
|
return res.stdout -- already absolute; drop any trailing slash
|
||||||
end
|
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?
|
---@return string?
|
||||||
function M.current_branch()
|
function M.home(cwd)
|
||||||
local res = git({ "symbolic-ref", "-q", "--short", "HEAD" })
|
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
|
if res.code ~= 0 then
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
@@ -104,9 +151,10 @@ end
|
|||||||
---@field locked? boolean
|
---@field locked? boolean
|
||||||
|
|
||||||
---Parse `git worktree list --porcelain` into records.
|
---Parse `git worktree list --porcelain` into records.
|
||||||
|
---@param cwd? string
|
||||||
---@return git-worktree.Worktree[]
|
---@return git-worktree.Worktree[]
|
||||||
function M.worktrees()
|
function M.worktrees(cwd)
|
||||||
local res = git({ "worktree", "list", "--porcelain" })
|
local res = git({ "worktree", "list", "--porcelain" }, { cwd = cwd })
|
||||||
if res.code ~= 0 then
|
if res.code ~= 0 then
|
||||||
return {}
|
return {}
|
||||||
end
|
end
|
||||||
@@ -116,7 +164,7 @@ function M.worktrees()
|
|||||||
if line == "" then
|
if line == "" then
|
||||||
cur = nil
|
cur = nil
|
||||||
elseif line:sub(1, 8) == "worktree" then
|
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)
|
table.insert(wts, cur)
|
||||||
elseif line:sub(1, 6) == "branch" and cur then
|
elseif line:sub(1, 6) == "branch" and cur then
|
||||||
cur.branch = line:sub(8):gsub("^refs/heads/", "")
|
cur.branch = line:sub(8):gsub("^refs/heads/", "")
|
||||||
@@ -132,9 +180,10 @@ function M.worktrees()
|
|||||||
end
|
end
|
||||||
|
|
||||||
---Names of all local branches.
|
---Names of all local branches.
|
||||||
|
---@param cwd? string
|
||||||
---@return string[]
|
---@return string[]
|
||||||
function M.local_branches()
|
function M.local_branches(cwd)
|
||||||
local res = git({ "for-each-ref", "--format=%(refname:short)", "refs/heads" })
|
local res = git({ "for-each-ref", "--format=%(refname:short)", "refs/heads" }, { cwd = cwd })
|
||||||
if res.code ~= 0 then
|
if res.code ~= 0 then
|
||||||
return {}
|
return {}
|
||||||
end
|
end
|
||||||
@@ -183,7 +232,10 @@ function M.branches()
|
|||||||
end
|
end
|
||||||
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
|
-- Preserve for-each-ref's date ordering (newest first) across both namespaces
|
||||||
local ordered = {} ---@type {name: string, info: string[], remote: boolean}[]
|
local ordered = {} ---@type {name: string, info: string[], remote: boolean}[]
|
||||||
@@ -247,16 +299,23 @@ end
|
|||||||
-- Worktree operations
|
-- 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 branch string
|
||||||
---@param repo string git root
|
---@param home string worktree-layout home directory (see M.home())
|
||||||
---@return string
|
---@return string
|
||||||
function M.worktree_dir(branch, repo)
|
function M.worktree_dir(branch, home)
|
||||||
return (M.config.worktree_dir or function(b, r)
|
if M.is_bare(home) then
|
||||||
local parent = vim.fs.dirname(r)
|
return vim.fs.joinpath(home, ".worktrees", (branch:gsub("/", "-")))
|
||||||
local project = vim.fs.basename(r)
|
end
|
||||||
return parent .. "/.worktrees/" .. project .. "/" .. b:gsub("/", "-")
|
local fn = M.config.worktree_dir
|
||||||
end)(branch, repo)
|
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
|
end
|
||||||
|
|
||||||
---Materialise `entry` as a linked worktree (creating a tracking local branch
|
---Materialise `entry` as a linked worktree (creating a tracking local branch
|
||||||
@@ -267,11 +326,11 @@ function M.ensure_worktree(entry)
|
|||||||
if entry.worktree then
|
if entry.worktree then
|
||||||
return { ok = true, path = entry.worktree, err = nil }
|
return { ok = true, path = entry.worktree, err = nil }
|
||||||
end
|
end
|
||||||
local repo = M.git_root()
|
local home = M.home()
|
||||||
if not repo then
|
if not home then
|
||||||
return { ok = false, path = nil, err = "Not inside a git repo" }
|
return { ok = false, path = nil, err = "Not inside a git repo" }
|
||||||
end
|
end
|
||||||
local dir = M.worktree_dir(entry.name, repo)
|
local dir = M.worktree_dir(entry.name, home)
|
||||||
if vim.fn.isdirectory(dir) == 1 then
|
if vim.fn.isdirectory(dir) == 1 then
|
||||||
return { ok = false, path = nil, err = "Target exists and is not a registered worktree: " .. dir }
|
return { ok = false, path = nil, err = "Target exists and is not a registered worktree: " .. dir }
|
||||||
end
|
end
|
||||||
@@ -301,8 +360,8 @@ function M.remove_entry(entry)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
if entry.worktree then
|
if entry.worktree then
|
||||||
if entry.worktree == M.git_root() then
|
if entry.worktree == M.home() then
|
||||||
vim.notify("Cannot remove the main worktree (" .. entry.worktree .. ")", vim.log.levels.WARN)
|
vim.notify("Cannot remove the home worktree (" .. entry.worktree .. ")", vim.log.levels.WARN)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local choice = vim.fn.confirm(("Remove worktree for %s?\n%s"):format(entry.name, entry.worktree), "&Yes\n&No", 2)
|
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
|
if base_name ~= nil and (base_name == "" or base_name:sub(1, 1) == ":") then
|
||||||
base_name = nil -- empty input → default
|
base_name = nil -- empty input → default
|
||||||
end
|
end
|
||||||
local repo = M.git_root()
|
local home = M.home()
|
||||||
if not repo then
|
if not home then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local dir = M.worktree_dir(name, repo)
|
local dir = M.worktree_dir(name, home)
|
||||||
if vim.fn.isdirectory(dir) == 1 then
|
if vim.fn.isdirectory(dir) == 1 then
|
||||||
vim.notify("Target already exists: " .. dir, vim.log.levels.ERROR)
|
vim.notify("Target already exists: " .. dir, vim.log.levels.ERROR)
|
||||||
return
|
return
|
||||||
@@ -458,84 +517,152 @@ function M.switch(entry)
|
|||||||
M.open_worktree(res.path, entry.name)
|
M.open_worktree(res.path, entry.name)
|
||||||
end
|
end
|
||||||
|
|
||||||
---Convert the current repo from a single-worktree checkout into a worktree
|
---Convert the current repo into a bare-home worktree layout:
|
||||||
---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)
|
--- 1. The whole directory (working tree + `.git`) is copied to a temp backup.
|
||||||
---stays checked out in the main worktree.
|
--- 2. The current directory (which must hold the repo's `.git`) is turned into
|
||||||
---@param opts? {keep?: string}
|
--- 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)
|
function M.convert(opts)
|
||||||
opts = opts or {}
|
opts = opts or {}
|
||||||
local repo = M.git_root()
|
local cwd = norm(vim.uv.cwd())
|
||||||
if not repo then
|
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)
|
vim.notify("Not inside a git repo", vim.log.levels.ERROR)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local project = vim.fs.basename(repo)
|
if norm(root) ~= cwd then
|
||||||
local cur = M.current_branch()
|
|
||||||
local keep = opts.keep or cur
|
|
||||||
if not keep then
|
|
||||||
vim.notify(
|
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
|
vim.log.levels.WARN
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if keep ~= cur then
|
local git_entry = cwd .. "/.git"
|
||||||
local res = git({ "switch", "--", keep })
|
if vim.fn.isdirectory(git_entry) ~= 1 then
|
||||||
if res.code ~= 0 then
|
if vim.fn.filereadable(git_entry) == 1 then
|
||||||
vim.notify(("Could not switch to --keep branch %s:\n%s"):format(keep, res.stderr), vim.log.levels.ERROR)
|
vim.notify(
|
||||||
return
|
"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
|
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
|
return
|
||||||
end
|
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/<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
|
||||||
|
)
|
||||||
|
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<string, string>
|
local existing = {} ---@type table<string, string>
|
||||||
for _, wt in ipairs(M.worktrees()) do
|
for _, wt in ipairs(M.worktrees()) do
|
||||||
if wt.branch then
|
if wt.branch then
|
||||||
existing[wt.branch] = wt.path
|
existing[wt.branch] = wt.path
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
vim.fn.mkdir(cwd .. "/.worktrees", "p")
|
||||||
-- Confirm if the main worktree is dirty (only affects `--keep` switching)
|
local created, kept, errors = {}, {}, {}
|
||||||
local dirty = git({ "status", "--porcelain", "--untracked-files=no" })
|
for _, b in ipairs(branches) do
|
||||||
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
|
if existing[b] then
|
||||||
skipped[#skipped + 1] = { b, existing[b] }
|
kept[#kept + 1] = { b, existing[b] }
|
||||||
else
|
else
|
||||||
local dir = M.worktree_dir(b, repo)
|
local dir = M.worktree_dir(b, cwd)
|
||||||
if vim.fn.isdirectory(dir) == 1 then
|
if vim.fn.isdirectory(dir) == 1 then
|
||||||
errors[#errors + 1] = { b, "target exists and is not a worktree: " .. dir }
|
errors[#errors + 1] = { b, "target exists and is not a worktree: " .. dir }
|
||||||
else
|
else
|
||||||
vim.fn.mkdir(vim.fn.fnamemodify(dir, ":h"), "p")
|
|
||||||
local res = git({ "worktree", "add", dir, "--", b })
|
local res = git({ "worktree", "add", dir, "--", b })
|
||||||
if res.code == 0 then
|
if res.code == 0 then
|
||||||
created[#created + 1] = { b, dir }
|
created[#created + 1] = { b, dir }
|
||||||
@@ -546,19 +673,21 @@ function M.convert(opts)
|
|||||||
end
|
end
|
||||||
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
|
if #created > 0 then
|
||||||
lines[#lines + 1] = "Created " .. #created .. " worktree(s):"
|
lines[#lines + 1] = "Created " .. #created .. " worktree(s):"
|
||||||
for _, c in ipairs(created) do
|
for _, c in ipairs(created) do
|
||||||
lines[#lines + 1] = " " .. c[1] .. " → " .. c[2]
|
lines[#lines + 1] = " " .. c[1] .. " → " .. c[2]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if #skipped > 0 then
|
if #kept > 0 then
|
||||||
lines[#lines + 1] = "Already materialised: "
|
lines[#lines + 1] = "Already checked out: "
|
||||||
.. table.concat(
|
.. table.concat(
|
||||||
vim.tbl_map(function(e)
|
vim.tbl_map(function(e)
|
||||||
return e[1]
|
return e[1]
|
||||||
end, skipped),
|
end, kept),
|
||||||
", "
|
", "
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
@@ -570,7 +699,25 @@ function M.convert(opts)
|
|||||||
"\n"
|
"\n"
|
||||||
), vim.log.levels.WARN)
|
), vim.log.levels.WARN)
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
---Parse `GitWorktreeConvert` command args (--keep=<branch>).
|
---Parse `GitWorktreeConvert` command args (--keep=<branch>).
|
||||||
@@ -586,18 +733,25 @@ function M.convert_prompt(fargs)
|
|||||||
M.convert({ keep = keep })
|
M.convert({ keep = keep })
|
||||||
end
|
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)
|
---@param path? string dir to label (defaults to cwd)
|
||||||
---@return string?
|
---@return string?
|
||||||
function M.explorer_title(path)
|
function M.explorer_title(path)
|
||||||
local cwd = path or vim.uv.cwd()
|
local cwd = path or vim.uv.cwd()
|
||||||
local repo = git({ "rev-parse", "--show-toplevel" }, { cwd = cwd })
|
local home = M.home(cwd)
|
||||||
if repo.code ~= 0 or repo.stdout == "" then
|
if not home then
|
||||||
return nil -- not a git dir; let the explorer use its default title
|
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
|
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 wts = git({ "worktree", "list", "--porcelain" }, { cwd = cwd })
|
||||||
local count = 0
|
local count = 0
|
||||||
if wts.code == 0 then
|
if wts.code == 0 then
|
||||||
@@ -620,7 +774,7 @@ end
|
|||||||
---@param opts? table telescope opts
|
---@param opts? table telescope opts
|
||||||
function M.pick(kind, opts)
|
function M.pick(kind, opts)
|
||||||
opts = opts or {}
|
opts = opts or {}
|
||||||
local repo = M.git_root()
|
local repo = M.home()
|
||||||
if not repo then
|
if not repo then
|
||||||
vim.notify("Not inside a git repo — open nvim from a project to switch branches", vim.log.levels.ERROR)
|
vim.notify("Not inside a git repo — open nvim from a project to switch branches", vim.log.levels.ERROR)
|
||||||
return
|
return
|
||||||
@@ -668,14 +822,22 @@ function M.pick(kind, opts)
|
|||||||
|
|
||||||
if kind == "worktree" then
|
if kind == "worktree" then
|
||||||
local wts = M.worktrees()
|
local wts = M.worktrees()
|
||||||
|
local cur_wt = M.git_root()
|
||||||
for _, wt in ipairs(wts) do
|
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] = {
|
items[#items + 1] = {
|
||||||
name = label,
|
name = label,
|
||||||
worktree = wt.path,
|
worktree = wt.path,
|
||||||
remote = nil,
|
remote = nil,
|
||||||
remote_only = false,
|
remote_only = false,
|
||||||
current = wt.path == repo,
|
current = wt.path == repo or wt.path == cur_wt,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
local d = displayer_for(items)
|
local d = displayer_for(items)
|
||||||
@@ -810,7 +972,11 @@ function M.pick(kind, opts)
|
|||||||
remap({ "i", "n" }, "<c-n>", function()
|
remap({ "i", "n" }, "<c-n>", function()
|
||||||
actions.close(prompt_bufnr)
|
actions.close(prompt_bufnr)
|
||||||
M.new_worktree(selected())
|
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
|
else
|
||||||
remap({ "i", "n" }, "<c-d>", function()
|
remap({ "i", "n" }, "<c-d>", function()
|
||||||
if selected() then
|
if selected() then
|
||||||
@@ -819,6 +985,14 @@ function M.pick(kind, opts)
|
|||||||
reopen()
|
reopen()
|
||||||
end
|
end
|
||||||
end, "Remove worktree")
|
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
|
end
|
||||||
return true
|
return true
|
||||||
end,
|
end,
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ return {
|
|||||||
end,
|
end,
|
||||||
desc = "Git Worktrees",
|
desc = "Git Worktrees",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"<leader>gN",
|
||||||
|
function()
|
||||||
|
require("config.git-worktree").new_worktree()
|
||||||
|
end,
|
||||||
|
desc = "New branch worktree (from current)",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"<leader>gC",
|
"<leader>gC",
|
||||||
function()
|
function()
|
||||||
|
|||||||
Reference in New Issue
Block a user