diff --git a/src/main.lua b/src/main.lua index f3314d56d3b3347b16bfa61628af0fc6c82ca574..6b79b3a67e85b65447d8740cfb84efa733372e95 100644 --- a/src/main.lua +++ b/src/main.lua @@ -27,46 +27,16 @@ local relative_path = path_mod.relative_path local path_inside = path_mod.path_inside local escape_pattern = path_mod.escape_pattern ----Walk up from cwd looking for .git file pointing to .bare, or .bare/ directory ----@return string|nil root ----@return string|nil error -local function find_project_root() - local handle = io.popen("pwd") - if not handle then - return nil, "failed to get current directory" - end - local cwd = handle:read("*l") - handle:close() - - if not cwd then - return nil, "failed to get current directory" - end - - local path = cwd - while path and path ~= "" and path ~= "/" do - -- Check for .bare directory - local bare_check = io.open(path .. "/.bare/HEAD", "r") - if bare_check then - bare_check:close() - return path, nil - end - - -- Check for .git file pointing to .bare - local git_file = io.open(path .. "/.git", "r") - if git_file then - local content = git_file:read("*a") - git_file:close() - if content and content:match("gitdir:%s*%.?/?%.bare") then - return path, nil - end - end - - -- Move up one directory - path = path:match("(.+)/[^/]+$") - end - - return nil, "not in a wt-managed repository" -end +local git_mod = require("wt.git") +local find_project_root = git_mod.find_project_root +local detect_source_worktree = git_mod.detect_source_worktree +local branch_exists_local = git_mod.branch_exists_local +local find_branch_remotes = git_mod.find_branch_remotes +local detect_cloned_default_branch = git_mod.detect_cloned_default_branch +local get_default_branch = git_mod.get_default_branch +local parse_branch_remotes = git_mod.parse_branch_remotes +local parse_worktree_list = git_mod.parse_worktree_list +local branch_checked_out_at = git_mod.branch_checked_out_at ---Substitute ${project} in template string ---@param template string @@ -281,31 +251,6 @@ local function extract_project_name(url) return name end ----Detect default branch from cloned bare repo ----@param git_dir string ----@return string -local function detect_cloned_default_branch(git_dir) - -- First try the bare repo's own HEAD (set during clone) - local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git symbolic-ref HEAD") - if code == 0 and output ~= "" then - local branch = output:match("refs/heads/(.+)") - if branch then - return (branch:gsub("%s+$", "")) - end - end - return "main" -end - ----Get default branch name from git config, fallback to "main" ----@return string -local function get_default_branch() - local output, code = run_cmd("git config --get init.defaultBranch") - if code == 0 and output ~= "" then - return (output:gsub("%s+$", "")) - end - return "main" -end - ---Load global config from ~/.config/wt/config.lua ---@return {branch_path_style?: string, flat_separator?: string, remotes?: table, default_remotes?: string[]|string} local function load_global_config() @@ -372,86 +317,6 @@ local function load_project_config(root) return result end ----Check if cwd is inside a worktree (has .git file, not at project root) ----@param root string ----@return string|nil source_worktree path if inside worktree, nil if at project root -local function detect_source_worktree(root) - local cwd = get_cwd() - if not cwd then - return nil - end - -- If cwd is the project root, no source worktree - if cwd == root then - return nil - end - -- Check if cwd has a .git file (indicating it's a worktree) - local git_file = io.open(cwd .. "/.git", "r") - if git_file then - git_file:close() - return cwd - end - -- Walk up to find worktree root - ---@type string|nil - local path = cwd - while path and path ~= "" and path ~= "/" and path ~= root do - local gf = io.open(path .. "/.git", "r") - if gf then - gf:close() - return path - end - path = path:match("(.+)/[^/]+$") - end - return nil -end - ----Check if branch exists locally ----@param git_dir string ----@param branch string ----@return boolean -local function branch_exists_local(git_dir, branch) - return run_cmd_silent("GIT_DIR=" .. git_dir .. " git show-ref --verify --quiet refs/heads/" .. branch) -end - ----Parse git branch -r output to extract remotes containing a branch ----@param output string git branch -r output ----@param branch string branch name to find ----@return string[] remote names -local function parse_branch_remotes(output, branch) - local remotes = {} - for line in output:gmatch("[^\n]+") do - -- Match: " origin/branch-name" or " upstream/feature/foo" - -- For branch "feature/foo", we want remote "origin", not "origin/feature" - -- The remote name is everything before the LAST occurrence of /branch - local trimmed = line:match("^%s*(.-)%s*$") - if trimmed then - -- Check if line ends with /branch - local suffix = "/" .. branch - if trimmed:sub(-#suffix) == suffix then - local remote = trimmed:sub(1, #trimmed - #suffix) - -- Simple remote name (no slashes) - this is what we want - -- Remote names with slashes (e.g., "forks/alice") are ambiguous - -- and skipped for safety - if remote ~= "" and not remote:match("/") then - table.insert(remotes, remote) - end - end - end - end - return remotes -end - ----Find which remotes have the branch ----@param git_dir string ----@param branch string ----@return string[] remote names -local function find_branch_remotes(git_dir, branch) - local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git branch -r --list '*/" .. branch .. "'") - if code ~= 0 then - return {} - end - return parse_branch_remotes(output, branch) -end - ---Load hook permissions from ~/.local/share/wt/hook-dirs.lua ---@return table local function load_hook_permissions() @@ -1147,56 +1012,6 @@ local function get_bare_head(git_dir) return (output:gsub("%s+$", "")) end ----Parse git worktree list --porcelain output ----@param output string git worktree list --porcelain output ----@return table[] array of {path: string, branch?: string, bare?: boolean, detached?: boolean} -local function parse_worktree_list(output) - local worktrees = {} - ---@type {path: string, branch?: string, bare?: boolean, detached?: boolean, head?: string}|nil - local current = nil - for line in output:gmatch("[^\n]+") do - local key, value = line:match("^(%S+)%s*(.*)$") - if key == "worktree" and value then - if current then - table.insert(worktrees, current) - end - current = { path = value } - elseif current then - if key == "branch" and value then - current.branch = value:gsub("^refs/heads/", "") - elseif key == "bare" then - current.bare = true - elseif key == "detached" then - current.detached = true - elseif key == "HEAD" then - current.head = value - end - end - end - if current then - table.insert(worktrees, current) - end - return worktrees -end - ----Check if branch is checked out in any worktree ----@param git_dir string ----@param branch string ----@return string|nil path if checked out, nil otherwise -local function branch_checked_out_at(git_dir, branch) - local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain") - if code ~= 0 then - return nil - end - local worktrees = parse_worktree_list(output) - for _, wt in ipairs(worktrees) do - if wt.branch == branch then - return wt.path - end - end - return nil -end - ---@param args string[] local function cmd_remove(args) -- Parse arguments: [-b] [-f] @@ -1793,55 +1608,9 @@ if pcall(debug.getlocal, 4, 1) then end end end, - -- Project root detection - find_project_root = function(cwd_override) - local cwd = cwd_override or get_cwd() - if not cwd then - return nil, "failed to get current directory" - end - local path = cwd - while path and path ~= "" and path ~= "/" do - local bare_check = io.open(path .. "/.bare/HEAD", "r") - if bare_check then - bare_check:close() - return path, nil - end - local git_file = io.open(path .. "/.git", "r") - if git_file then - local content = git_file:read("*a") - git_file:close() - if content and content:match("gitdir:%s*%.?/?%.bare") then - return path, nil - end - end - path = path:match("(.+)/[^/]+$") - end - return nil, "not in a wt-managed repository" - end, - detect_source_worktree = function(root, cwd_override) - local cwd = cwd_override or get_cwd() - if not cwd then - return nil - end - if cwd == root then - return nil - end - local git_file = io.open(cwd .. "/.git", "r") - if git_file then - git_file:close() - return cwd - end - local path = cwd - while path and path ~= "" and path ~= "/" and path ~= root do - local gf = io.open(path .. "/.git", "r") - if gf then - gf:close() - return path - end - path = path:match("(.+)/[^/]+$") - end - return nil - end, + -- Project root detection (re-exported from wt.git) + find_project_root = find_project_root, + detect_source_worktree = detect_source_worktree, -- Command execution (for integration tests) run_cmd = run_cmd, run_cmd_silent = run_cmd_silent, diff --git a/src/wt/git.lua b/src/wt/git.lua new file mode 100644 index 0000000000000000000000000000000000000000..890fe081eb3eb0aaf3b01541970ba60194340415 --- /dev/null +++ b/src/wt/git.lua @@ -0,0 +1,214 @@ +-- SPDX-FileCopyrightText: Amolith +-- +-- SPDX-License-Identifier: GPL-3.0-or-later + +local shell = require("wt.shell") +local run_cmd = shell.run_cmd +local run_cmd_silent = shell.run_cmd_silent +local get_cwd = shell.get_cwd + +---@class wt.git +local M = {} + +---Walk up from cwd looking for .git file pointing to .bare, or .bare/ directory +---@param cwd_override? string optional starting directory (defaults to pwd) +---@return string|nil root +---@return string|nil error +function M.find_project_root(cwd_override) + local cwd = cwd_override + if not cwd then + local handle = io.popen("pwd") + if not handle then + return nil, "failed to get current directory" + end + cwd = handle:read("*l") + handle:close() + end + + if not cwd then + return nil, "failed to get current directory" + end + + local path = cwd + while path and path ~= "" and path ~= "/" do + -- Check for .bare directory + local bare_check = io.open(path .. "/.bare/HEAD", "r") + if bare_check then + bare_check:close() + return path, nil + end + + -- Check for .git file pointing to .bare + local git_file = io.open(path .. "/.git", "r") + if git_file then + local content = git_file:read("*a") + git_file:close() + if content and content:match("gitdir:%s*%.?/?%.bare") then + return path, nil + end + end + + -- Move up one directory + path = path:match("(.+)/[^/]+$") + end + + return nil, "not in a wt-managed repository" +end + +---Check if cwd is inside a worktree (has .git file, not at project root) +---@param root string +---@param cwd_override? string optional current directory (defaults to get_cwd()) +---@return string|nil source_worktree path if inside worktree, nil if at project root +function M.detect_source_worktree(root, cwd_override) + local cwd = cwd_override or get_cwd() + if not cwd then + return nil + end + -- If cwd is the project root, no source worktree + if cwd == root then + return nil + end + -- Check if cwd has a .git file (indicating it's a worktree) + local git_file = io.open(cwd .. "/.git", "r") + if git_file then + git_file:close() + return cwd + end + -- Walk up to find worktree root + ---@type string|nil + local path = cwd + while path and path ~= "" and path ~= "/" and path ~= root do + local gf = io.open(path .. "/.git", "r") + if gf then + gf:close() + return path + end + path = path:match("(.+)/[^/]+$") + end + return nil +end + +---Check if branch exists locally +---@param git_dir string +---@param branch string +---@return boolean +function M.branch_exists_local(git_dir, branch) + return run_cmd_silent("GIT_DIR=" .. git_dir .. " git show-ref --verify --quiet refs/heads/" .. branch) +end + +---Parse git branch -r output to extract remotes containing a branch +---@param output string git branch -r output +---@param branch string branch name to find +---@return string[] remote names +function M.parse_branch_remotes(output, branch) + local remotes = {} + for line in output:gmatch("[^\n]+") do + -- Match: " origin/branch-name" or " upstream/feature/foo" + -- For branch "feature/foo", we want remote "origin", not "origin/feature" + -- The remote name is everything before the LAST occurrence of /branch + local trimmed = line:match("^%s*(.-)%s*$") + if trimmed then + -- Check if line ends with /branch + local suffix = "/" .. branch + if trimmed:sub(-#suffix) == suffix then + local remote = trimmed:sub(1, #trimmed - #suffix) + -- Simple remote name (no slashes) - this is what we want + -- Remote names with slashes (e.g., "forks/alice") are ambiguous + -- and skipped for safety + if remote ~= "" and not remote:match("/") then + table.insert(remotes, remote) + end + end + end + end + return remotes +end + +---Find which remotes have the branch +---@param git_dir string +---@param branch string +---@return string[] remote names +function M.find_branch_remotes(git_dir, branch) + local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git branch -r --list '*/" .. branch .. "'") + if code ~= 0 then + return {} + end + return M.parse_branch_remotes(output, branch) +end + +---Detect default branch from cloned bare repo's HEAD +---@param git_dir string +---@return string branch name +function M.detect_cloned_default_branch(git_dir) + -- First try the bare repo's own HEAD (set during clone) + local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git symbolic-ref HEAD") + if code == 0 and output ~= "" then + local branch = output:match("refs/heads/(.+)") + if branch then + return (branch:gsub("%s+$", "")) + end + end + return "main" +end + +---Get default branch name from git config, fallback to "main" +---@return string +function M.get_default_branch() + local output, code = run_cmd("git config --get init.defaultBranch") + if code == 0 and output ~= "" then + return (output:gsub("%s+$", "")) + end + return "main" +end + +---Parse git worktree list --porcelain output +---@param output string +---@return {path: string, branch?: string, bare?: boolean, detached?: boolean, head?: string}[] +function M.parse_worktree_list(output) + local worktrees = {} + ---@type {path: string, branch?: string, bare?: boolean, detached?: boolean, head?: string}|nil + local current = nil + for line in output:gmatch("[^\n]+") do + local key, value = line:match("^(%S+)%s*(.*)$") + if key == "worktree" and value then + if current then + table.insert(worktrees, current) + end + current = { path = value } + elseif current then + if key == "branch" and value then + current.branch = value:gsub("^refs/heads/", "") + elseif key == "bare" then + current.bare = true + elseif key == "detached" then + current.detached = true + elseif key == "HEAD" then + current.head = value + end + end + end + if current then + table.insert(worktrees, current) + end + return worktrees +end + +---Check if branch is checked out in any worktree +---@param git_dir string +---@param branch string +---@return string|nil path if checked out, nil otherwise +function M.branch_checked_out_at(git_dir, branch) + local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain") + if code ~= 0 then + return nil + end + local worktrees = M.parse_worktree_list(output) + for _, wt in ipairs(worktrees) do + if wt.branch == branch then + return wt.path + end + end + return nil +end + +return M