@@ -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<string, string>, 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<string, boolean>
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: <branch> [-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,
@@ -0,0 +1,214 @@
+-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+--
+-- 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