main.lua

   1#!/usr/bin/env lua
   2
   3-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
   4--
   5-- SPDX-License-Identifier: GPL-3.0-or-later
   6
   7if _VERSION < "Lua 5.2" then
   8	io.stderr:write("error: wt requires Lua 5.2 or later\n")
   9	os.exit(1)
  10end
  11
  12local exit = require("wt.exit")
  13local EXIT_SUCCESS = exit.EXIT_SUCCESS
  14local EXIT_USER_ERROR = exit.EXIT_USER_ERROR
  15local EXIT_SYSTEM_ERROR = exit.EXIT_SYSTEM_ERROR
  16
  17local shell = require("wt.shell")
  18local run_cmd = shell.run_cmd
  19local run_cmd_silent = shell.run_cmd_silent
  20local get_cwd = shell.get_cwd
  21local die = shell.die
  22
  23local path_mod = require("wt.path")
  24local branch_to_path = path_mod.branch_to_path
  25local split_path = path_mod.split_path
  26local relative_path = path_mod.relative_path
  27local path_inside = path_mod.path_inside
  28local escape_pattern = path_mod.escape_pattern
  29
  30local git_mod = require("wt.git")
  31local find_project_root = git_mod.find_project_root
  32local detect_source_worktree = git_mod.detect_source_worktree
  33local branch_exists_local = git_mod.branch_exists_local
  34local find_branch_remotes = git_mod.find_branch_remotes
  35local detect_cloned_default_branch = git_mod.detect_cloned_default_branch
  36local get_default_branch = git_mod.get_default_branch
  37local parse_branch_remotes = git_mod.parse_branch_remotes
  38local parse_worktree_list = git_mod.parse_worktree_list
  39local branch_checked_out_at = git_mod.branch_checked_out_at
  40
  41local config_mod = require("wt.config")
  42local resolve_url_template = config_mod.resolve_url_template
  43local extract_project_name = config_mod.extract_project_name
  44local load_global_config = config_mod.load_global_config
  45local load_project_config = config_mod.load_project_config
  46
  47---Print usage information
  48local function print_usage()
  49	print("wt - git worktree manager")
  50	print("")
  51	print("Usage: wt <command> [options]")
  52	print("")
  53	print("Commands:")
  54	print("  c <url> [--remote name]... [--own]   Clone into bare worktree structure")
  55	print("  n <project-name> [--remote name]...  Initialize fresh project")
  56	print("  a <branch> [-b [<start-point>]]      Add worktree with optional hooks")
  57	print("  r <branch> [-b] [-f]                 Remove worktree, optionally delete branch")
  58	print("  l                                    List worktrees with status")
  59	print("  f                                    Fetch all remotes")
  60	print("  init [--dry-run] [-y]                Convert existing repo to bare structure")
  61	print("  help                                 Show this help message")
  62end
  63
  64-- Per-command help text (using table.concat for performance)
  65local COMMAND_HELP = {
  66	c = table.concat({
  67		"wt c <url> [--remote name]... [--own]",
  68		"",
  69		"Clone a repository into bare worktree structure.",
  70		"",
  71		"Arguments:",
  72		"  <url>              Git URL to clone",
  73		"",
  74		"Options:",
  75		"  --remote <name>    Add configured remote from ~/.config/wt/config.lua",
  76		"                     Can be specified multiple times",
  77		"  --own              Treat as your own project: first remote becomes 'origin'",
  78		"                     (default: 'origin' renamed to 'upstream', your remotes added)",
  79		"",
  80		"Examples:",
  81		"  wt c https://github.com/user/repo.git",
  82		"  wt c git@github.com:user/repo.git --remote github --own",
  83	}, "\n"),
  84
  85	n = table.concat({
  86		"wt n <project-name> [--remote name]...",
  87		"",
  88		"Initialize a fresh project with bare worktree structure.",
  89		"",
  90		"Arguments:",
  91		"  <project-name>     Name of the new project directory",
  92		"",
  93		"Options:",
  94		"  --remote <name>    Add configured remote from ~/.config/wt/config.lua",
  95		"                     Can be specified multiple times",
  96		"",
  97		"Examples:",
  98		"  wt n my-project",
  99		"  wt n my-project --remote github --remote gitlab",
 100	}, "\n"),
 101
 102	a = table.concat({
 103		"wt a <branch> [-b [<start-point>]]",
 104		"",
 105		"Add a worktree for a branch.",
 106		"",
 107		"Arguments:",
 108		"  <branch>           Branch name to checkout or create",
 109		"",
 110		"Options:",
 111		"  -b                 Create a new branch",
 112		"  <start-point>      Base commit/branch for new branch (only with -b)",
 113		"",
 114		"If run from inside an existing worktree, hooks from .wt.lua will be applied.",
 115		"",
 116		"Examples:",
 117		"  wt a main                    # Checkout existing branch",
 118		"  wt a feature/new -b          # Create new branch from HEAD",
 119		"  wt a feature/new -b main     # Create new branch from main",
 120	}, "\n"),
 121
 122	r = table.concat({
 123		"wt r <branch> [-b] [-f]",
 124		"",
 125		"Remove a worktree.",
 126		"",
 127		"Arguments:",
 128		"  <branch>           Branch name of worktree to remove",
 129		"",
 130		"Options:",
 131		"  -b                 Also delete the branch after removing worktree",
 132		"  -f                 Force removal even with uncommitted changes",
 133		"",
 134		"Examples:",
 135		"  wt r feature/old             # Remove worktree, keep branch",
 136		"  wt r feature/old -b          # Remove worktree and delete branch",
 137		"  wt r feature/old -f          # Force remove with uncommitted changes",
 138	}, "\n"),
 139
 140	l = table.concat({
 141		"wt l",
 142		"",
 143		"List all worktrees with status information.",
 144		"",
 145		"Displays a table showing:",
 146		"  - Branch name",
 147		"  - Relative path from project root",
 148		"  - Commit status (ahead/behind remote)",
 149		"  - Working tree status (clean/dirty)",
 150	}, "\n"),
 151
 152	f = table.concat({
 153		"wt f",
 154		"",
 155		"Fetch from all configured remotes.",
 156		"",
 157		"Runs 'git fetch --all' in the bare repository.",
 158	}, "\n"),
 159
 160	init = table.concat({
 161		"wt init [--dry-run] [-y]",
 162		"",
 163		"Convert an existing git repository to bare worktree structure.",
 164		"",
 165		"Options:",
 166		"  --dry-run          Show what would be done without making changes",
 167		"  -y                 Skip confirmation prompt",
 168		"",
 169		"This command:",
 170		"  1. Moves .git/ to .bare/",
 171		"  2. Creates .git file pointing to .bare/",
 172		"  3. Creates a worktree for the current branch",
 173		"  4. Removes orphaned files from project root",
 174	}, "\n"),
 175}
 176
 177---Show help for a specific command
 178---@param cmd string
 179local function show_command_help(cmd)
 180	local help = COMMAND_HELP[cmd]
 181	if help then
 182		print(help)
 183	else
 184		print_usage()
 185	end
 186	os.exit(EXIT_SUCCESS)
 187end
 188
 189---Load hook permissions from ~/.local/share/wt/hook-dirs.lua
 190---@return table<string, boolean>
 191local function load_hook_permissions()
 192	local home = os.getenv("HOME")
 193	if not home then
 194		return {}
 195	end
 196	local path = home .. "/.local/share/wt/hook-dirs.lua"
 197	local f = io.open(path, "r")
 198	if not f then
 199		return {}
 200	end
 201	local content = f:read("*a")
 202	f:close()
 203	local chunk = load("return " .. content, path, "t", {})
 204	if not chunk then
 205		return {}
 206	end
 207	local ok, result = pcall(chunk)
 208	if ok and type(result) == "table" then
 209		return result
 210	end
 211	return {}
 212end
 213
 214---Save hook permissions to ~/.local/share/wt/hook-dirs.lua
 215---@param perms table<string, boolean>
 216local function save_hook_permissions(perms)
 217	local home = os.getenv("HOME")
 218	if not home then
 219		return
 220	end
 221	local dir = home .. "/.local/share/wt"
 222	run_cmd_silent("mkdir -p " .. dir)
 223	local path = dir .. "/hook-dirs.lua"
 224	local f = io.open(path, "w")
 225	if not f then
 226		return
 227	end
 228	f:write("{\n")
 229	for k, v in pairs(perms) do
 230		f:write('\t["' .. k .. '"] = ' .. tostring(v) .. ",\n")
 231	end
 232	f:write("}\n")
 233	f:close()
 234end
 235
 236---Summarize hooks for confirmation prompt
 237---@param hooks {copy?: string[], symlink?: string[], run?: string[]}
 238---@return string
 239local function summarize_hooks(hooks)
 240	local parts = {}
 241	if hooks.copy and #hooks.copy > 0 then
 242		local items = {}
 243		for i = 1, math.min(3, #hooks.copy) do
 244			table.insert(items, hooks.copy[i])
 245		end
 246		local suffix = #hooks.copy > 3 and " (+" .. (#hooks.copy - 3) .. " more)" or ""
 247		table.insert(parts, "copy: " .. table.concat(items, ", ") .. suffix)
 248	end
 249	if hooks.symlink and #hooks.symlink > 0 then
 250		local items = {}
 251		for i = 1, math.min(3, #hooks.symlink) do
 252			table.insert(items, hooks.symlink[i])
 253		end
 254		local suffix = #hooks.symlink > 3 and " (+" .. (#hooks.symlink - 3) .. " more)" or ""
 255		table.insert(parts, "symlink: " .. table.concat(items, ", ") .. suffix)
 256	end
 257	if hooks.run and #hooks.run > 0 then
 258		local items = {}
 259		for i = 1, math.min(3, #hooks.run) do
 260			table.insert(items, hooks.run[i])
 261		end
 262		local suffix = #hooks.run > 3 and " (+" .. (#hooks.run - 3) .. " more)" or ""
 263		table.insert(parts, "run: " .. table.concat(items, ", ") .. suffix)
 264	end
 265	return table.concat(parts, "; ")
 266end
 267
 268---Check if hooks are allowed for a project, prompting if unknown
 269---@param root string project root path
 270---@param hooks {copy?: string[], symlink?: string[], run?: string[]}
 271---@return boolean allowed
 272local function check_hook_permission(root, hooks)
 273	local perms = load_hook_permissions()
 274	if perms[root] ~= nil then
 275		return perms[root]
 276	end
 277
 278	-- Prompt user
 279	local summary = summarize_hooks(hooks)
 280	local prompt = "Allow hooks for " .. root .. "?\\n" .. summary
 281	local allowed = run_cmd_silent("gum confirm " .. "'" .. prompt:gsub("'", "'\\''") .. "'")
 282
 283	perms[root] = allowed
 284	save_hook_permissions(perms)
 285	return allowed
 286end
 287
 288---Run hooks from .wt.lua config
 289---@param source string source worktree path
 290---@param target string target worktree path
 291---@param hooks {copy?: string[], symlink?: string[], run?: string[]}
 292---@param root string project root path
 293local function run_hooks(source, target, hooks, root)
 294	-- Check permission before running any hooks
 295	if not check_hook_permission(root, hooks) then
 296		io.stderr:write("hooks skipped (not allowed for this project)\n")
 297		return
 298	end
 299
 300	if hooks.copy then
 301		for _, item in ipairs(hooks.copy) do
 302			local src = source .. "/" .. item
 303			local dst = target .. "/" .. item
 304			-- Create parent directory if needed
 305			local parent = dst:match("(.+)/[^/]+$")
 306			if parent then
 307				run_cmd_silent("mkdir -p " .. parent)
 308			end
 309			local _, code = run_cmd("cp -r " .. src .. " " .. dst)
 310			if code ~= 0 then
 311				io.stderr:write("warning: failed to copy " .. item .. "\n")
 312			end
 313		end
 314	end
 315	if hooks.symlink then
 316		for _, item in ipairs(hooks.symlink) do
 317			local src = source .. "/" .. item
 318			local dst = target .. "/" .. item
 319			-- Create parent directory if needed
 320			local parent = dst:match("(.+)/[^/]+$")
 321			if parent then
 322				run_cmd_silent("mkdir -p " .. parent)
 323			end
 324			local _, code = run_cmd("ln -s " .. src .. " " .. dst)
 325			if code ~= 0 then
 326				io.stderr:write("warning: failed to symlink " .. item .. "\n")
 327			end
 328		end
 329	end
 330	if hooks.run then
 331		for _, cmd in ipairs(hooks.run) do
 332			local _, code = run_cmd("cd " .. target .. " && " .. cmd)
 333			if code ~= 0 then
 334				io.stderr:write("warning: hook command failed: " .. cmd .. "\n")
 335			end
 336		end
 337	end
 338end
 339
 340---@param args string[]
 341local function cmd_clone(args)
 342	-- Parse arguments: <url> [--remote name]... [--own]
 343	local url = nil
 344	---@type string[]
 345	local remote_flags = {}
 346	local own = false
 347
 348	local i = 1
 349	while i <= #args do
 350		local a = args[i]
 351		if a == "--remote" then
 352			if not args[i + 1] then
 353				die("--remote requires a name")
 354			end
 355			table.insert(remote_flags, args[i + 1])
 356			i = i + 1
 357		elseif a == "--own" then
 358			own = true
 359		elseif not url then
 360			url = a
 361		else
 362			die("unexpected argument: " .. a)
 363		end
 364		i = i + 1
 365	end
 366
 367	if not url then
 368		die("usage: wt c <url> [--remote name]... [--own]")
 369		return
 370	end
 371
 372	-- Extract project name from URL
 373	local project_name = extract_project_name(url)
 374	if not project_name then
 375		die("could not extract project name from URL: " .. url)
 376		return
 377	end
 378
 379	-- Check if project directory already exists
 380	local cwd = get_cwd()
 381	if not cwd then
 382		die("failed to get current directory", EXIT_SYSTEM_ERROR)
 383	end
 384	local project_path = cwd .. "/" .. project_name
 385	local check = io.open(project_path, "r")
 386	if check then
 387		check:close()
 388		die("directory already exists: " .. project_path)
 389	end
 390
 391	-- Clone bare repo
 392	local bare_path = project_path .. "/.bare"
 393	local output, code = run_cmd("git clone --bare " .. url .. " " .. bare_path)
 394	if code ~= 0 then
 395		die("failed to clone: " .. output, EXIT_SYSTEM_ERROR)
 396	end
 397
 398	-- Write .git file pointing to .bare
 399	local git_file_handle = io.open(project_path .. "/.git", "w")
 400	if not git_file_handle then
 401		die("failed to create .git file", EXIT_SYSTEM_ERROR)
 402		return
 403	end
 404	git_file_handle:write("gitdir: ./.bare\n")
 405	git_file_handle:close()
 406
 407	-- Detect default branch
 408	local git_dir = bare_path
 409	local default_branch = detect_cloned_default_branch(git_dir)
 410
 411	-- Load global config
 412	local global_config = load_global_config()
 413
 414	-- Determine which remotes to use
 415	---@type string[]
 416	local selected_remotes = {}
 417
 418	if #remote_flags > 0 then
 419		selected_remotes = remote_flags
 420	elseif global_config.default_remotes then
 421		if type(global_config.default_remotes) == "table" then
 422			selected_remotes = global_config.default_remotes
 423		elseif global_config.default_remotes == "prompt" then
 424			if global_config.remotes then
 425				local keys = {}
 426				for k in pairs(global_config.remotes) do
 427					table.insert(keys, k)
 428				end
 429				table.sort(keys)
 430				if #keys > 0 then
 431					local input = table.concat(keys, "\n")
 432					local choose_type = own and "" or " --no-limit"
 433					local cmd = "echo '" .. input .. "' | gum choose" .. choose_type
 434					output, code = run_cmd(cmd)
 435					if code == 0 and output ~= "" then
 436						for line in output:gmatch("[^\n]+") do
 437							table.insert(selected_remotes, line)
 438						end
 439					end
 440				end
 441			end
 442		end
 443	elseif global_config.remotes then
 444		local keys = {}
 445		for k in pairs(global_config.remotes) do
 446			table.insert(keys, k)
 447		end
 448		table.sort(keys)
 449		if #keys > 0 then
 450			local input = table.concat(keys, "\n")
 451			local choose_type = own and "" or " --no-limit"
 452			local cmd = "echo '" .. input .. "' | gum choose" .. choose_type
 453			output, code = run_cmd(cmd)
 454			if code == 0 and output ~= "" then
 455				for line in output:gmatch("[^\n]+") do
 456					table.insert(selected_remotes, line)
 457				end
 458			end
 459		end
 460	end
 461
 462	-- Track configured remotes for summary
 463	---@type string[]
 464	local configured_remotes = {}
 465
 466	if own then
 467		-- User's own project: origin is their canonical remote
 468		if #selected_remotes > 0 then
 469			local first_remote = selected_remotes[1]
 470			-- Rename origin to first remote
 471			output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote rename origin " .. first_remote)
 472			if code ~= 0 then
 473				io.stderr:write("warning: failed to rename origin to " .. first_remote .. ": " .. output .. "\n")
 474			else
 475				-- Configure fetch refspec
 476				run_cmd(
 477					"GIT_DIR="
 478						.. git_dir
 479						.. " git config remote."
 480						.. first_remote
 481						.. ".fetch '+refs/heads/*:refs/remotes/"
 482						.. first_remote
 483						.. "/*'"
 484				)
 485				table.insert(configured_remotes, first_remote)
 486			end
 487
 488			-- Add additional remotes and push to them
 489			for j = 2, #selected_remotes do
 490				local remote_name = selected_remotes[j]
 491				local template = global_config.remotes and global_config.remotes[remote_name]
 492				if template then
 493					local remote_url = resolve_url_template(template, project_name)
 494					output, code =
 495						run_cmd("GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. remote_url)
 496					if code ~= 0 then
 497						io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
 498					else
 499						run_cmd(
 500							"GIT_DIR="
 501								.. git_dir
 502								.. " git config remote."
 503								.. remote_name
 504								.. ".fetch '+refs/heads/*:refs/remotes/"
 505								.. remote_name
 506								.. "/*'"
 507						)
 508						-- Push to additional remotes
 509						output, code =
 510							run_cmd("GIT_DIR=" .. git_dir .. " git push " .. remote_name .. " " .. default_branch)
 511						if code ~= 0 then
 512							io.stderr:write("warning: failed to push to " .. remote_name .. ": " .. output .. "\n")
 513						end
 514						table.insert(configured_remotes, remote_name)
 515					end
 516				else
 517					io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
 518				end
 519			end
 520		else
 521			-- No remotes selected, keep origin as-is
 522			run_cmd("GIT_DIR=" .. git_dir .. " git config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'")
 523			table.insert(configured_remotes, "origin")
 524		end
 525	else
 526		-- Contributing to someone else's project
 527		-- Rename origin to upstream
 528		output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote rename origin upstream")
 529		if code ~= 0 then
 530			io.stderr:write("warning: failed to rename origin to upstream: " .. output .. "\n")
 531		else
 532			run_cmd(
 533				"GIT_DIR=" .. git_dir .. " git config remote.upstream.fetch '+refs/heads/*:refs/remotes/upstream/*'"
 534			)
 535			table.insert(configured_remotes, "upstream")
 536		end
 537
 538		-- Add user's remotes and push to each
 539		for _, remote_name in ipairs(selected_remotes) do
 540			local template = global_config.remotes and global_config.remotes[remote_name]
 541			if template then
 542				local remote_url = resolve_url_template(template, project_name)
 543				output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. remote_url)
 544				if code ~= 0 then
 545					io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
 546				else
 547					run_cmd(
 548						"GIT_DIR="
 549							.. git_dir
 550							.. " git config remote."
 551							.. remote_name
 552							.. ".fetch '+refs/heads/*:refs/remotes/"
 553							.. remote_name
 554							.. "/*'"
 555					)
 556					-- Push to this remote
 557					output, code =
 558						run_cmd("GIT_DIR=" .. git_dir .. " git push " .. remote_name .. " " .. default_branch)
 559					if code ~= 0 then
 560						io.stderr:write("warning: failed to push to " .. remote_name .. ": " .. output .. "\n")
 561					end
 562					table.insert(configured_remotes, remote_name)
 563				end
 564			else
 565				io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
 566			end
 567		end
 568	end
 569
 570	-- Fetch all remotes
 571	run_cmd("GIT_DIR=" .. git_dir .. " git fetch --all")
 572
 573	-- Load config for path style
 574	local style = global_config.branch_path_style or "nested"
 575	local separator = global_config.flat_separator
 576	local worktree_path = branch_to_path(project_path, default_branch, style, separator)
 577
 578	-- Create initial worktree
 579	output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -- " .. worktree_path .. " " .. default_branch)
 580	if code ~= 0 then
 581		die("failed to create worktree: " .. output, EXIT_SYSTEM_ERROR)
 582	end
 583
 584	-- Print summary
 585	print("Created project: " .. project_path)
 586	print("Default branch:  " .. default_branch)
 587	print("Worktree:        " .. worktree_path)
 588	if #configured_remotes > 0 then
 589		print("Remotes:         " .. table.concat(configured_remotes, ", "))
 590	end
 591end
 592
 593---@param args string[]
 594local function cmd_new(args)
 595	-- Parse arguments: <project-name> [--remote name]...
 596	local project_name = nil
 597	---@type string[]
 598	local remote_flags = {}
 599
 600	local i = 1
 601	while i <= #args do
 602		local a = args[i]
 603		if a == "--remote" then
 604			if not args[i + 1] then
 605				die("--remote requires a name")
 606			end
 607			table.insert(remote_flags, args[i + 1])
 608			i = i + 1
 609		elseif not project_name then
 610			project_name = a
 611		else
 612			die("unexpected argument: " .. a)
 613		end
 614		i = i + 1
 615	end
 616
 617	if not project_name then
 618		die("usage: wt n <project-name> [--remote name]...")
 619		return
 620	end
 621
 622	-- Check if project directory already exists
 623	local cwd = get_cwd()
 624	if not cwd then
 625		die("failed to get current directory", EXIT_SYSTEM_ERROR)
 626	end
 627	local project_path = cwd .. "/" .. project_name
 628	local check = io.open(project_path, "r")
 629	if check then
 630		check:close()
 631		die("directory already exists: " .. project_path)
 632	end
 633
 634	-- Load global config
 635	local global_config = load_global_config()
 636
 637	-- Determine which remotes to use
 638	---@type string[]
 639	local selected_remotes = {}
 640
 641	if #remote_flags > 0 then
 642		-- Use explicitly provided remotes
 643		selected_remotes = remote_flags
 644	elseif global_config.default_remotes then
 645		if type(global_config.default_remotes) == "table" then
 646			selected_remotes = global_config.default_remotes
 647		elseif global_config.default_remotes == "prompt" then
 648			-- Prompt with gum choose
 649			if global_config.remotes then
 650				local keys = {}
 651				for k in pairs(global_config.remotes) do
 652					table.insert(keys, k)
 653				end
 654				table.sort(keys)
 655				if #keys > 0 then
 656					local input = table.concat(keys, "\n")
 657					local cmd = "echo '" .. input .. "' | gum choose --no-limit"
 658					local output, code = run_cmd(cmd)
 659					if code == 0 and output ~= "" then
 660						for line in output:gmatch("[^\n]+") do
 661							table.insert(selected_remotes, line)
 662						end
 663					end
 664				end
 665			end
 666		end
 667	elseif global_config.remotes then
 668		-- No default_remotes configured, prompt if remotes exist
 669		local keys = {}
 670		for k in pairs(global_config.remotes) do
 671			table.insert(keys, k)
 672		end
 673		table.sort(keys)
 674		if #keys > 0 then
 675			local input = table.concat(keys, "\n")
 676			local cmd = "echo '" .. input .. "' | gum choose --no-limit"
 677			local output, code = run_cmd(cmd)
 678			if code == 0 and output ~= "" then
 679				for line in output:gmatch("[^\n]+") do
 680					table.insert(selected_remotes, line)
 681				end
 682			end
 683		end
 684	end
 685
 686	-- Create project structure
 687	local bare_path = project_path .. "/.bare"
 688	local output, code = run_cmd("mkdir -p " .. bare_path)
 689	if code ~= 0 then
 690		die("failed to create directory: " .. output, EXIT_SYSTEM_ERROR)
 691	end
 692
 693	output, code = run_cmd("git init --bare " .. bare_path)
 694	if code ~= 0 then
 695		die("failed to init bare repo: " .. output, EXIT_SYSTEM_ERROR)
 696	end
 697
 698	-- Write .git file pointing to .bare
 699	local git_file_handle = io.open(project_path .. "/.git", "w")
 700	if not git_file_handle then
 701		die("failed to create .git file", EXIT_SYSTEM_ERROR)
 702		return
 703	end
 704	git_file_handle:write("gitdir: ./.bare\n")
 705	git_file_handle:close()
 706
 707	-- Add remotes
 708	local git_dir = bare_path
 709	for _, remote_name in ipairs(selected_remotes) do
 710		local template = global_config.remotes and global_config.remotes[remote_name]
 711		if template then
 712			local url = resolve_url_template(template, project_name)
 713			output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. url)
 714			if code ~= 0 then
 715				io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
 716			else
 717				-- Configure fetch refspec for the remote
 718				run_cmd(
 719					"GIT_DIR="
 720						.. git_dir
 721						.. " git config remote."
 722						.. remote_name
 723						.. ".fetch '+refs/heads/*:refs/remotes/"
 724						.. remote_name
 725						.. "/*'"
 726				)
 727			end
 728		else
 729			io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
 730		end
 731	end
 732
 733	-- Detect default branch
 734	local default_branch = get_default_branch()
 735
 736	-- Load config for path style
 737	local style = global_config.branch_path_style or "nested"
 738	local separator = global_config.flat_separator
 739	local worktree_path = branch_to_path(project_path, default_branch, style, separator)
 740
 741	-- Create orphan worktree
 742	output, code =
 743		run_cmd("GIT_DIR=" .. git_dir .. " git worktree add --orphan -b " .. default_branch .. " -- " .. worktree_path)
 744	if code ~= 0 then
 745		die("failed to create worktree: " .. output, EXIT_SYSTEM_ERROR)
 746	end
 747
 748	-- Print summary
 749	print("Created project: " .. project_path)
 750	print("Default branch:  " .. default_branch)
 751	print("Worktree:        " .. worktree_path)
 752	if #selected_remotes > 0 then
 753		print("Remotes:         " .. table.concat(selected_remotes, ", "))
 754	end
 755end
 756
 757---@param args string[]
 758local function cmd_add(args)
 759	-- Parse arguments: <branch> [-b [<start-point>]]
 760	---@type string|nil
 761	local branch = nil
 762	local create_branch = false
 763	---@type string|nil
 764	local start_point = nil
 765
 766	local i = 1
 767	while i <= #args do
 768		local a = args[i]
 769		if a == "-b" then
 770			create_branch = true
 771			-- Check if next arg is start-point (not another flag)
 772			if args[i + 1] and not args[i + 1]:match("^%-") then
 773				start_point = args[i + 1]
 774				i = i + 1
 775			end
 776		elseif not branch then
 777			branch = a
 778		else
 779			die("unexpected argument: " .. a)
 780		end
 781		i = i + 1
 782	end
 783
 784	if not branch then
 785		die("usage: wt a <branch> [-b [<start-point>]]")
 786		return
 787	end
 788
 789	local root, err = find_project_root()
 790	if not root then
 791		die(err --[[@as string]])
 792		return
 793	end
 794
 795	local git_dir = root .. "/.bare"
 796	local source_worktree = detect_source_worktree(root)
 797
 798	-- Load config for path style
 799	local global_config = load_global_config()
 800	local style = global_config.branch_path_style or "nested"
 801	local separator = global_config.flat_separator or "_"
 802
 803	local target_path = branch_to_path(root, branch, style, separator)
 804
 805	-- Check if target already exists
 806	local check = io.open(target_path .. "/.git", "r")
 807	if check then
 808		check:close()
 809		die("worktree already exists at " .. target_path)
 810	end
 811
 812	local output, code
 813	if create_branch then
 814		-- Create new branch with worktree
 815		if start_point then
 816			output, code = run_cmd(
 817				"GIT_DIR="
 818					.. git_dir
 819					.. " git worktree add -b "
 820					.. branch
 821					.. " -- "
 822					.. target_path
 823					.. " "
 824					.. start_point
 825			)
 826		else
 827			output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -b " .. branch .. " -- " .. target_path)
 828		end
 829	else
 830		-- Check if branch exists locally or on remotes
 831		local exists_local = branch_exists_local(git_dir, branch)
 832		local remotes = find_branch_remotes(git_dir, branch)
 833
 834		if not exists_local and #remotes == 0 then
 835			die("branch '" .. branch .. "' not found locally or on any remote (use -b to create)")
 836		end
 837
 838		if #remotes > 1 then
 839			die("branch '" .. branch .. "' exists on multiple remotes: " .. table.concat(remotes, ", "))
 840		end
 841
 842		output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -- " .. target_path .. " " .. branch)
 843	end
 844
 845	if code ~= 0 then
 846		die("failed to add worktree: " .. output, EXIT_SYSTEM_ERROR)
 847	end
 848
 849	-- Run hooks if we have a source worktree
 850	local project_config = load_project_config(root)
 851	if source_worktree then
 852		if project_config.hooks then
 853			run_hooks(source_worktree, target_path, project_config.hooks, root)
 854		end
 855	elseif project_config.hooks then
 856		io.stderr:write("warning: hooks skipped (run from inside a worktree to apply hooks)\n")
 857	end
 858
 859	print(target_path)
 860end
 861
 862---Check if cwd is inside (or equal to) a given path
 863---@param target string
 864---@return boolean
 865local function cwd_inside_path(target)
 866	local cwd = get_cwd()
 867	if not cwd then
 868		return false
 869	end
 870	return path_inside(cwd, target)
 871end
 872
 873---Get the bare repo's HEAD branch
 874---@param git_dir string
 875---@return string|nil branch name, nil on error
 876local function get_bare_head(git_dir)
 877	local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git symbolic-ref --short HEAD")
 878	if code ~= 0 then
 879		return nil
 880	end
 881	return (output:gsub("%s+$", ""))
 882end
 883
 884---@param args string[]
 885local function cmd_remove(args)
 886	-- Parse arguments: <branch> [-b] [-f]
 887	local branch = nil
 888	local delete_branch = false
 889	local force = false
 890
 891	for _, a in ipairs(args) do
 892		if a == "-b" then
 893			delete_branch = true
 894		elseif a == "-f" then
 895			force = true
 896		elseif not branch then
 897			branch = a
 898		else
 899			die("unexpected argument: " .. a)
 900		end
 901	end
 902
 903	if not branch then
 904		die("usage: wt r <branch> [-b] [-f]")
 905		return
 906	end
 907
 908	local root, err = find_project_root()
 909	if not root then
 910		die(err --[[@as string]])
 911		return
 912	end
 913
 914	local git_dir = root .. "/.bare"
 915
 916	-- Find worktree by querying git for actual location (not computed from config)
 917	local wt_output, wt_code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain")
 918	if wt_code ~= 0 then
 919		die("failed to list worktrees", EXIT_SYSTEM_ERROR)
 920		return
 921	end
 922
 923	local worktrees = parse_worktree_list(wt_output)
 924	local target_path = nil
 925	for _, wt in ipairs(worktrees) do
 926		if wt.branch == branch then
 927			target_path = wt.path
 928			break
 929		end
 930	end
 931
 932	if not target_path then
 933		die("no worktree found for branch '" .. branch .. "'")
 934		return
 935	end
 936
 937	-- Error if cwd is inside the worktree
 938	if cwd_inside_path(target_path) then
 939		die("cannot remove worktree while inside it")
 940	end
 941
 942	-- Check for uncommitted changes
 943	if not force then
 944		local status_out = run_cmd("git -C " .. target_path .. " status --porcelain")
 945		if status_out ~= "" then
 946			die("worktree has uncommitted changes (use -f to force)")
 947		end
 948	end
 949
 950	-- Remove worktree
 951	local remove_cmd = "GIT_DIR=" .. git_dir .. " git worktree remove"
 952	if force then
 953		remove_cmd = remove_cmd .. " --force"
 954	end
 955	remove_cmd = remove_cmd .. " -- " .. target_path
 956
 957	local output, code = run_cmd(remove_cmd)
 958	if code ~= 0 then
 959		die("failed to remove worktree: " .. output, EXIT_SYSTEM_ERROR)
 960	end
 961
 962	-- Delete branch if requested
 963	if delete_branch then
 964		-- Check if branch is bare repo's HEAD
 965		local bare_head = get_bare_head(git_dir)
 966		if bare_head and bare_head == branch then
 967			io.stderr:write("warning: cannot delete branch '" .. branch .. "' (it's the bare repo's HEAD)\n")
 968			print("Worktree removed; branch retained")
 969			return
 970		end
 971
 972		-- Check if branch is checked out elsewhere
 973		local checked_out = branch_checked_out_at(git_dir, branch)
 974		if checked_out then
 975			die("cannot delete branch '" .. branch .. "': checked out at " .. checked_out)
 976		end
 977
 978		-- Delete branch
 979		local delete_flag = force and "-D" or "-d"
 980		local del_output, del_code = run_cmd("GIT_DIR=" .. git_dir .. " git branch " .. delete_flag .. " " .. branch)
 981		if del_code ~= 0 then
 982			io.stderr:write("warning: failed to delete branch: " .. del_output .. "\n")
 983			print("Worktree removed; branch retained")
 984			return
 985		end
 986
 987		print("Worktree and branch '" .. branch .. "' removed")
 988	else
 989		print("Worktree removed")
 990	end
 991end
 992
 993local function cmd_list()
 994	local root, err = find_project_root()
 995	if not root then
 996		die(err --[[@as string]])
 997		return
 998	end
 999
1000	local git_dir = root .. "/.bare"
1001	local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain")
1002	if code ~= 0 then
1003		die("failed to list worktrees: " .. output, EXIT_SYSTEM_ERROR)
1004	end
1005
1006	-- Parse porcelain output into worktree entries
1007	---@type {path: string, head: string, branch: string}[]
1008	local worktrees = {}
1009	local current = {}
1010
1011	for line in output:gmatch("[^\n]+") do
1012		local key, value = line:match("^(%S+)%s*(.*)$")
1013		if key == "worktree" and value then
1014			if current.path then
1015				table.insert(worktrees, current)
1016			end
1017			-- Skip .bare directory
1018			if value:match("/%.bare$") then
1019				current = {}
1020			else
1021				current = { path = value, head = "", branch = "(detached)" }
1022			end
1023		elseif key == "HEAD" and value then
1024			current.head = value:sub(1, 7)
1025		elseif key == "branch" and value then
1026			current.branch = value:gsub("^refs/heads/", "")
1027		elseif key == "bare" then
1028			-- Skip bare repo entry
1029			current = {}
1030		end
1031	end
1032	if current.path then
1033		table.insert(worktrees, current)
1034	end
1035
1036	if #worktrees == 0 then
1037		print("No worktrees found")
1038		return
1039	end
1040
1041	-- Get current working directory
1042	local cwd = get_cwd() or ""
1043
1044	-- Build table rows with status
1045	local rows = {}
1046	for _, wt in ipairs(worktrees) do
1047		local rel_path = relative_path(cwd, wt.path)
1048
1049		-- Check dirty status
1050		local status_out = run_cmd("git -C " .. wt.path .. " status --porcelain")
1051		local status = status_out == "" and "clean" or "dirty"
1052
1053		table.insert(rows, rel_path .. "," .. wt.branch .. "," .. wt.head .. "," .. status)
1054	end
1055
1056	-- Output via gum table
1057	local table_input = "Path,Branch,HEAD,Status\n" .. table.concat(rows, "\n")
1058	table_input = table_input:gsub("EOF", "eof")
1059	local table_cmd = "gum table --print <<'EOF'\n" .. table_input .. "\nEOF"
1060	local table_handle = io.popen(table_cmd, "r")
1061	if not table_handle then
1062		return
1063	end
1064	io.write(table_handle:read("*a") or "")
1065	table_handle:close()
1066end
1067
1068local function cmd_fetch()
1069	local root, err = find_project_root()
1070	if not root then
1071		die(err --[[@as string]])
1072		return
1073	end
1074
1075	local git_dir = root .. "/.bare"
1076	local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git fetch --all --prune")
1077	io.write(output)
1078	if code ~= 0 then
1079		os.exit(EXIT_SYSTEM_ERROR)
1080	end
1081end
1082
1083---List directory entries (excluding . and ..)
1084---@param path string
1085---@return string[]
1086local function list_dir(path)
1087	local entries = {}
1088	local handle = io.popen("ls -A " .. path .. " 2>/dev/null")
1089	if not handle then
1090		return entries
1091	end
1092	for line in handle:lines() do
1093		if line ~= "" then
1094			table.insert(entries, line)
1095		end
1096	end
1097	handle:close()
1098	return entries
1099end
1100
1101---Check if path is a directory
1102---@param path string
1103---@return boolean
1104local function is_dir(path)
1105	local f = io.open(path, "r")
1106	if not f then
1107		return false
1108	end
1109	f:close()
1110	return run_cmd_silent("test -d " .. path)
1111end
1112
1113---Check if path is a file (not directory)
1114---@param path string
1115---@return boolean
1116local function is_file(path)
1117	local f = io.open(path, "r")
1118	if not f then
1119		return false
1120	end
1121	f:close()
1122	return run_cmd_silent("test -f " .. path)
1123end
1124
1125---@param args string[]
1126local function cmd_init(args)
1127	-- Parse arguments
1128	local dry_run = false
1129	local skip_confirm = false
1130	for _, a in ipairs(args) do
1131		if a == "--dry-run" then
1132			dry_run = true
1133		elseif a == "-y" or a == "--yes" then
1134			skip_confirm = true
1135		else
1136			die("unexpected argument: " .. a)
1137		end
1138	end
1139
1140	local cwd = get_cwd()
1141	if not cwd then
1142		die("failed to get current directory", EXIT_SYSTEM_ERROR)
1143		return
1144	end
1145
1146	-- Case 1: Already wt-managed (.git file pointing to .bare + .bare/ exists)
1147	local git_path = cwd .. "/.git"
1148	local bare_path = cwd .. "/.bare"
1149
1150	local bare_exists = is_dir(bare_path)
1151	local git_file = io.open(git_path, "r")
1152
1153	if git_file then
1154		local content = git_file:read("*a")
1155		git_file:close()
1156
1157		-- Check if it's a file (not directory) pointing to .bare
1158		if is_file(git_path) and content and content:match("gitdir:%s*%.?/?%.bare") then
1159			if bare_exists then
1160				print("Already using wt bare structure")
1161				os.exit(EXIT_SUCCESS)
1162			end
1163		end
1164
1165		-- Check if .git is a file pointing elsewhere (inside a worktree)
1166		if is_file(git_path) and content and content:match("^gitdir:") then
1167			-- It's a worktree, not project root
1168			die("inside a worktree; run from project root or use 'wt c' to clone fresh")
1169		end
1170	end
1171
1172	-- Check for .git directory
1173	local git_dir_exists = is_dir(git_path)
1174
1175	if not git_dir_exists then
1176		-- Case 5: No .git at all, or bare repo without .git dir
1177		if bare_exists then
1178			die("found .bare/ but no .git; manually create .git file with 'gitdir: ./.bare'")
1179		end
1180		die("not a git repository (no .git found)")
1181	end
1182
1183	-- Now we have a .git directory
1184	-- Case 3: Existing worktree setup (.git/worktrees/ exists)
1185	local worktrees_path = git_path .. "/worktrees"
1186	if is_dir(worktrees_path) then
1187		local worktrees = list_dir(worktrees_path)
1188		io.stderr:write("error: repository already uses git worktrees\n")
1189		io.stderr:write("\n")
1190		io.stderr:write("Converting an existing worktree setup is complex and error-prone.\n")
1191		io.stderr:write("Recommend: clone fresh with 'wt c <url>' and recreate worktrees.\n")
1192		if #worktrees > 0 then
1193			io.stderr:write("\nExisting worktrees:\n")
1194			for _, wt in ipairs(worktrees) do
1195				io.stderr:write("  " .. wt .. "\n")
1196			end
1197		end
1198		os.exit(EXIT_USER_ERROR)
1199	end
1200
1201	-- Case 4: Normal clone (.git/ directory, no worktrees)
1202	-- Check for uncommitted changes
1203	local status_out = run_cmd("git status --porcelain")
1204	if status_out ~= "" then
1205		die("uncommitted changes; commit or stash before converting")
1206	end
1207
1208	-- Detect default branch
1209	local default_branch = detect_cloned_default_branch(git_path)
1210
1211	-- Warnings
1212	local warnings = {}
1213
1214	-- Check for submodules
1215	if is_file(cwd .. "/.gitmodules") then
1216		table.insert(warnings, "submodules detected (.gitmodules exists); verify they work after conversion")
1217	end
1218
1219	-- Check for nested .git directories (excluding the main one)
1220	local nested_git_output, _ = run_cmd("find " .. cwd .. " -mindepth 2 -name .git -type d 2>/dev/null")
1221	if nested_git_output ~= "" then
1222		table.insert(warnings, "nested .git directories found; these may cause issues")
1223	end
1224
1225	-- Find orphaned files (files in root that will be deleted)
1226	local all_entries = list_dir(cwd)
1227	local orphaned = {}
1228	for _, entry in ipairs(all_entries) do
1229		if entry ~= ".git" and entry ~= ".bare" then
1230			table.insert(orphaned, entry)
1231		end
1232	end
1233
1234	-- Load global config for path style
1235	local global_config = load_global_config()
1236	local style = global_config.branch_path_style or "nested"
1237	local separator = global_config.flat_separator
1238	local worktree_path = branch_to_path(cwd, default_branch, style, separator)
1239
1240	if dry_run then
1241		print("Dry run - planned actions:")
1242		print("")
1243		print("1. Move .git/ to .bare/")
1244		print("2. Create .git file pointing to .bare/")
1245		print("3. Create worktree: " .. worktree_path .. " (" .. default_branch .. ")")
1246		if #orphaned > 0 then
1247			print("4. Remove " .. #orphaned .. " orphaned items from root:")
1248			for _, item in ipairs(orphaned) do
1249				print("   - " .. item)
1250			end
1251		end
1252		if #warnings > 0 then
1253			print("")
1254			print("Warnings:")
1255			for _, w in ipairs(warnings) do
1256				print("" .. w)
1257			end
1258		end
1259		os.exit(EXIT_SUCCESS)
1260	end
1261
1262	-- Show warnings
1263	for _, w in ipairs(warnings) do
1264		io.stderr:write("warning: " .. w .. "\n")
1265	end
1266
1267	-- Confirm with gum (unless -y/--yes)
1268	if not skip_confirm then
1269		local confirm_msg = "Convert to wt bare structure? This will move .git to .bare"
1270		if #orphaned > 0 then
1271			confirm_msg = confirm_msg .. " and remove " .. #orphaned .. " items from root"
1272		end
1273
1274		local confirm_code = os.execute("gum confirm '" .. confirm_msg .. "'")
1275		if confirm_code ~= true then
1276			print("Aborted")
1277			os.exit(EXIT_USER_ERROR)
1278		end
1279	end
1280
1281	-- Step 1: Move .git to .bare
1282	local output, code = run_cmd("mv " .. git_path .. " " .. bare_path)
1283	if code ~= 0 then
1284		die("failed to move .git to .bare: " .. output, EXIT_SYSTEM_ERROR)
1285	end
1286
1287	-- Step 2: Write .git file
1288	local git_file_handle = io.open(git_path, "w")
1289	if not git_file_handle then
1290		-- Try to recover
1291		run_cmd("mv " .. bare_path .. " " .. git_path)
1292		die("failed to create .git file", EXIT_SYSTEM_ERROR)
1293		return
1294	end
1295	git_file_handle:write("gitdir: ./.bare\n")
1296	git_file_handle:close()
1297
1298	-- Step 3: Detach HEAD so branch can be checked out in worktree
1299	-- Point bare repo's HEAD to a placeholder so main branch can be used by worktree
1300	run_cmd("GIT_DIR=" .. bare_path .. " git symbolic-ref HEAD refs/heads/__wt_detached_placeholder__")
1301
1302	-- Step 4: Create worktree for default branch
1303	output, code = run_cmd("GIT_DIR=" .. bare_path .. " git worktree add -- " .. worktree_path .. " " .. default_branch)
1304	if code ~= 0 then
1305		die("failed to create worktree: " .. output, EXIT_SYSTEM_ERROR)
1306	end
1307
1308	-- Step 5: Remove orphaned files from root
1309	for _, item in ipairs(orphaned) do
1310		local item_path = cwd .. "/" .. item
1311		output, code = run_cmd("rm -rf " .. item_path)
1312		if code ~= 0 then
1313			io.stderr:write("warning: failed to remove " .. item .. ": " .. output .. "\n")
1314		end
1315	end
1316
1317	-- Summary
1318	print("Converted to wt bare structure")
1319	print("Bare repo:  " .. bare_path)
1320	print("Worktree:   " .. worktree_path)
1321	if #orphaned > 0 then
1322		print("Removed:    " .. #orphaned .. " items from root")
1323	end
1324end
1325
1326-- Main entry point
1327
1328local function main()
1329	local command = arg[1]
1330
1331	if not command or command == "help" or command == "--help" or command == "-h" then
1332		print_usage()
1333		os.exit(EXIT_SUCCESS)
1334	end
1335
1336	---@cast command string
1337
1338	-- Collect remaining args
1339	local subargs = {}
1340	for i = 2, #arg do
1341		table.insert(subargs, arg[i])
1342	end
1343
1344	-- Check for --help on any command
1345	if subargs[1] == "--help" or subargs[1] == "-h" then
1346		show_command_help(command)
1347	end
1348
1349	if command == "c" then
1350		cmd_clone(subargs)
1351	elseif command == "n" then
1352		cmd_new(subargs)
1353	elseif command == "a" then
1354		cmd_add(subargs)
1355	elseif command == "r" then
1356		cmd_remove(subargs)
1357	elseif command == "l" then
1358		cmd_list()
1359	elseif command == "f" then
1360		cmd_fetch()
1361	elseif command == "init" then
1362		cmd_init(subargs)
1363	else
1364		die("unknown command: " .. command)
1365	end
1366end
1367
1368-- Export for testing when required as module
1369if pcall(debug.getlocal, 4, 1) then
1370	return {
1371		-- URL/project parsing
1372		extract_project_name = extract_project_name,
1373		resolve_url_template = resolve_url_template,
1374		-- Path manipulation
1375		branch_to_path = branch_to_path,
1376		split_path = split_path,
1377		relative_path = relative_path,
1378		path_inside = path_inside,
1379		-- Config loading
1380		load_global_config = load_global_config,
1381		load_project_config = load_project_config,
1382		-- Git output parsing (testable without git)
1383		parse_branch_remotes = parse_branch_remotes,
1384		parse_worktree_list = parse_worktree_list,
1385		escape_pattern = escape_pattern,
1386		-- Hook helpers
1387		summarize_hooks = summarize_hooks,
1388		load_hook_permissions = function(home_override)
1389			local home = home_override or os.getenv("HOME")
1390			if not home then
1391				return {}
1392			end
1393			local path = home .. "/.local/share/wt/hook-dirs.lua"
1394			local f = io.open(path, "r")
1395			if not f then
1396				return {}
1397			end
1398			local content = f:read("*a")
1399			f:close()
1400			local chunk = load("return " .. content, path, "t", {})
1401			if not chunk then
1402				return {}
1403			end
1404			local ok, result = pcall(chunk)
1405			if ok and type(result) == "table" then
1406				return result
1407			end
1408			return {}
1409		end,
1410		save_hook_permissions = function(perms, home_override)
1411			local home = home_override or os.getenv("HOME")
1412			if not home then
1413				return
1414			end
1415			local dir = home .. "/.local/share/wt"
1416			run_cmd_silent("mkdir -p " .. dir)
1417			local path = dir .. "/hook-dirs.lua"
1418			local f = io.open(path, "w")
1419			if not f then
1420				return
1421			end
1422			f:write("{\n")
1423			for k, v in pairs(perms) do
1424				f:write('\t["' .. k .. '"] = ' .. tostring(v) .. ",\n")
1425			end
1426			f:write("}\n")
1427			f:close()
1428		end,
1429		run_hooks = function(source, target, hooks, root, home_override)
1430			local home = home_override or os.getenv("HOME")
1431			if not home then
1432				return
1433			end
1434			local perm_path = home .. "/.local/share/wt/hook-dirs.lua"
1435			local perms = {}
1436			local pf = io.open(perm_path, "r")
1437			if pf then
1438				local content = pf:read("*a")
1439				pf:close()
1440				local chunk = load("return " .. content, perm_path, "t", {})
1441				if chunk then
1442					local ok, result = pcall(chunk)
1443					if ok and type(result) == "table" then
1444						perms = result
1445					end
1446				end
1447			end
1448			if perms[root] == false then
1449				io.stderr:write("hooks skipped (not allowed for this project)\n")
1450				return
1451			end
1452			if hooks.copy then
1453				for _, item in ipairs(hooks.copy) do
1454					local src = source .. "/" .. item
1455					local dst = target .. "/" .. item
1456					local parent = dst:match("(.+)/[^/]+$")
1457					if parent then
1458						run_cmd_silent("mkdir -p " .. parent)
1459					end
1460					run_cmd("cp -r " .. src .. " " .. dst)
1461				end
1462			end
1463			if hooks.symlink then
1464				for _, item in ipairs(hooks.symlink) do
1465					local src = source .. "/" .. item
1466					local dst = target .. "/" .. item
1467					local parent = dst:match("(.+)/[^/]+$")
1468					if parent then
1469						run_cmd_silent("mkdir -p " .. parent)
1470					end
1471					run_cmd("ln -s " .. src .. " " .. dst)
1472				end
1473			end
1474			if hooks.run then
1475				for _, cmd in ipairs(hooks.run) do
1476					run_cmd("cd " .. target .. " && " .. cmd)
1477				end
1478			end
1479		end,
1480		-- Project root detection (re-exported from wt.git)
1481		find_project_root = find_project_root,
1482		detect_source_worktree = detect_source_worktree,
1483		-- Command execution (for integration tests)
1484		run_cmd = run_cmd,
1485		run_cmd_silent = run_cmd_silent,
1486		-- Exit codes (re-exported from wt.exit)
1487		EXIT_SUCCESS = exit.EXIT_SUCCESS,
1488		EXIT_USER_ERROR = exit.EXIT_USER_ERROR,
1489		EXIT_SYSTEM_ERROR = exit.EXIT_SYSTEM_ERROR,
1490	}
1491end
1492
1493main()