@@ -197,40 +197,32 @@ end
return M
]]
-
-if _VERSION < "Lua 5.2" then
- io.stderr:write("error: wt requires Lua 5.2 or later\n")
- os.exit(1)
-end
-
-local exit = require("wt.exit")
-local EXIT_SUCCESS = exit.EXIT_SUCCESS
-local EXIT_USER_ERROR = exit.EXIT_USER_ERROR
-local EXIT_SYSTEM_ERROR = exit.EXIT_SYSTEM_ERROR
+_EMBEDDED_MODULES["wt.git"] = [[-- 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
-local die = shell.die
-local path_mod = require("wt.path")
-local branch_to_path = path_mod.branch_to_path
-local split_path = path_mod.split_path
-local relative_path = path_mod.relative_path
-local path_inside = path_mod.path_inside
-local escape_pattern = path_mod.escape_pattern
+---@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
-local function find_project_root()
- local handle = io.popen("pwd")
- if not handle then
- return nil, "failed to get current directory"
+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
- local cwd = handle:read("*l")
- handle:close()
if not cwd then
return nil, "failed to get current directory"
@@ -262,11 +254,177 @@ local function find_project_root()
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
+]]
+
+_EMBEDDED_MODULES["wt.config"] = [[-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+--
+-- SPDX-License-Identifier: GPL-3.0-or-later
+
+---@class wt.config
+local M = {}
+
---Substitute ${project} in template string
---@param template string
---@param project_name string
---@return string
-local function resolve_url_template(template, project_name)
+function M.resolve_url_template(template, project_name)
local escaped = project_name:gsub("%%", "%%%%")
return (template:gsub("%${project}", escaped))
end
@@ -274,7 +432,7 @@ end
---Parse git URLs to extract project name
---@param url string
---@return string|nil
-local function _extract_project_name(url) -- luacheck: ignore 211
+function M.extract_project_name(url)
if not url or url == "" then
return nil
end
@@ -302,6 +460,116 @@ local function _extract_project_name(url) -- luacheck: ignore 211
return name
end
+---Load global config from ~/.config/wt/config.lua
+---@return table
+function M.load_global_config()
+ local home = os.getenv("HOME")
+ if not home then
+ return {}
+ end
+ local config_path = home .. "/.config/wt/config.lua"
+ local f = io.open(config_path, "r")
+ if not f then
+ return {}
+ end
+ local content = f:read("*a")
+ f:close()
+ local chunk, err = load(content, config_path, "t", {})
+ if not chunk then
+ chunk, err = load("return " .. content, config_path, "t", {})
+ end
+ if not chunk then
+ io.stderr:write("warning: config syntax error in " .. config_path .. ": " .. err .. "\n")
+ return {}
+ end
+ local ok, result = pcall(chunk)
+ if not ok then
+ io.stderr:write("warning: config execution error in " .. config_path .. ": " .. result .. "\n")
+ return {}
+ end
+ if type(result) ~= "table" then
+ io.stderr:write("warning: config must return a table in " .. config_path .. "\n")
+ return {}
+ end
+ return result
+end
+
+---Load project config from <root>/.wt.lua
+---@param root string
+---@return {hooks?: {copy?: string[], symlink?: string[], run?: string[]}}
+function M.load_project_config(root)
+ local config_path = root .. "/.wt.lua"
+ local f = io.open(config_path, "r")
+ if not f then
+ return {}
+ end
+ local content = f:read("*a")
+ f:close()
+
+ local chunk, err = load(content, config_path, "t", {})
+ if not chunk then
+ chunk, err = load("return " .. content, config_path, "t", {})
+ end
+ if not chunk then
+ io.stderr:write("warning: config syntax error in " .. config_path .. ": " .. err .. "\n")
+ return {}
+ end
+ local ok, result = pcall(chunk)
+ if not ok then
+ io.stderr:write("warning: config execution error in " .. config_path .. ": " .. result .. "\n")
+ return {}
+ end
+ if type(result) ~= "table" then
+ io.stderr:write("warning: config must return a table in " .. config_path .. "\n")
+ return {}
+ end
+ return result
+end
+
+return M
+]]
+
+
+if _VERSION < "Lua 5.2" then
+ io.stderr:write("error: wt requires Lua 5.2 or later\n")
+ os.exit(1)
+end
+
+local exit = require("wt.exit")
+local EXIT_SUCCESS = exit.EXIT_SUCCESS
+local EXIT_USER_ERROR = exit.EXIT_USER_ERROR
+local EXIT_SYSTEM_ERROR = exit.EXIT_SYSTEM_ERROR
+
+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
+local die = shell.die
+
+local path_mod = require("wt.path")
+local branch_to_path = path_mod.branch_to_path
+local split_path = path_mod.split_path
+local relative_path = path_mod.relative_path
+local path_inside = path_mod.path_inside
+local escape_pattern = path_mod.escape_pattern
+
+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
+
+local config_mod = require("wt.config")
+local resolve_url_template = config_mod.resolve_url_template
+local extract_project_name = config_mod.extract_project_name
+local load_global_config = config_mod.load_global_config
+local load_project_config = config_mod.load_project_config
+
---Print usage information
local function print_usage()
print("wt - git worktree manager")
@@ -444,208 +712,6 @@ local function show_command_help(cmd)
os.exit(EXIT_SUCCESS)
end
----Parse git URLs to extract project name (exported version)
----@param url string
----@return string|nil
-local function extract_project_name(url)
- if not url or url == "" then
- return nil
- end
-
- url = url:gsub("[?#].*$", "")
- url = url:gsub("/+$", "")
-
- if url == "" or url == "/" then
- return nil
- end
-
- url = url:gsub("%.git$", "")
-
- if not url:match("://") then
- local scp_path = url:match("^[^@]+@[^:]+:(.+)$")
- if scp_path and scp_path ~= "" then
- url = scp_path
- end
- end
-
- local name = url:match("([^/]+)$") or url:match("([^:]+)$")
- if not name or name == "" then
- return nil
- end
- 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()
- local home = os.getenv("HOME")
- if not home then
- return {}
- end
- local config_path = home .. "/.config/wt/config.lua"
- local f = io.open(config_path, "r")
- if not f then
- return {}
- end
- local content = f:read("*a")
- f:close()
- local chunk, err = load(content, config_path, "t", {})
- if not chunk then
- chunk, err = load("return " .. content, config_path, "t", {})
- end
- if not chunk then
- io.stderr:write("warning: config syntax error in " .. config_path .. ": " .. err .. "\n")
- return {}
- end
- local ok, result = pcall(chunk)
- if not ok then
- io.stderr:write("warning: config execution error in " .. config_path .. ": " .. result .. "\n")
- return {}
- end
- if type(result) ~= "table" then
- io.stderr:write("warning: config must return a table in " .. config_path .. "\n")
- return {}
- end
- return result
-end
-
----Load project config from <root>/.wt.lua
----@param root string
----@return {hooks?: {copy?: string[], symlink?: string[], run?: string[]}}
-local function load_project_config(root)
- local config_path = root .. "/.wt.lua"
- local f = io.open(config_path, "r")
- if not f then
- return {}
- end
- local content = f:read("*a")
- f:close()
-
- local chunk, err = load(content, config_path, "t", {})
- if not chunk then
- chunk, err = load("return " .. content, config_path, "t", {})
- end
- if not chunk then
- io.stderr:write("warning: config syntax error in " .. config_path .. ": " .. err .. "\n")
- return {}
- end
- local ok, result = pcall(chunk)
- if not ok then
- io.stderr:write("warning: config execution error in " .. config_path .. ": " .. result .. "\n")
- return {}
- end
- if type(result) ~= "table" then
- io.stderr:write("warning: config must return a table in " .. config_path .. "\n")
- return {}
- end
- 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()
@@ -1341,56 +1407,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]
@@ -1987,55 +2003,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,
@@ -38,45 +38,11 @@ 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
----@param project_name string
----@return string
-local function resolve_url_template(template, project_name)
- local escaped = project_name:gsub("%%", "%%%%")
- return (template:gsub("%${project}", escaped))
-end
-
----Parse git URLs to extract project name
----@param url string
----@return string|nil
-local function _extract_project_name(url) -- luacheck: ignore 211
- if not url or url == "" then
- return nil
- end
-
- url = url:gsub("[?#].*$", "")
- url = url:gsub("/+$", "")
-
- if url == "" or url == "/" then
- return nil
- end
-
- url = url:gsub("%.git$", "")
-
- if not url:match("://") then
- local scp_path = url:match("^[^@]+@[^:]+:(.+)$")
- if scp_path and scp_path ~= "" then
- url = scp_path
- end
- end
-
- local name = url:match("([^/]+)$") or url:match("([^:]+)$")
- if not name or name == "" then
- return nil
- end
- return name
-end
+local config_mod = require("wt.config")
+local resolve_url_template = config_mod.resolve_url_template
+local extract_project_name = config_mod.extract_project_name
+local load_global_config = config_mod.load_global_config
+local load_project_config = config_mod.load_project_config
---Print usage information
local function print_usage()
@@ -220,103 +186,6 @@ local function show_command_help(cmd)
os.exit(EXIT_SUCCESS)
end
----Parse git URLs to extract project name (exported version)
----@param url string
----@return string|nil
-local function extract_project_name(url)
- if not url or url == "" then
- return nil
- end
-
- url = url:gsub("[?#].*$", "")
- url = url:gsub("/+$", "")
-
- if url == "" or url == "/" then
- return nil
- end
-
- url = url:gsub("%.git$", "")
-
- if not url:match("://") then
- local scp_path = url:match("^[^@]+@[^:]+:(.+)$")
- if scp_path and scp_path ~= "" then
- url = scp_path
- end
- end
-
- local name = url:match("([^/]+)$") or url:match("([^:]+)$")
- if not name or name == "" then
- return nil
- end
- return name
-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()
- local home = os.getenv("HOME")
- if not home then
- return {}
- end
- local config_path = home .. "/.config/wt/config.lua"
- local f = io.open(config_path, "r")
- if not f then
- return {}
- end
- local content = f:read("*a")
- f:close()
- local chunk, err = load(content, config_path, "t", {})
- if not chunk then
- chunk, err = load("return " .. content, config_path, "t", {})
- end
- if not chunk then
- io.stderr:write("warning: config syntax error in " .. config_path .. ": " .. err .. "\n")
- return {}
- end
- local ok, result = pcall(chunk)
- if not ok then
- io.stderr:write("warning: config execution error in " .. config_path .. ": " .. result .. "\n")
- return {}
- end
- if type(result) ~= "table" then
- io.stderr:write("warning: config must return a table in " .. config_path .. "\n")
- return {}
- end
- return result
-end
-
----Load project config from <root>/.wt.lua
----@param root string
----@return {hooks?: {copy?: string[], symlink?: string[], run?: string[]}}
-local function load_project_config(root)
- local config_path = root .. "/.wt.lua"
- local f = io.open(config_path, "r")
- if not f then
- return {}
- end
- local content = f:read("*a")
- f:close()
-
- local chunk, err = load(content, config_path, "t", {})
- if not chunk then
- chunk, err = load("return " .. content, config_path, "t", {})
- end
- if not chunk then
- io.stderr:write("warning: config syntax error in " .. config_path .. ": " .. err .. "\n")
- return {}
- end
- local ok, result = pcall(chunk)
- if not ok then
- io.stderr:write("warning: config execution error in " .. config_path .. ": " .. result .. "\n")
- return {}
- end
- if type(result) ~= "table" then
- io.stderr:write("warning: config must return a table in " .. config_path .. "\n")
- return {}
- end
- return result
-end
-
---Load hook permissions from ~/.local/share/wt/hook-dirs.lua
---@return table<string, boolean>
local function load_hook_permissions()
@@ -0,0 +1,114 @@
+-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+--
+-- SPDX-License-Identifier: GPL-3.0-or-later
+
+---@class wt.config
+local M = {}
+
+---Substitute ${project} in template string
+---@param template string
+---@param project_name string
+---@return string
+function M.resolve_url_template(template, project_name)
+ local escaped = project_name:gsub("%%", "%%%%")
+ return (template:gsub("%${project}", escaped))
+end
+
+---Parse git URLs to extract project name
+---@param url string
+---@return string|nil
+function M.extract_project_name(url)
+ if not url or url == "" then
+ return nil
+ end
+
+ url = url:gsub("[?#].*$", "")
+ url = url:gsub("/+$", "")
+
+ if url == "" or url == "/" then
+ return nil
+ end
+
+ url = url:gsub("%.git$", "")
+
+ if not url:match("://") then
+ local scp_path = url:match("^[^@]+@[^:]+:(.+)$")
+ if scp_path and scp_path ~= "" then
+ url = scp_path
+ end
+ end
+
+ local name = url:match("([^/]+)$") or url:match("([^:]+)$")
+ if not name or name == "" then
+ return nil
+ end
+ return name
+end
+
+---Load global config from ~/.config/wt/config.lua
+---@return table
+function M.load_global_config()
+ local home = os.getenv("HOME")
+ if not home then
+ return {}
+ end
+ local config_path = home .. "/.config/wt/config.lua"
+ local f = io.open(config_path, "r")
+ if not f then
+ return {}
+ end
+ local content = f:read("*a")
+ f:close()
+ local chunk, err = load(content, config_path, "t", {})
+ if not chunk then
+ chunk, err = load("return " .. content, config_path, "t", {})
+ end
+ if not chunk then
+ io.stderr:write("warning: config syntax error in " .. config_path .. ": " .. err .. "\n")
+ return {}
+ end
+ local ok, result = pcall(chunk)
+ if not ok then
+ io.stderr:write("warning: config execution error in " .. config_path .. ": " .. result .. "\n")
+ return {}
+ end
+ if type(result) ~= "table" then
+ io.stderr:write("warning: config must return a table in " .. config_path .. "\n")
+ return {}
+ end
+ return result
+end
+
+---Load project config from <root>/.wt.lua
+---@param root string
+---@return {hooks?: {copy?: string[], symlink?: string[], run?: string[]}}
+function M.load_project_config(root)
+ local config_path = root .. "/.wt.lua"
+ local f = io.open(config_path, "r")
+ if not f then
+ return {}
+ end
+ local content = f:read("*a")
+ f:close()
+
+ local chunk, err = load(content, config_path, "t", {})
+ if not chunk then
+ chunk, err = load("return " .. content, config_path, "t", {})
+ end
+ if not chunk then
+ io.stderr:write("warning: config syntax error in " .. config_path .. ": " .. err .. "\n")
+ return {}
+ end
+ local ok, result = pcall(chunk)
+ if not ok then
+ io.stderr:write("warning: config execution error in " .. config_path .. ": " .. result .. "\n")
+ return {}
+ end
+ if type(result) ~= "table" then
+ io.stderr:write("warning: config must return a table in " .. config_path .. "\n")
+ return {}
+ end
+ return result
+end
+
+return M