refactor(wt): extract hooks module

Amolith created

Create src/wt/hooks.lua with load_hook_permissions,
save_hook_permissions, summarize_hooks, check_hook_permission, and
run_hooks functions. Update main.lua to import from the new module.

Assisted-by: Claude Opus 4.5 via Amp

Change summary

dist/wt          | 431 +++++++++++++++++++++----------------------------
src/main.lua     | 254 +----------------------------
src/wt/hooks.lua | 175 ++++++++++++++++++++
3 files changed, 372 insertions(+), 488 deletions(-)

Detailed changes

dist/wt 🔗

@@ -529,6 +529,183 @@ end
 return M
 ]]
 
+_EMBEDDED_MODULES["wt.hooks"] = [[-- 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
+
+---@class wt.hooks
+local M = {}
+
+---Load hook permissions from ~/.local/share/wt/hook-dirs.lua
+---@param home_override? string override HOME for testing
+---@return table<string, boolean>
+function M.load_hook_permissions(home_override)
+	local home = home_override or os.getenv("HOME")
+	if not home then
+		return {}
+	end
+	local path = home .. "/.local/share/wt/hook-dirs.lua"
+	local f = io.open(path, "r")
+	if not f then
+		return {}
+	end
+	local content = f:read("*a")
+	f:close()
+	local chunk = load("return " .. content, path, "t", {})
+	if not chunk then
+		return {}
+	end
+	local ok, result = pcall(chunk)
+	if ok and type(result) == "table" then
+		return result
+	end
+	return {}
+end
+
+---Save hook permissions to ~/.local/share/wt/hook-dirs.lua
+---@param perms table<string, boolean>
+---@param home_override? string override HOME for testing
+function M.save_hook_permissions(perms, home_override)
+	local home = home_override or os.getenv("HOME")
+	if not home then
+		return
+	end
+	local dir = home .. "/.local/share/wt"
+	run_cmd_silent("mkdir -p " .. dir)
+	local path = dir .. "/hook-dirs.lua"
+	local f = io.open(path, "w")
+	if not f then
+		return
+	end
+	f:write("{\n")
+	for k, v in pairs(perms) do
+		f:write('\t["' .. k .. '"] = ' .. tostring(v) .. ",\n")
+	end
+	f:write("}\n")
+	f:close()
+end
+
+---Summarize hooks for confirmation prompt
+---@param hooks {copy?: string[], symlink?: string[], run?: string[]}
+---@return string
+function M.summarize_hooks(hooks)
+	local parts = {}
+	if hooks.copy and #hooks.copy > 0 then
+		local items = {}
+		for i = 1, math.min(3, #hooks.copy) do
+			table.insert(items, hooks.copy[i])
+		end
+		local suffix = #hooks.copy > 3 and " (+" .. (#hooks.copy - 3) .. " more)" or ""
+		table.insert(parts, "copy: " .. table.concat(items, ", ") .. suffix)
+	end
+	if hooks.symlink and #hooks.symlink > 0 then
+		local items = {}
+		for i = 1, math.min(3, #hooks.symlink) do
+			table.insert(items, hooks.symlink[i])
+		end
+		local suffix = #hooks.symlink > 3 and " (+" .. (#hooks.symlink - 3) .. " more)" or ""
+		table.insert(parts, "symlink: " .. table.concat(items, ", ") .. suffix)
+	end
+	if hooks.run and #hooks.run > 0 then
+		local items = {}
+		for i = 1, math.min(3, #hooks.run) do
+			table.insert(items, hooks.run[i])
+		end
+		local suffix = #hooks.run > 3 and " (+" .. (#hooks.run - 3) .. " more)" or ""
+		table.insert(parts, "run: " .. table.concat(items, ", ") .. suffix)
+	end
+	return table.concat(parts, "; ")
+end
+
+---Check if hooks are allowed for a project, prompting if unknown
+---@param root string project root path
+---@param hooks {copy?: string[], symlink?: string[], run?: string[]}
+---@return boolean allowed
+function M.check_hook_permission(root, hooks)
+	local perms = M.load_hook_permissions()
+	if perms[root] ~= nil then
+		return perms[root]
+	end
+
+	-- Prompt user
+	local summary = M.summarize_hooks(hooks)
+	local prompt = "Allow hooks for " .. root .. "?\\n" .. summary
+	local allowed = run_cmd_silent("gum confirm " .. "'" .. prompt:gsub("'", "'\\''") .. "'")
+
+	perms[root] = allowed
+	M.save_hook_permissions(perms)
+	return allowed
+end
+
+---Run hooks from .wt.lua config
+---@param source string source worktree path
+---@param target string target worktree path
+---@param hooks {copy?: string[], symlink?: string[], run?: string[]}
+---@param root string project root path
+---@param home_override? string override HOME for testing
+function M.run_hooks(source, target, hooks, root, home_override)
+	-- Check permission before running any hooks
+	-- For testing: check permissions file directly if home_override given
+	if home_override then
+		local perms = M.load_hook_permissions(home_override)
+		if perms[root] == false then
+			io.stderr:write("hooks skipped (not allowed for this project)\n")
+			return
+		end
+	else
+		if not M.check_hook_permission(root, hooks) then
+			io.stderr:write("hooks skipped (not allowed for this project)\n")
+			return
+		end
+	end
+
+	if hooks.copy then
+		for _, item in ipairs(hooks.copy) do
+			local src = source .. "/" .. item
+			local dst = target .. "/" .. item
+			-- Create parent directory if needed
+			local parent = dst:match("(.+)/[^/]+$")
+			if parent then
+				run_cmd_silent("mkdir -p " .. parent)
+			end
+			local _, code = run_cmd("cp -r " .. src .. " " .. dst)
+			if code ~= 0 then
+				io.stderr:write("warning: failed to copy " .. item .. "\n")
+			end
+		end
+	end
+	if hooks.symlink then
+		for _, item in ipairs(hooks.symlink) do
+			local src = source .. "/" .. item
+			local dst = target .. "/" .. item
+			-- Create parent directory if needed
+			local parent = dst:match("(.+)/[^/]+$")
+			if parent then
+				run_cmd_silent("mkdir -p " .. parent)
+			end
+			local _, code = run_cmd("ln -s " .. src .. " " .. dst)
+			if code ~= 0 then
+				io.stderr:write("warning: failed to symlink " .. item .. "\n")
+			end
+		end
+	end
+	if hooks.run then
+		for _, cmd in ipairs(hooks.run) do
+			local _, code = run_cmd("cd " .. target .. " && " .. cmd)
+			if code ~= 0 then
+				io.stderr:write("warning: hook command failed: " .. cmd .. "\n")
+			end
+		end
+	end
+end
+
+return M
+]]
+
 
 if _VERSION < "Lua 5.2" then
 	io.stderr:write("error: wt requires Lua 5.2 or later\n")
@@ -570,6 +747,12 @@ 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
 
+local hooks_mod = require("wt.hooks")
+local load_hook_permissions = hooks_mod.load_hook_permissions
+local save_hook_permissions = hooks_mod.save_hook_permissions
+local summarize_hooks = hooks_mod.summarize_hooks
+local run_hooks = hooks_mod.run_hooks
+
 ---Print usage information
 local function print_usage()
 	print("wt - git worktree manager")
@@ -712,157 +895,6 @@ local function show_command_help(cmd)
 	os.exit(EXIT_SUCCESS)
 end
 
----Load hook permissions from ~/.local/share/wt/hook-dirs.lua
----@return table<string, boolean>
-local function load_hook_permissions()
-	local home = os.getenv("HOME")
-	if not home then
-		return {}
-	end
-	local path = home .. "/.local/share/wt/hook-dirs.lua"
-	local f = io.open(path, "r")
-	if not f then
-		return {}
-	end
-	local content = f:read("*a")
-	f:close()
-	local chunk = load("return " .. content, path, "t", {})
-	if not chunk then
-		return {}
-	end
-	local ok, result = pcall(chunk)
-	if ok and type(result) == "table" then
-		return result
-	end
-	return {}
-end
-
----Save hook permissions to ~/.local/share/wt/hook-dirs.lua
----@param perms table<string, boolean>
-local function save_hook_permissions(perms)
-	local home = os.getenv("HOME")
-	if not home then
-		return
-	end
-	local dir = home .. "/.local/share/wt"
-	run_cmd_silent("mkdir -p " .. dir)
-	local path = dir .. "/hook-dirs.lua"
-	local f = io.open(path, "w")
-	if not f then
-		return
-	end
-	f:write("{\n")
-	for k, v in pairs(perms) do
-		f:write('\t["' .. k .. '"] = ' .. tostring(v) .. ",\n")
-	end
-	f:write("}\n")
-	f:close()
-end
-
----Summarize hooks for confirmation prompt
----@param hooks {copy?: string[], symlink?: string[], run?: string[]}
----@return string
-local function summarize_hooks(hooks)
-	local parts = {}
-	if hooks.copy and #hooks.copy > 0 then
-		local items = {}
-		for i = 1, math.min(3, #hooks.copy) do
-			table.insert(items, hooks.copy[i])
-		end
-		local suffix = #hooks.copy > 3 and " (+" .. (#hooks.copy - 3) .. " more)" or ""
-		table.insert(parts, "copy: " .. table.concat(items, ", ") .. suffix)
-	end
-	if hooks.symlink and #hooks.symlink > 0 then
-		local items = {}
-		for i = 1, math.min(3, #hooks.symlink) do
-			table.insert(items, hooks.symlink[i])
-		end
-		local suffix = #hooks.symlink > 3 and " (+" .. (#hooks.symlink - 3) .. " more)" or ""
-		table.insert(parts, "symlink: " .. table.concat(items, ", ") .. suffix)
-	end
-	if hooks.run and #hooks.run > 0 then
-		local items = {}
-		for i = 1, math.min(3, #hooks.run) do
-			table.insert(items, hooks.run[i])
-		end
-		local suffix = #hooks.run > 3 and " (+" .. (#hooks.run - 3) .. " more)" or ""
-		table.insert(parts, "run: " .. table.concat(items, ", ") .. suffix)
-	end
-	return table.concat(parts, "; ")
-end
-
----Check if hooks are allowed for a project, prompting if unknown
----@param root string project root path
----@param hooks {copy?: string[], symlink?: string[], run?: string[]}
----@return boolean allowed
-local function check_hook_permission(root, hooks)
-	local perms = load_hook_permissions()
-	if perms[root] ~= nil then
-		return perms[root]
-	end
-
-	-- Prompt user
-	local summary = summarize_hooks(hooks)
-	local prompt = "Allow hooks for " .. root .. "?\\n" .. summary
-	local allowed = run_cmd_silent("gum confirm " .. "'" .. prompt:gsub("'", "'\\''") .. "'")
-
-	perms[root] = allowed
-	save_hook_permissions(perms)
-	return allowed
-end
-
----Run hooks from .wt.lua config
----@param source string source worktree path
----@param target string target worktree path
----@param hooks {copy?: string[], symlink?: string[], run?: string[]}
----@param root string project root path
-local function run_hooks(source, target, hooks, root)
-	-- Check permission before running any hooks
-	if not check_hook_permission(root, hooks) then
-		io.stderr:write("hooks skipped (not allowed for this project)\n")
-		return
-	end
-
-	if hooks.copy then
-		for _, item in ipairs(hooks.copy) do
-			local src = source .. "/" .. item
-			local dst = target .. "/" .. item
-			-- Create parent directory if needed
-			local parent = dst:match("(.+)/[^/]+$")
-			if parent then
-				run_cmd_silent("mkdir -p " .. parent)
-			end
-			local _, code = run_cmd("cp -r " .. src .. " " .. dst)
-			if code ~= 0 then
-				io.stderr:write("warning: failed to copy " .. item .. "\n")
-			end
-		end
-	end
-	if hooks.symlink then
-		for _, item in ipairs(hooks.symlink) do
-			local src = source .. "/" .. item
-			local dst = target .. "/" .. item
-			-- Create parent directory if needed
-			local parent = dst:match("(.+)/[^/]+$")
-			if parent then
-				run_cmd_silent("mkdir -p " .. parent)
-			end
-			local _, code = run_cmd("ln -s " .. src .. " " .. dst)
-			if code ~= 0 then
-				io.stderr:write("warning: failed to symlink " .. item .. "\n")
-			end
-		end
-	end
-	if hooks.run then
-		for _, cmd in ipairs(hooks.run) do
-			local _, code = run_cmd("cd " .. target .. " && " .. cmd)
-			if code ~= 0 then
-				io.stderr:write("warning: hook command failed: " .. cmd .. "\n")
-			end
-		end
-	end
-end
-
 ---@param args string[]
 local function cmd_clone(args)
 	-- Parse arguments: <url> [--remote name]... [--own]
@@ -1909,100 +1941,11 @@ if pcall(debug.getlocal, 4, 1) then
 		parse_branch_remotes = parse_branch_remotes,
 		parse_worktree_list = parse_worktree_list,
 		escape_pattern = escape_pattern,
-		-- Hook helpers
+		-- Hook helpers (re-exported from wt.hooks)
 		summarize_hooks = summarize_hooks,
-		load_hook_permissions = function(home_override)
-			local home = home_override or os.getenv("HOME")
-			if not home then
-				return {}
-			end
-			local path = home .. "/.local/share/wt/hook-dirs.lua"
-			local f = io.open(path, "r")
-			if not f then
-				return {}
-			end
-			local content = f:read("*a")
-			f:close()
-			local chunk = load("return " .. content, path, "t", {})
-			if not chunk then
-				return {}
-			end
-			local ok, result = pcall(chunk)
-			if ok and type(result) == "table" then
-				return result
-			end
-			return {}
-		end,
-		save_hook_permissions = function(perms, home_override)
-			local home = home_override or os.getenv("HOME")
-			if not home then
-				return
-			end
-			local dir = home .. "/.local/share/wt"
-			run_cmd_silent("mkdir -p " .. dir)
-			local path = dir .. "/hook-dirs.lua"
-			local f = io.open(path, "w")
-			if not f then
-				return
-			end
-			f:write("{\n")
-			for k, v in pairs(perms) do
-				f:write('\t["' .. k .. '"] = ' .. tostring(v) .. ",\n")
-			end
-			f:write("}\n")
-			f:close()
-		end,
-		run_hooks = function(source, target, hooks, root, home_override)
-			local home = home_override or os.getenv("HOME")
-			if not home then
-				return
-			end
-			local perm_path = home .. "/.local/share/wt/hook-dirs.lua"
-			local perms = {}
-			local pf = io.open(perm_path, "r")
-			if pf then
-				local content = pf:read("*a")
-				pf:close()
-				local chunk = load("return " .. content, perm_path, "t", {})
-				if chunk then
-					local ok, result = pcall(chunk)
-					if ok and type(result) == "table" then
-						perms = result
-					end
-				end
-			end
-			if perms[root] == false then
-				io.stderr:write("hooks skipped (not allowed for this project)\n")
-				return
-			end
-			if hooks.copy then
-				for _, item in ipairs(hooks.copy) do
-					local src = source .. "/" .. item
-					local dst = target .. "/" .. item
-					local parent = dst:match("(.+)/[^/]+$")
-					if parent then
-						run_cmd_silent("mkdir -p " .. parent)
-					end
-					run_cmd("cp -r " .. src .. " " .. dst)
-				end
-			end
-			if hooks.symlink then
-				for _, item in ipairs(hooks.symlink) do
-					local src = source .. "/" .. item
-					local dst = target .. "/" .. item
-					local parent = dst:match("(.+)/[^/]+$")
-					if parent then
-						run_cmd_silent("mkdir -p " .. parent)
-					end
-					run_cmd("ln -s " .. src .. " " .. dst)
-				end
-			end
-			if hooks.run then
-				for _, cmd in ipairs(hooks.run) do
-					run_cmd("cd " .. target .. " && " .. cmd)
-				end
-			end
-		end,
+		load_hook_permissions = load_hook_permissions,
+		save_hook_permissions = save_hook_permissions,
+		run_hooks = run_hooks,
 		-- Project root detection (re-exported from wt.git)
 		find_project_root = find_project_root,
 		detect_source_worktree = detect_source_worktree,

src/main.lua 🔗

@@ -44,6 +44,12 @@ 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
 
+local hooks_mod = require("wt.hooks")
+local load_hook_permissions = hooks_mod.load_hook_permissions
+local save_hook_permissions = hooks_mod.save_hook_permissions
+local summarize_hooks = hooks_mod.summarize_hooks
+local run_hooks = hooks_mod.run_hooks
+
 ---Print usage information
 local function print_usage()
 	print("wt - git worktree manager")
@@ -186,157 +192,6 @@ local function show_command_help(cmd)
 	os.exit(EXIT_SUCCESS)
 end
 
----Load hook permissions from ~/.local/share/wt/hook-dirs.lua
----@return table<string, boolean>
-local function load_hook_permissions()
-	local home = os.getenv("HOME")
-	if not home then
-		return {}
-	end
-	local path = home .. "/.local/share/wt/hook-dirs.lua"
-	local f = io.open(path, "r")
-	if not f then
-		return {}
-	end
-	local content = f:read("*a")
-	f:close()
-	local chunk = load("return " .. content, path, "t", {})
-	if not chunk then
-		return {}
-	end
-	local ok, result = pcall(chunk)
-	if ok and type(result) == "table" then
-		return result
-	end
-	return {}
-end
-
----Save hook permissions to ~/.local/share/wt/hook-dirs.lua
----@param perms table<string, boolean>
-local function save_hook_permissions(perms)
-	local home = os.getenv("HOME")
-	if not home then
-		return
-	end
-	local dir = home .. "/.local/share/wt"
-	run_cmd_silent("mkdir -p " .. dir)
-	local path = dir .. "/hook-dirs.lua"
-	local f = io.open(path, "w")
-	if not f then
-		return
-	end
-	f:write("{\n")
-	for k, v in pairs(perms) do
-		f:write('\t["' .. k .. '"] = ' .. tostring(v) .. ",\n")
-	end
-	f:write("}\n")
-	f:close()
-end
-
----Summarize hooks for confirmation prompt
----@param hooks {copy?: string[], symlink?: string[], run?: string[]}
----@return string
-local function summarize_hooks(hooks)
-	local parts = {}
-	if hooks.copy and #hooks.copy > 0 then
-		local items = {}
-		for i = 1, math.min(3, #hooks.copy) do
-			table.insert(items, hooks.copy[i])
-		end
-		local suffix = #hooks.copy > 3 and " (+" .. (#hooks.copy - 3) .. " more)" or ""
-		table.insert(parts, "copy: " .. table.concat(items, ", ") .. suffix)
-	end
-	if hooks.symlink and #hooks.symlink > 0 then
-		local items = {}
-		for i = 1, math.min(3, #hooks.symlink) do
-			table.insert(items, hooks.symlink[i])
-		end
-		local suffix = #hooks.symlink > 3 and " (+" .. (#hooks.symlink - 3) .. " more)" or ""
-		table.insert(parts, "symlink: " .. table.concat(items, ", ") .. suffix)
-	end
-	if hooks.run and #hooks.run > 0 then
-		local items = {}
-		for i = 1, math.min(3, #hooks.run) do
-			table.insert(items, hooks.run[i])
-		end
-		local suffix = #hooks.run > 3 and " (+" .. (#hooks.run - 3) .. " more)" or ""
-		table.insert(parts, "run: " .. table.concat(items, ", ") .. suffix)
-	end
-	return table.concat(parts, "; ")
-end
-
----Check if hooks are allowed for a project, prompting if unknown
----@param root string project root path
----@param hooks {copy?: string[], symlink?: string[], run?: string[]}
----@return boolean allowed
-local function check_hook_permission(root, hooks)
-	local perms = load_hook_permissions()
-	if perms[root] ~= nil then
-		return perms[root]
-	end
-
-	-- Prompt user
-	local summary = summarize_hooks(hooks)
-	local prompt = "Allow hooks for " .. root .. "?\\n" .. summary
-	local allowed = run_cmd_silent("gum confirm " .. "'" .. prompt:gsub("'", "'\\''") .. "'")
-
-	perms[root] = allowed
-	save_hook_permissions(perms)
-	return allowed
-end
-
----Run hooks from .wt.lua config
----@param source string source worktree path
----@param target string target worktree path
----@param hooks {copy?: string[], symlink?: string[], run?: string[]}
----@param root string project root path
-local function run_hooks(source, target, hooks, root)
-	-- Check permission before running any hooks
-	if not check_hook_permission(root, hooks) then
-		io.stderr:write("hooks skipped (not allowed for this project)\n")
-		return
-	end
-
-	if hooks.copy then
-		for _, item in ipairs(hooks.copy) do
-			local src = source .. "/" .. item
-			local dst = target .. "/" .. item
-			-- Create parent directory if needed
-			local parent = dst:match("(.+)/[^/]+$")
-			if parent then
-				run_cmd_silent("mkdir -p " .. parent)
-			end
-			local _, code = run_cmd("cp -r " .. src .. " " .. dst)
-			if code ~= 0 then
-				io.stderr:write("warning: failed to copy " .. item .. "\n")
-			end
-		end
-	end
-	if hooks.symlink then
-		for _, item in ipairs(hooks.symlink) do
-			local src = source .. "/" .. item
-			local dst = target .. "/" .. item
-			-- Create parent directory if needed
-			local parent = dst:match("(.+)/[^/]+$")
-			if parent then
-				run_cmd_silent("mkdir -p " .. parent)
-			end
-			local _, code = run_cmd("ln -s " .. src .. " " .. dst)
-			if code ~= 0 then
-				io.stderr:write("warning: failed to symlink " .. item .. "\n")
-			end
-		end
-	end
-	if hooks.run then
-		for _, cmd in ipairs(hooks.run) do
-			local _, code = run_cmd("cd " .. target .. " && " .. cmd)
-			if code ~= 0 then
-				io.stderr:write("warning: hook command failed: " .. cmd .. "\n")
-			end
-		end
-	end
-end
-
 ---@param args string[]
 local function cmd_clone(args)
 	-- Parse arguments: <url> [--remote name]... [--own]
@@ -1383,100 +1238,11 @@ if pcall(debug.getlocal, 4, 1) then
 		parse_branch_remotes = parse_branch_remotes,
 		parse_worktree_list = parse_worktree_list,
 		escape_pattern = escape_pattern,
-		-- Hook helpers
+		-- Hook helpers (re-exported from wt.hooks)
 		summarize_hooks = summarize_hooks,
-		load_hook_permissions = function(home_override)
-			local home = home_override or os.getenv("HOME")
-			if not home then
-				return {}
-			end
-			local path = home .. "/.local/share/wt/hook-dirs.lua"
-			local f = io.open(path, "r")
-			if not f then
-				return {}
-			end
-			local content = f:read("*a")
-			f:close()
-			local chunk = load("return " .. content, path, "t", {})
-			if not chunk then
-				return {}
-			end
-			local ok, result = pcall(chunk)
-			if ok and type(result) == "table" then
-				return result
-			end
-			return {}
-		end,
-		save_hook_permissions = function(perms, home_override)
-			local home = home_override or os.getenv("HOME")
-			if not home then
-				return
-			end
-			local dir = home .. "/.local/share/wt"
-			run_cmd_silent("mkdir -p " .. dir)
-			local path = dir .. "/hook-dirs.lua"
-			local f = io.open(path, "w")
-			if not f then
-				return
-			end
-			f:write("{\n")
-			for k, v in pairs(perms) do
-				f:write('\t["' .. k .. '"] = ' .. tostring(v) .. ",\n")
-			end
-			f:write("}\n")
-			f:close()
-		end,
-		run_hooks = function(source, target, hooks, root, home_override)
-			local home = home_override or os.getenv("HOME")
-			if not home then
-				return
-			end
-			local perm_path = home .. "/.local/share/wt/hook-dirs.lua"
-			local perms = {}
-			local pf = io.open(perm_path, "r")
-			if pf then
-				local content = pf:read("*a")
-				pf:close()
-				local chunk = load("return " .. content, perm_path, "t", {})
-				if chunk then
-					local ok, result = pcall(chunk)
-					if ok and type(result) == "table" then
-						perms = result
-					end
-				end
-			end
-			if perms[root] == false then
-				io.stderr:write("hooks skipped (not allowed for this project)\n")
-				return
-			end
-			if hooks.copy then
-				for _, item in ipairs(hooks.copy) do
-					local src = source .. "/" .. item
-					local dst = target .. "/" .. item
-					local parent = dst:match("(.+)/[^/]+$")
-					if parent then
-						run_cmd_silent("mkdir -p " .. parent)
-					end
-					run_cmd("cp -r " .. src .. " " .. dst)
-				end
-			end
-			if hooks.symlink then
-				for _, item in ipairs(hooks.symlink) do
-					local src = source .. "/" .. item
-					local dst = target .. "/" .. item
-					local parent = dst:match("(.+)/[^/]+$")
-					if parent then
-						run_cmd_silent("mkdir -p " .. parent)
-					end
-					run_cmd("ln -s " .. src .. " " .. dst)
-				end
-			end
-			if hooks.run then
-				for _, cmd in ipairs(hooks.run) do
-					run_cmd("cd " .. target .. " && " .. cmd)
-				end
-			end
-		end,
+		load_hook_permissions = load_hook_permissions,
+		save_hook_permissions = save_hook_permissions,
+		run_hooks = run_hooks,
 		-- Project root detection (re-exported from wt.git)
 		find_project_root = find_project_root,
 		detect_source_worktree = detect_source_worktree,

src/wt/hooks.lua 🔗

@@ -0,0 +1,175 @@
+-- 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
+
+---@class wt.hooks
+local M = {}
+
+---Load hook permissions from ~/.local/share/wt/hook-dirs.lua
+---@param home_override? string override HOME for testing
+---@return table<string, boolean>
+function M.load_hook_permissions(home_override)
+	local home = home_override or os.getenv("HOME")
+	if not home then
+		return {}
+	end
+	local path = home .. "/.local/share/wt/hook-dirs.lua"
+	local f = io.open(path, "r")
+	if not f then
+		return {}
+	end
+	local content = f:read("*a")
+	f:close()
+	local chunk = load("return " .. content, path, "t", {})
+	if not chunk then
+		return {}
+	end
+	local ok, result = pcall(chunk)
+	if ok and type(result) == "table" then
+		return result
+	end
+	return {}
+end
+
+---Save hook permissions to ~/.local/share/wt/hook-dirs.lua
+---@param perms table<string, boolean>
+---@param home_override? string override HOME for testing
+function M.save_hook_permissions(perms, home_override)
+	local home = home_override or os.getenv("HOME")
+	if not home then
+		return
+	end
+	local dir = home .. "/.local/share/wt"
+	run_cmd_silent("mkdir -p " .. dir)
+	local path = dir .. "/hook-dirs.lua"
+	local f = io.open(path, "w")
+	if not f then
+		return
+	end
+	f:write("{\n")
+	for k, v in pairs(perms) do
+		f:write('\t["' .. k .. '"] = ' .. tostring(v) .. ",\n")
+	end
+	f:write("}\n")
+	f:close()
+end
+
+---Summarize hooks for confirmation prompt
+---@param hooks {copy?: string[], symlink?: string[], run?: string[]}
+---@return string
+function M.summarize_hooks(hooks)
+	local parts = {}
+	if hooks.copy and #hooks.copy > 0 then
+		local items = {}
+		for i = 1, math.min(3, #hooks.copy) do
+			table.insert(items, hooks.copy[i])
+		end
+		local suffix = #hooks.copy > 3 and " (+" .. (#hooks.copy - 3) .. " more)" or ""
+		table.insert(parts, "copy: " .. table.concat(items, ", ") .. suffix)
+	end
+	if hooks.symlink and #hooks.symlink > 0 then
+		local items = {}
+		for i = 1, math.min(3, #hooks.symlink) do
+			table.insert(items, hooks.symlink[i])
+		end
+		local suffix = #hooks.symlink > 3 and " (+" .. (#hooks.symlink - 3) .. " more)" or ""
+		table.insert(parts, "symlink: " .. table.concat(items, ", ") .. suffix)
+	end
+	if hooks.run and #hooks.run > 0 then
+		local items = {}
+		for i = 1, math.min(3, #hooks.run) do
+			table.insert(items, hooks.run[i])
+		end
+		local suffix = #hooks.run > 3 and " (+" .. (#hooks.run - 3) .. " more)" or ""
+		table.insert(parts, "run: " .. table.concat(items, ", ") .. suffix)
+	end
+	return table.concat(parts, "; ")
+end
+
+---Check if hooks are allowed for a project, prompting if unknown
+---@param root string project root path
+---@param hooks {copy?: string[], symlink?: string[], run?: string[]}
+---@return boolean allowed
+function M.check_hook_permission(root, hooks)
+	local perms = M.load_hook_permissions()
+	if perms[root] ~= nil then
+		return perms[root]
+	end
+
+	-- Prompt user
+	local summary = M.summarize_hooks(hooks)
+	local prompt = "Allow hooks for " .. root .. "?\\n" .. summary
+	local allowed = run_cmd_silent("gum confirm " .. "'" .. prompt:gsub("'", "'\\''") .. "'")
+
+	perms[root] = allowed
+	M.save_hook_permissions(perms)
+	return allowed
+end
+
+---Run hooks from .wt.lua config
+---@param source string source worktree path
+---@param target string target worktree path
+---@param hooks {copy?: string[], symlink?: string[], run?: string[]}
+---@param root string project root path
+---@param home_override? string override HOME for testing
+function M.run_hooks(source, target, hooks, root, home_override)
+	-- Check permission before running any hooks
+	-- For testing: check permissions file directly if home_override given
+	if home_override then
+		local perms = M.load_hook_permissions(home_override)
+		if perms[root] == false then
+			io.stderr:write("hooks skipped (not allowed for this project)\n")
+			return
+		end
+	else
+		if not M.check_hook_permission(root, hooks) then
+			io.stderr:write("hooks skipped (not allowed for this project)\n")
+			return
+		end
+	end
+
+	if hooks.copy then
+		for _, item in ipairs(hooks.copy) do
+			local src = source .. "/" .. item
+			local dst = target .. "/" .. item
+			-- Create parent directory if needed
+			local parent = dst:match("(.+)/[^/]+$")
+			if parent then
+				run_cmd_silent("mkdir -p " .. parent)
+			end
+			local _, code = run_cmd("cp -r " .. src .. " " .. dst)
+			if code ~= 0 then
+				io.stderr:write("warning: failed to copy " .. item .. "\n")
+			end
+		end
+	end
+	if hooks.symlink then
+		for _, item in ipairs(hooks.symlink) do
+			local src = source .. "/" .. item
+			local dst = target .. "/" .. item
+			-- Create parent directory if needed
+			local parent = dst:match("(.+)/[^/]+$")
+			if parent then
+				run_cmd_silent("mkdir -p " .. parent)
+			end
+			local _, code = run_cmd("ln -s " .. src .. " " .. dst)
+			if code ~= 0 then
+				io.stderr:write("warning: failed to symlink " .. item .. "\n")
+			end
+		end
+	end
+	if hooks.run then
+		for _, cmd in ipairs(hooks.run) do
+			local _, code = run_cmd("cd " .. target .. " && " .. cmd)
+			if code ~= 0 then
+				io.stderr:write("warning: hook command failed: " .. cmd .. "\n")
+			end
+		end
+	end
+end
+
+return M