refactor(wt): extract config module

Amolith created

Assisted-by: Claude Opus 4.5 via Amp

Change summary

dist/wt           | 622 +++++++++++++++++++++++-------------------------
src/main.lua      | 141 ----------
src/wt/config.lua | 114 ++++++++
3 files changed, 415 insertions(+), 462 deletions(-)

Detailed changes

dist/wt 🔗

@@ -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,

src/main.lua 🔗

@@ -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()

src/wt/config.lua 🔗

@@ -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