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