main.lua

   1#!/usr/bin/env lua
   2
   3if _VERSION < "Lua 5.2" then
   4	io.stderr:write("error: wt requires Lua 5.2 or later\n")
   5	os.exit(1)
   6end
   7
   8-- Exit codes
   9local EXIT_SUCCESS = 0
  10local EXIT_USER_ERROR = 1
  11local EXIT_SYSTEM_ERROR = 2
  12
  13---Execute command, return output and exit code
  14---@param cmd string
  15---@return string output
  16---@return integer code
  17local function run_cmd(cmd)
  18	local handle = io.popen(cmd .. " 2>&1")
  19	if not handle then
  20		return "", EXIT_SYSTEM_ERROR
  21	end
  22	local output = handle:read("*a") or ""
  23	local success, _, code = handle:close()
  24	if success then
  25		return output, 0
  26	end
  27	return output, code or EXIT_SYSTEM_ERROR
  28end
  29
  30---Execute command silently, return success boolean
  31---@param cmd string
  32---@return boolean success
  33local function run_cmd_silent(cmd)
  34	local success = os.execute(cmd .. " >/dev/null 2>&1")
  35	return success == true
  36end
  37
  38---Walk up from cwd looking for .git file pointing to .bare, or .bare/ directory
  39---@return string|nil root
  40---@return string|nil error
  41local function find_project_root()
  42	local handle = io.popen("pwd")
  43	if not handle then
  44		return nil, "failed to get current directory"
  45	end
  46	local cwd = handle:read("*l")
  47	handle:close()
  48
  49	if not cwd then
  50		return nil, "failed to get current directory"
  51	end
  52
  53	local path = cwd
  54	while path and path ~= "" and path ~= "/" do
  55		-- Check for .bare directory
  56		local bare_check = io.open(path .. "/.bare/HEAD", "r")
  57		if bare_check then
  58			bare_check:close()
  59			return path, nil
  60		end
  61
  62		-- Check for .git file pointing to .bare
  63		local git_file = io.open(path .. "/.git", "r")
  64		if git_file then
  65			local content = git_file:read("*a")
  66			git_file:close()
  67			if content and content:match("gitdir:%s*%.?/?%.bare") then
  68				return path, nil
  69			end
  70		end
  71
  72		-- Move up one directory
  73		path = path:match("(.+)/[^/]+$")
  74	end
  75
  76	return nil, "not in a wt-managed repository"
  77end
  78
  79---Substitute ${project} in template string
  80---@param template string
  81---@param project_name string
  82---@return string
  83local function resolve_url_template(template, project_name)
  84	local escaped = project_name:gsub("%%", "%%%%")
  85	return (template:gsub("%${project}", escaped))
  86end
  87
  88---Parse git URLs to extract project name
  89---@param url string
  90---@return string|nil
  91local function _extract_project_name(url) -- luacheck: ignore 211
  92	if not url or url == "" then
  93		return nil
  94	end
  95
  96	url = url:gsub("[?#].*$", "")
  97	url = url:gsub("/+$", "")
  98
  99	if url == "" or url == "/" then
 100		return nil
 101	end
 102
 103	url = url:gsub("%.git$", "")
 104
 105	if not url:match("://") then
 106		local scp_path = url:match("^[^@]+@[^:]+:(.+)$")
 107		if scp_path and scp_path ~= "" then
 108			url = scp_path
 109		end
 110	end
 111
 112	local name = url:match("([^/]+)$") or url:match("([^:]+)$")
 113	if not name or name == "" then
 114		return nil
 115	end
 116	return name
 117end
 118
 119---Print error message and exit
 120---@param msg string
 121---@param code? integer
 122local function die(msg, code)
 123	io.stderr:write("error: " .. msg .. "\n")
 124	os.exit(code or EXIT_USER_ERROR)
 125end
 126
 127---Print usage information
 128local function print_usage()
 129	print("wt - git worktree manager")
 130	print("")
 131	print("Usage: wt <command> [options]")
 132	print("")
 133	print("Commands:")
 134	print("  c <url> [--remote name]... [--own]   Clone into bare worktree structure")
 135	print("  n <project-name> [--remote name]...  Initialize fresh project")
 136	print("  a <branch> [-b [<start-point>]]      Add worktree with optional hooks")
 137	print("  r <branch> [-b] [-f]                 Remove worktree, optionally delete branch")
 138	print("  l                                    List worktrees with status")
 139	print("  f                                    Fetch all remotes")
 140	print("  init [--dry-run] [-y]                 Convert existing repo to bare structure")
 141	print("  help                                 Show this help message")
 142end
 143
 144---Parse git URLs to extract project name (exported version)
 145---@param url string
 146---@return string|nil
 147local function extract_project_name(url)
 148	if not url or url == "" then
 149		return nil
 150	end
 151
 152	url = url:gsub("[?#].*$", "")
 153	url = url:gsub("/+$", "")
 154
 155	if url == "" or url == "/" then
 156		return nil
 157	end
 158
 159	url = url:gsub("%.git$", "")
 160
 161	if not url:match("://") then
 162		local scp_path = url:match("^[^@]+@[^:]+:(.+)$")
 163		if scp_path and scp_path ~= "" then
 164			url = scp_path
 165		end
 166	end
 167
 168	local name = url:match("([^/]+)$") or url:match("([^:]+)$")
 169	if not name or name == "" then
 170		return nil
 171	end
 172	return name
 173end
 174
 175---Detect default branch from cloned bare repo
 176---@param git_dir string
 177---@return string
 178local function detect_cloned_default_branch(git_dir)
 179	-- First try the bare repo's own HEAD (set during clone)
 180	local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git symbolic-ref HEAD")
 181	if code == 0 and output ~= "" then
 182		local branch = output:match("refs/heads/(.+)")
 183		if branch then
 184			return (branch:gsub("%s+$", ""))
 185		end
 186	end
 187	return "main"
 188end
 189
 190---Get default branch name from git config, fallback to "main"
 191---@return string
 192local function get_default_branch()
 193	local output, code = run_cmd("git config --get init.defaultBranch")
 194	if code == 0 and output ~= "" then
 195		return (output:gsub("%s+$", ""))
 196	end
 197	return "main"
 198end
 199
 200---Get current working directory
 201---@return string|nil
 202local function get_cwd()
 203	local handle = io.popen("pwd")
 204	if not handle then
 205		return nil
 206	end
 207	local cwd = handle:read("*l")
 208	handle:close()
 209	return cwd
 210end
 211
 212---Convert branch name to worktree path
 213---@param root string
 214---@param branch string
 215---@param style string "nested" or "flat"
 216---@param separator? string separator for flat style
 217---@return string
 218local function branch_to_path(root, branch, style, separator)
 219	if style == "flat" then
 220		local sep = separator or "_"
 221		local escaped_sep = sep:gsub("%%", "%%%%")
 222		local flat_name = branch:gsub("/", escaped_sep)
 223		return root .. "/" .. flat_name
 224	end
 225	-- nested style (default): preserve slashes
 226	return root .. "/" .. branch
 227end
 228
 229---Load global config from ~/.config/wt/config.lua
 230---@return {branch_path_style?: string, flat_separator?: string, remotes?: table<string, string>, default_remotes?: string[]|string}
 231local function load_global_config()
 232	local home = os.getenv("HOME")
 233	if not home then
 234		return {}
 235	end
 236	local config_path = home .. "/.config/wt/config.lua"
 237	local f = io.open(config_path, "r")
 238	if not f then
 239		return {}
 240	end
 241	local content = f:read("*a")
 242	f:close()
 243	local chunk, err = load(content, config_path, "t", {})
 244	if not chunk then
 245		chunk, err = load("return " .. content, config_path, "t", {})
 246	end
 247	if not chunk then
 248		io.stderr:write("warning: config syntax error in " .. config_path .. ": " .. err .. "\n")
 249		return {}
 250	end
 251	local ok, result = pcall(chunk)
 252	if not ok then
 253		io.stderr:write("warning: config execution error in " .. config_path .. ": " .. result .. "\n")
 254		return {}
 255	end
 256	if type(result) ~= "table" then
 257		io.stderr:write("warning: config must return a table in " .. config_path .. "\n")
 258		return {}
 259	end
 260	return result
 261end
 262
 263---Load project config from <root>/.wt.lua
 264---@param root string
 265---@return {hooks?: {copy?: string[], symlink?: string[], run?: string[]}}
 266local function load_project_config(root)
 267	local config_path = root .. "/.wt.lua"
 268	local f = io.open(config_path, "r")
 269	if not f then
 270		return {}
 271	end
 272	local content = f:read("*a")
 273	f:close()
 274
 275	local chunk, err = load(content, config_path, "t", {})
 276	if not chunk then
 277		chunk, err = load("return " .. content, config_path, "t", {})
 278	end
 279	if not chunk then
 280		io.stderr:write("warning: config syntax error in " .. config_path .. ": " .. err .. "\n")
 281		return {}
 282	end
 283	local ok, result = pcall(chunk)
 284	if not ok then
 285		io.stderr:write("warning: config execution error in " .. config_path .. ": " .. result .. "\n")
 286		return {}
 287	end
 288	if type(result) ~= "table" then
 289		io.stderr:write("warning: config must return a table in " .. config_path .. "\n")
 290		return {}
 291	end
 292	return result
 293end
 294
 295---Split path into components
 296---@param path string
 297---@return string[]
 298local function split_path(path)
 299	local parts = {}
 300	for part in path:gmatch("[^/]+") do
 301		table.insert(parts, part)
 302	end
 303	return parts
 304end
 305
 306---Calculate relative path from one absolute path to another
 307---@param from string absolute path of starting directory
 308---@param to string absolute path of target
 309---@return string relative path
 310local function relative_path(from, to)
 311	if from == to then
 312		return "./"
 313	end
 314
 315	local from_parts = split_path(from)
 316	local to_parts = split_path(to)
 317
 318	local common = 0
 319	for i = 1, math.min(#from_parts, #to_parts) do
 320		if from_parts[i] == to_parts[i] then
 321			common = i
 322		else
 323			break
 324		end
 325	end
 326
 327	local up_count = #from_parts - common
 328	local result = {}
 329
 330	for _ = 1, up_count do
 331		table.insert(result, "..")
 332	end
 333
 334	for i = common + 1, #to_parts do
 335		table.insert(result, to_parts[i])
 336	end
 337
 338	if #result == 0 then
 339		return "./"
 340	end
 341
 342	return table.concat(result, "/")
 343end
 344
 345---Check if cwd is inside a worktree (has .git file, not at project root)
 346---@param root string
 347---@return string|nil source_worktree path if inside worktree, nil if at project root
 348local function detect_source_worktree(root)
 349	local cwd = get_cwd()
 350	if not cwd then
 351		return nil
 352	end
 353	-- If cwd is the project root, no source worktree
 354	if cwd == root then
 355		return nil
 356	end
 357	-- Check if cwd has a .git file (indicating it's a worktree)
 358	local git_file = io.open(cwd .. "/.git", "r")
 359	if git_file then
 360		git_file:close()
 361		return cwd
 362	end
 363	-- Walk up to find worktree root
 364	---@type string|nil
 365	local path = cwd
 366	while path and path ~= "" and path ~= "/" and path ~= root do
 367		local gf = io.open(path .. "/.git", "r")
 368		if gf then
 369			gf:close()
 370			return path
 371		end
 372		path = path:match("(.+)/[^/]+$")
 373	end
 374	return nil
 375end
 376
 377---Check if branch exists locally
 378---@param git_dir string
 379---@param branch string
 380---@return boolean
 381local function branch_exists_local(git_dir, branch)
 382	return run_cmd_silent("GIT_DIR=" .. git_dir .. " git show-ref --verify --quiet refs/heads/" .. branch)
 383end
 384
 385---Escape special Lua pattern characters in a string
 386---@param str string
 387---@return string
 388local function escape_pattern(str)
 389	return (str:gsub("([%%%-%+%[%]%(%)%.%^%$%*%?])", "%%%1"))
 390end
 391
 392---Parse git branch -r output to extract remotes containing a branch
 393---@param output string git branch -r output
 394---@param branch string branch name to find
 395---@return string[] remote names
 396local function parse_branch_remotes(output, branch)
 397	local remotes = {}
 398	for line in output:gmatch("[^\n]+") do
 399		-- Match: "  origin/branch-name" or "  upstream/feature/foo"
 400		-- For branch "feature/foo", we want remote "origin", not "origin/feature"
 401		-- The remote name is everything before the LAST occurrence of /branch
 402		local trimmed = line:match("^%s*(.-)%s*$")
 403		if trimmed then
 404			-- Check if line ends with /branch
 405			local suffix = "/" .. branch
 406			if trimmed:sub(-#suffix) == suffix then
 407				local remote = trimmed:sub(1, #trimmed - #suffix)
 408				-- Simple remote name (no slashes) - this is what we want
 409				-- Remote names with slashes (e.g., "forks/alice") are ambiguous
 410				-- and skipped for safety
 411				if remote ~= "" and not remote:match("/") then
 412					table.insert(remotes, remote)
 413				end
 414			end
 415		end
 416	end
 417	return remotes
 418end
 419
 420---Find which remotes have the branch
 421---@param git_dir string
 422---@param branch string
 423---@return string[] remote names
 424local function find_branch_remotes(git_dir, branch)
 425	local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git branch -r --list '*/" .. branch .. "'")
 426	if code ~= 0 then
 427		return {}
 428	end
 429	return parse_branch_remotes(output, branch)
 430end
 431
 432---Load hook permissions from ~/.local/share/wt/hook-dirs.lua
 433---@return table<string, boolean>
 434local function load_hook_permissions()
 435	local home = os.getenv("HOME")
 436	if not home then
 437		return {}
 438	end
 439	local path = home .. "/.local/share/wt/hook-dirs.lua"
 440	local f = io.open(path, "r")
 441	if not f then
 442		return {}
 443	end
 444	local content = f:read("*a")
 445	f:close()
 446	local chunk = load("return " .. content, path, "t", {})
 447	if not chunk then
 448		return {}
 449	end
 450	local ok, result = pcall(chunk)
 451	if ok and type(result) == "table" then
 452		return result
 453	end
 454	return {}
 455end
 456
 457---Save hook permissions to ~/.local/share/wt/hook-dirs.lua
 458---@param perms table<string, boolean>
 459local function save_hook_permissions(perms)
 460	local home = os.getenv("HOME")
 461	if not home then
 462		return
 463	end
 464	local dir = home .. "/.local/share/wt"
 465	run_cmd_silent("mkdir -p " .. dir)
 466	local path = dir .. "/hook-dirs.lua"
 467	local f = io.open(path, "w")
 468	if not f then
 469		return
 470	end
 471	f:write("{\n")
 472	for k, v in pairs(perms) do
 473		f:write('\t["' .. k .. '"] = ' .. tostring(v) .. ",\n")
 474	end
 475	f:write("}\n")
 476	f:close()
 477end
 478
 479---Summarize hooks for confirmation prompt
 480---@param hooks {copy?: string[], symlink?: string[], run?: string[]}
 481---@return string
 482local function summarize_hooks(hooks)
 483	local parts = {}
 484	if hooks.copy and #hooks.copy > 0 then
 485		local items = {}
 486		for i = 1, math.min(3, #hooks.copy) do
 487			table.insert(items, hooks.copy[i])
 488		end
 489		local suffix = #hooks.copy > 3 and " (+" .. (#hooks.copy - 3) .. " more)" or ""
 490		table.insert(parts, "copy: " .. table.concat(items, ", ") .. suffix)
 491	end
 492	if hooks.symlink and #hooks.symlink > 0 then
 493		local items = {}
 494		for i = 1, math.min(3, #hooks.symlink) do
 495			table.insert(items, hooks.symlink[i])
 496		end
 497		local suffix = #hooks.symlink > 3 and " (+" .. (#hooks.symlink - 3) .. " more)" or ""
 498		table.insert(parts, "symlink: " .. table.concat(items, ", ") .. suffix)
 499	end
 500	if hooks.run and #hooks.run > 0 then
 501		local items = {}
 502		for i = 1, math.min(3, #hooks.run) do
 503			table.insert(items, hooks.run[i])
 504		end
 505		local suffix = #hooks.run > 3 and " (+" .. (#hooks.run - 3) .. " more)" or ""
 506		table.insert(parts, "run: " .. table.concat(items, ", ") .. suffix)
 507	end
 508	return table.concat(parts, "; ")
 509end
 510
 511---Check if hooks are allowed for a project, prompting if unknown
 512---@param root string project root path
 513---@param hooks {copy?: string[], symlink?: string[], run?: string[]}
 514---@return boolean allowed
 515local function check_hook_permission(root, hooks)
 516	local perms = load_hook_permissions()
 517	if perms[root] ~= nil then
 518		return perms[root]
 519	end
 520
 521	-- Prompt user
 522	local summary = summarize_hooks(hooks)
 523	local prompt = "Allow hooks for " .. root .. "?\\n" .. summary
 524	local allowed = run_cmd_silent("gum confirm " .. "'" .. prompt:gsub("'", "'\\''") .. "'")
 525
 526	perms[root] = allowed
 527	save_hook_permissions(perms)
 528	return allowed
 529end
 530
 531---Run hooks from .wt.lua config
 532---@param source string source worktree path
 533---@param target string target worktree path
 534---@param hooks {copy?: string[], symlink?: string[], run?: string[]}
 535---@param root string project root path
 536local function run_hooks(source, target, hooks, root)
 537	-- Check permission before running any hooks
 538	if not check_hook_permission(root, hooks) then
 539		io.stderr:write("hooks skipped (not allowed for this project)\n")
 540		return
 541	end
 542
 543	if hooks.copy then
 544		for _, item in ipairs(hooks.copy) do
 545			local src = source .. "/" .. item
 546			local dst = target .. "/" .. item
 547			-- Create parent directory if needed
 548			local parent = dst:match("(.+)/[^/]+$")
 549			if parent then
 550				run_cmd_silent("mkdir -p " .. parent)
 551			end
 552			local _, code = run_cmd("cp -r " .. src .. " " .. dst)
 553			if code ~= 0 then
 554				io.stderr:write("warning: failed to copy " .. item .. "\n")
 555			end
 556		end
 557	end
 558	if hooks.symlink then
 559		for _, item in ipairs(hooks.symlink) do
 560			local src = source .. "/" .. item
 561			local dst = target .. "/" .. item
 562			-- Create parent directory if needed
 563			local parent = dst:match("(.+)/[^/]+$")
 564			if parent then
 565				run_cmd_silent("mkdir -p " .. parent)
 566			end
 567			local _, code = run_cmd("ln -s " .. src .. " " .. dst)
 568			if code ~= 0 then
 569				io.stderr:write("warning: failed to symlink " .. item .. "\n")
 570			end
 571		end
 572	end
 573	if hooks.run then
 574		for _, cmd in ipairs(hooks.run) do
 575			local _, code = run_cmd("cd " .. target .. " && " .. cmd)
 576			if code ~= 0 then
 577				io.stderr:write("warning: hook command failed: " .. cmd .. "\n")
 578			end
 579		end
 580	end
 581end
 582
 583---@param args string[]
 584local function cmd_clone(args)
 585	-- Parse arguments: <url> [--remote name]... [--own]
 586	local url = nil
 587	---@type string[]
 588	local remote_flags = {}
 589	local own = false
 590
 591	local i = 1
 592	while i <= #args do
 593		local a = args[i]
 594		if a == "--remote" then
 595			if not args[i + 1] then
 596				die("--remote requires a name")
 597			end
 598			table.insert(remote_flags, args[i + 1])
 599			i = i + 1
 600		elseif a == "--own" then
 601			own = true
 602		elseif not url then
 603			url = a
 604		else
 605			die("unexpected argument: " .. a)
 606		end
 607		i = i + 1
 608	end
 609
 610	if not url then
 611		die("usage: wt c <url> [--remote name]... [--own]")
 612		return
 613	end
 614
 615	-- Extract project name from URL
 616	local project_name = extract_project_name(url)
 617	if not project_name then
 618		die("could not extract project name from URL: " .. url)
 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	-- Clone bare repo
 635	local bare_path = project_path .. "/.bare"
 636	local output, code = run_cmd("git clone --bare " .. url .. " " .. bare_path)
 637	if code ~= 0 then
 638		die("failed to clone: " .. output, EXIT_SYSTEM_ERROR)
 639	end
 640
 641	-- Write .git file pointing to .bare
 642	local git_file_handle = io.open(project_path .. "/.git", "w")
 643	if not git_file_handle then
 644		die("failed to create .git file", EXIT_SYSTEM_ERROR)
 645		return
 646	end
 647	git_file_handle:write("gitdir: ./.bare\n")
 648	git_file_handle:close()
 649
 650	-- Detect default branch
 651	local git_dir = bare_path
 652	local default_branch = detect_cloned_default_branch(git_dir)
 653
 654	-- Load global config
 655	local global_config = load_global_config()
 656
 657	-- Determine which remotes to use
 658	---@type string[]
 659	local selected_remotes = {}
 660
 661	if #remote_flags > 0 then
 662		selected_remotes = remote_flags
 663	elseif global_config.default_remotes then
 664		if type(global_config.default_remotes) == "table" then
 665			selected_remotes = global_config.default_remotes
 666		elseif global_config.default_remotes == "prompt" then
 667			if global_config.remotes then
 668				local keys = {}
 669				for k in pairs(global_config.remotes) do
 670					table.insert(keys, k)
 671				end
 672				table.sort(keys)
 673				if #keys > 0 then
 674					local input = table.concat(keys, "\n")
 675					local choose_type = own and "" or " --no-limit"
 676					local cmd = "echo '" .. input .. "' | gum choose" .. choose_type
 677					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		end
 686	elseif global_config.remotes then
 687		local keys = {}
 688		for k in pairs(global_config.remotes) do
 689			table.insert(keys, k)
 690		end
 691		table.sort(keys)
 692		if #keys > 0 then
 693			local input = table.concat(keys, "\n")
 694			local choose_type = own and "" or " --no-limit"
 695			local cmd = "echo '" .. input .. "' | gum choose" .. choose_type
 696			output, code = run_cmd(cmd)
 697			if code == 0 and output ~= "" then
 698				for line in output:gmatch("[^\n]+") do
 699					table.insert(selected_remotes, line)
 700				end
 701			end
 702		end
 703	end
 704
 705	-- Track configured remotes for summary
 706	---@type string[]
 707	local configured_remotes = {}
 708
 709	if own then
 710		-- User's own project: origin is their canonical remote
 711		if #selected_remotes > 0 then
 712			local first_remote = selected_remotes[1]
 713			-- Rename origin to first remote
 714			output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote rename origin " .. first_remote)
 715			if code ~= 0 then
 716				io.stderr:write("warning: failed to rename origin to " .. first_remote .. ": " .. output .. "\n")
 717			else
 718				-- Configure fetch refspec
 719				run_cmd(
 720					"GIT_DIR="
 721						.. git_dir
 722						.. " git config remote."
 723						.. first_remote
 724						.. ".fetch '+refs/heads/*:refs/remotes/"
 725						.. first_remote
 726						.. "/*'"
 727				)
 728				table.insert(configured_remotes, first_remote)
 729			end
 730
 731			-- Add additional remotes and push to them
 732			for j = 2, #selected_remotes do
 733				local remote_name = selected_remotes[j]
 734				local template = global_config.remotes and global_config.remotes[remote_name]
 735				if template then
 736					local remote_url = resolve_url_template(template, project_name)
 737					output, code =
 738						run_cmd("GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. remote_url)
 739					if code ~= 0 then
 740						io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
 741					else
 742						run_cmd(
 743							"GIT_DIR="
 744								.. git_dir
 745								.. " git config remote."
 746								.. remote_name
 747								.. ".fetch '+refs/heads/*:refs/remotes/"
 748								.. remote_name
 749								.. "/*'"
 750						)
 751						-- Push to additional remotes
 752						output, code =
 753							run_cmd("GIT_DIR=" .. git_dir .. " git push " .. remote_name .. " " .. default_branch)
 754						if code ~= 0 then
 755							io.stderr:write("warning: failed to push to " .. remote_name .. ": " .. output .. "\n")
 756						end
 757						table.insert(configured_remotes, remote_name)
 758					end
 759				else
 760					io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
 761				end
 762			end
 763		else
 764			-- No remotes selected, keep origin as-is
 765			run_cmd("GIT_DIR=" .. git_dir .. " git config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'")
 766			table.insert(configured_remotes, "origin")
 767		end
 768	else
 769		-- Contributing to someone else's project
 770		-- Rename origin to upstream
 771		output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote rename origin upstream")
 772		if code ~= 0 then
 773			io.stderr:write("warning: failed to rename origin to upstream: " .. output .. "\n")
 774		else
 775			run_cmd(
 776				"GIT_DIR=" .. git_dir .. " git config remote.upstream.fetch '+refs/heads/*:refs/remotes/upstream/*'"
 777			)
 778			table.insert(configured_remotes, "upstream")
 779		end
 780
 781		-- Add user's remotes and push to each
 782		for _, remote_name in ipairs(selected_remotes) do
 783			local template = global_config.remotes and global_config.remotes[remote_name]
 784			if template then
 785				local remote_url = resolve_url_template(template, project_name)
 786				output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. remote_url)
 787				if code ~= 0 then
 788					io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
 789				else
 790					run_cmd(
 791						"GIT_DIR="
 792							.. git_dir
 793							.. " git config remote."
 794							.. remote_name
 795							.. ".fetch '+refs/heads/*:refs/remotes/"
 796							.. remote_name
 797							.. "/*'"
 798					)
 799					-- Push to this remote
 800					output, code =
 801						run_cmd("GIT_DIR=" .. git_dir .. " git push " .. remote_name .. " " .. default_branch)
 802					if code ~= 0 then
 803						io.stderr:write("warning: failed to push to " .. remote_name .. ": " .. output .. "\n")
 804					end
 805					table.insert(configured_remotes, remote_name)
 806				end
 807			else
 808				io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
 809			end
 810		end
 811	end
 812
 813	-- Fetch all remotes
 814	run_cmd("GIT_DIR=" .. git_dir .. " git fetch --all")
 815
 816	-- Load config for path style
 817	local style = global_config.branch_path_style or "nested"
 818	local separator = global_config.flat_separator
 819	local worktree_path = branch_to_path(project_path, default_branch, style, separator)
 820
 821	-- Create initial worktree
 822	output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -- " .. worktree_path .. " " .. default_branch)
 823	if code ~= 0 then
 824		die("failed to create worktree: " .. output, EXIT_SYSTEM_ERROR)
 825	end
 826
 827	-- Print summary
 828	print("Created project: " .. project_path)
 829	print("Default branch:  " .. default_branch)
 830	print("Worktree:        " .. worktree_path)
 831	if #configured_remotes > 0 then
 832		print("Remotes:         " .. table.concat(configured_remotes, ", "))
 833	end
 834end
 835
 836---@param args string[]
 837local function cmd_new(args)
 838	-- Parse arguments: <project-name> [--remote name]...
 839	local project_name = nil
 840	---@type string[]
 841	local remote_flags = {}
 842
 843	local i = 1
 844	while i <= #args do
 845		local a = args[i]
 846		if a == "--remote" then
 847			if not args[i + 1] then
 848				die("--remote requires a name")
 849			end
 850			table.insert(remote_flags, args[i + 1])
 851			i = i + 1
 852		elseif not project_name then
 853			project_name = a
 854		else
 855			die("unexpected argument: " .. a)
 856		end
 857		i = i + 1
 858	end
 859
 860	if not project_name then
 861		die("usage: wt n <project-name> [--remote name]...")
 862		return
 863	end
 864
 865	-- Check if project directory already exists
 866	local cwd = get_cwd()
 867	if not cwd then
 868		die("failed to get current directory", EXIT_SYSTEM_ERROR)
 869	end
 870	local project_path = cwd .. "/" .. project_name
 871	local check = io.open(project_path, "r")
 872	if check then
 873		check:close()
 874		die("directory already exists: " .. project_path)
 875	end
 876
 877	-- Load global config
 878	local global_config = load_global_config()
 879
 880	-- Determine which remotes to use
 881	---@type string[]
 882	local selected_remotes = {}
 883
 884	if #remote_flags > 0 then
 885		-- Use explicitly provided remotes
 886		selected_remotes = remote_flags
 887	elseif global_config.default_remotes then
 888		if type(global_config.default_remotes) == "table" then
 889			selected_remotes = global_config.default_remotes
 890		elseif global_config.default_remotes == "prompt" then
 891			-- Prompt with gum choose
 892			if global_config.remotes then
 893				local keys = {}
 894				for k in pairs(global_config.remotes) do
 895					table.insert(keys, k)
 896				end
 897				table.sort(keys)
 898				if #keys > 0 then
 899					local input = table.concat(keys, "\n")
 900					local cmd = "echo '" .. input .. "' | gum choose --no-limit"
 901					local output, code = run_cmd(cmd)
 902					if code == 0 and output ~= "" then
 903						for line in output:gmatch("[^\n]+") do
 904							table.insert(selected_remotes, line)
 905						end
 906					end
 907				end
 908			end
 909		end
 910	elseif global_config.remotes then
 911		-- No default_remotes configured, prompt if remotes exist
 912		local keys = {}
 913		for k in pairs(global_config.remotes) do
 914			table.insert(keys, k)
 915		end
 916		table.sort(keys)
 917		if #keys > 0 then
 918			local input = table.concat(keys, "\n")
 919			local cmd = "echo '" .. input .. "' | gum choose --no-limit"
 920			local output, code = run_cmd(cmd)
 921			if code == 0 and output ~= "" then
 922				for line in output:gmatch("[^\n]+") do
 923					table.insert(selected_remotes, line)
 924				end
 925			end
 926		end
 927	end
 928
 929	-- Create project structure
 930	local bare_path = project_path .. "/.bare"
 931	local output, code = run_cmd("mkdir -p " .. bare_path)
 932	if code ~= 0 then
 933		die("failed to create directory: " .. output, EXIT_SYSTEM_ERROR)
 934	end
 935
 936	output, code = run_cmd("git init --bare " .. bare_path)
 937	if code ~= 0 then
 938		die("failed to init bare repo: " .. output, EXIT_SYSTEM_ERROR)
 939	end
 940
 941	-- Write .git file pointing to .bare
 942	local git_file_handle = io.open(project_path .. "/.git", "w")
 943	if not git_file_handle then
 944		die("failed to create .git file", EXIT_SYSTEM_ERROR)
 945		return
 946	end
 947	git_file_handle:write("gitdir: ./.bare\n")
 948	git_file_handle:close()
 949
 950	-- Add remotes
 951	local git_dir = bare_path
 952	for _, remote_name in ipairs(selected_remotes) do
 953		local template = global_config.remotes and global_config.remotes[remote_name]
 954		if template then
 955			local url = resolve_url_template(template, project_name)
 956			output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. url)
 957			if code ~= 0 then
 958				io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
 959			else
 960				-- Configure fetch refspec for the remote
 961				run_cmd(
 962					"GIT_DIR="
 963						.. git_dir
 964						.. " git config remote."
 965						.. remote_name
 966						.. ".fetch '+refs/heads/*:refs/remotes/"
 967						.. remote_name
 968						.. "/*'"
 969				)
 970			end
 971		else
 972			io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
 973		end
 974	end
 975
 976	-- Detect default branch
 977	local default_branch = get_default_branch()
 978
 979	-- Load config for path style
 980	local style = global_config.branch_path_style or "nested"
 981	local separator = global_config.flat_separator
 982	local worktree_path = branch_to_path(project_path, default_branch, style, separator)
 983
 984	-- Create orphan worktree
 985	output, code =
 986		run_cmd("GIT_DIR=" .. git_dir .. " git worktree add --orphan -b " .. default_branch .. " -- " .. worktree_path)
 987	if code ~= 0 then
 988		die("failed to create worktree: " .. output, EXIT_SYSTEM_ERROR)
 989	end
 990
 991	-- Print summary
 992	print("Created project: " .. project_path)
 993	print("Default branch:  " .. default_branch)
 994	print("Worktree:        " .. worktree_path)
 995	if #selected_remotes > 0 then
 996		print("Remotes:         " .. table.concat(selected_remotes, ", "))
 997	end
 998end
 999
1000---@param args string[]
1001local function cmd_add(args)
1002	-- Parse arguments: <branch> [-b [<start-point>]]
1003	---@type string|nil
1004	local branch = nil
1005	local create_branch = false
1006	---@type string|nil
1007	local start_point = nil
1008
1009	local i = 1
1010	while i <= #args do
1011		local a = args[i]
1012		if a == "-b" then
1013			create_branch = true
1014			-- Check if next arg is start-point (not another flag)
1015			if args[i + 1] and not args[i + 1]:match("^%-") then
1016				start_point = args[i + 1]
1017				i = i + 1
1018			end
1019		elseif not branch then
1020			branch = a
1021		else
1022			die("unexpected argument: " .. a)
1023		end
1024		i = i + 1
1025	end
1026
1027	if not branch then
1028		die("usage: wt a <branch> [-b [<start-point>]]")
1029		return
1030	end
1031
1032	local root, err = find_project_root()
1033	if not root then
1034		die(err --[[@as string]])
1035		return
1036	end
1037
1038	local git_dir = root .. "/.bare"
1039	local source_worktree = detect_source_worktree(root)
1040
1041	-- Load config for path style
1042	local global_config = load_global_config()
1043	local style = global_config.branch_path_style or "nested"
1044	local separator = global_config.flat_separator or "_"
1045
1046	local target_path = branch_to_path(root, branch, style, separator)
1047
1048	-- Check if target already exists
1049	local check = io.open(target_path .. "/.git", "r")
1050	if check then
1051		check:close()
1052		die("worktree already exists at " .. target_path)
1053	end
1054
1055	local output, code
1056	if create_branch then
1057		-- Create new branch with worktree
1058		if start_point then
1059			output, code = run_cmd(
1060				"GIT_DIR="
1061					.. git_dir
1062					.. " git worktree add -b "
1063					.. branch
1064					.. " -- "
1065					.. target_path
1066					.. " "
1067					.. start_point
1068			)
1069		else
1070			output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -b " .. branch .. " -- " .. target_path)
1071		end
1072	else
1073		-- Check if branch exists locally or on remotes
1074		local exists_local = branch_exists_local(git_dir, branch)
1075		local remotes = find_branch_remotes(git_dir, branch)
1076
1077		if not exists_local and #remotes == 0 then
1078			die("branch '" .. branch .. "' not found locally or on any remote (use -b to create)")
1079		end
1080
1081		if #remotes > 1 then
1082			die("branch '" .. branch .. "' exists on multiple remotes: " .. table.concat(remotes, ", "))
1083		end
1084
1085		output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -- " .. target_path .. " " .. branch)
1086	end
1087
1088	if code ~= 0 then
1089		die("failed to add worktree: " .. output, EXIT_SYSTEM_ERROR)
1090	end
1091
1092	-- Run hooks if we have a source worktree
1093	local project_config = load_project_config(root)
1094	if source_worktree then
1095		if project_config.hooks then
1096			run_hooks(source_worktree, target_path, project_config.hooks, root)
1097		end
1098	elseif project_config.hooks then
1099		io.stderr:write("warning: hooks skipped (run from inside a worktree to apply hooks)\n")
1100	end
1101
1102	print(target_path)
1103end
1104
1105---Check if path_a is inside (or equal to) path_b
1106---@param path_a string the path to check
1107---@param path_b string the container path
1108---@return boolean
1109local function path_inside(path_a, path_b)
1110	-- Normalize: ensure no trailing slash for comparison
1111	path_b = path_b:gsub("/$", "")
1112	path_a = path_a:gsub("/$", "")
1113	return path_a == path_b or path_a:sub(1, #path_b + 1) == path_b .. "/"
1114end
1115
1116---Check if cwd is inside (or equal to) a given path
1117---@param target string
1118---@return boolean
1119local function cwd_inside_path(target)
1120	local cwd = get_cwd()
1121	if not cwd then
1122		return false
1123	end
1124	return path_inside(cwd, target)
1125end
1126
1127---Get the bare repo's HEAD branch
1128---@param git_dir string
1129---@return string|nil branch name, nil on error
1130local function get_bare_head(git_dir)
1131	local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git symbolic-ref --short HEAD")
1132	if code ~= 0 then
1133		return nil
1134	end
1135	return (output:gsub("%s+$", ""))
1136end
1137
1138---Parse git worktree list --porcelain output
1139---@param output string git worktree list --porcelain output
1140---@return table[] array of {path: string, branch?: string, bare?: boolean, detached?: boolean}
1141local function parse_worktree_list(output)
1142	local worktrees = {}
1143	local current = nil
1144	for line in output:gmatch("[^\n]+") do
1145		local key, value = line:match("^(%S+)%s*(.*)$")
1146		if key == "worktree" then
1147			if current then
1148				table.insert(worktrees, current)
1149			end
1150			current = { path = value }
1151		elseif current then
1152			if key == "branch" and value then
1153				current.branch = value:gsub("^refs/heads/", "")
1154			elseif key == "bare" then
1155				current.bare = true
1156			elseif key == "detached" then
1157				current.detached = true
1158			elseif key == "HEAD" then
1159				current.head = value
1160			end
1161		end
1162	end
1163	if current then
1164		table.insert(worktrees, current)
1165	end
1166	return worktrees
1167end
1168
1169---Check if branch is checked out in any worktree
1170---@param git_dir string
1171---@param branch string
1172---@return string|nil path if checked out, nil otherwise
1173local function branch_checked_out_at(git_dir, branch)
1174	local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain")
1175	if code ~= 0 then
1176		return nil
1177	end
1178	local worktrees = parse_worktree_list(output)
1179	for _, wt in ipairs(worktrees) do
1180		if wt.branch == branch then
1181			return wt.path
1182		end
1183	end
1184	return nil
1185end
1186
1187---@param args string[]
1188local function cmd_remove(args)
1189	-- Parse arguments: <branch> [-b] [-f]
1190	local branch = nil
1191	local delete_branch = false
1192	local force = false
1193
1194	for _, a in ipairs(args) do
1195		if a == "-b" then
1196			delete_branch = true
1197		elseif a == "-f" then
1198			force = true
1199		elseif not branch then
1200			branch = a
1201		else
1202			die("unexpected argument: " .. a)
1203		end
1204	end
1205
1206	if not branch then
1207		die("usage: wt r <branch> [-b] [-f]")
1208		return
1209	end
1210
1211	local root, err = find_project_root()
1212	if not root then
1213		die(err --[[@as string]])
1214		return
1215	end
1216
1217	local git_dir = root .. "/.bare"
1218
1219	-- Find worktree by querying git for actual location (not computed from config)
1220	local wt_output, wt_code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain")
1221	if wt_code ~= 0 then
1222		die("failed to list worktrees", EXIT_SYSTEM_ERROR)
1223		return
1224	end
1225
1226	local worktrees = parse_worktree_list(wt_output)
1227	local target_path = nil
1228	for _, wt in ipairs(worktrees) do
1229		if wt.branch == branch then
1230			target_path = wt.path
1231			break
1232		end
1233	end
1234
1235	if not target_path then
1236		die("no worktree found for branch '" .. branch .. "'")
1237		return
1238	end
1239
1240	-- Error if cwd is inside the worktree
1241	if cwd_inside_path(target_path) then
1242		die("cannot remove worktree while inside it")
1243	end
1244
1245	-- Check for uncommitted changes
1246	if not force then
1247		local status_out = run_cmd("git -C " .. target_path .. " status --porcelain")
1248		if status_out ~= "" then
1249			die("worktree has uncommitted changes (use -f to force)")
1250		end
1251	end
1252
1253	-- Remove worktree
1254	local remove_cmd = "GIT_DIR=" .. git_dir .. " git worktree remove"
1255	if force then
1256		remove_cmd = remove_cmd .. " --force"
1257	end
1258	remove_cmd = remove_cmd .. " -- " .. target_path
1259
1260	local output, code = run_cmd(remove_cmd)
1261	if code ~= 0 then
1262		die("failed to remove worktree: " .. output, EXIT_SYSTEM_ERROR)
1263	end
1264
1265	-- Delete branch if requested
1266	if delete_branch then
1267		-- Check if branch is bare repo's HEAD
1268		local bare_head = get_bare_head(git_dir)
1269		if bare_head and bare_head == branch then
1270			io.stderr:write("warning: cannot delete branch '" .. branch .. "' (it's the bare repo's HEAD)\n")
1271			print("Worktree removed; branch retained")
1272			return
1273		end
1274
1275		-- Check if branch is checked out elsewhere
1276		local checked_out = branch_checked_out_at(git_dir, branch)
1277		if checked_out then
1278			die("cannot delete branch '" .. branch .. "': checked out at " .. checked_out)
1279		end
1280
1281		-- Delete branch
1282		local delete_flag = force and "-D" or "-d"
1283		local del_output, del_code = run_cmd("GIT_DIR=" .. git_dir .. " git branch " .. delete_flag .. " " .. branch)
1284		if del_code ~= 0 then
1285			io.stderr:write("warning: failed to delete branch: " .. del_output .. "\n")
1286			print("Worktree removed; branch retained")
1287			return
1288		end
1289
1290		print("Worktree and branch '" .. branch .. "' removed")
1291	else
1292		print("Worktree removed")
1293	end
1294end
1295
1296local function cmd_list()
1297	local root, err = find_project_root()
1298	if not root then
1299		die(err --[[@as string]])
1300		return
1301	end
1302
1303	local git_dir = root .. "/.bare"
1304	local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain")
1305	if code ~= 0 then
1306		die("failed to list worktrees: " .. output, EXIT_SYSTEM_ERROR)
1307	end
1308
1309	-- Parse porcelain output into worktree entries
1310	---@type {path: string, head: string, branch: string}[]
1311	local worktrees = {}
1312	local current = {}
1313
1314	for line in output:gmatch("[^\n]+") do
1315		local key, value = line:match("^(%S+)%s*(.*)$")
1316		if key == "worktree" and value then
1317			if current.path then
1318				table.insert(worktrees, current)
1319			end
1320			-- Skip .bare directory
1321			if value:match("/%.bare$") then
1322				current = {}
1323			else
1324				current = { path = value, head = "", branch = "(detached)" }
1325			end
1326		elseif key == "HEAD" and value then
1327			current.head = value:sub(1, 7)
1328		elseif key == "branch" and value then
1329			current.branch = value:gsub("^refs/heads/", "")
1330		elseif key == "bare" then
1331			-- Skip bare repo entry
1332			current = {}
1333		end
1334	end
1335	if current.path then
1336		table.insert(worktrees, current)
1337	end
1338
1339	if #worktrees == 0 then
1340		print("No worktrees found")
1341		return
1342	end
1343
1344	-- Get current working directory
1345	local cwd = get_cwd() or ""
1346
1347	-- Build table rows with status
1348	local rows = {}
1349	for _, wt in ipairs(worktrees) do
1350		local rel_path = relative_path(cwd, wt.path)
1351
1352		-- Check dirty status
1353		local status_out = run_cmd("git -C " .. wt.path .. " status --porcelain")
1354		local status = status_out == "" and "clean" or "dirty"
1355
1356		table.insert(rows, rel_path .. "," .. wt.branch .. "," .. wt.head .. "," .. status)
1357	end
1358
1359	-- Output via gum table
1360	local table_input = "Path,Branch,HEAD,Status\n" .. table.concat(rows, "\n")
1361	table_input = table_input:gsub("EOF", "eof")
1362	local table_cmd = "gum table --print <<'EOF'\n" .. table_input .. "\nEOF"
1363	local table_handle = io.popen(table_cmd, "r")
1364	if not table_handle then
1365		return
1366	end
1367	io.write(table_handle:read("*a") or "")
1368	table_handle:close()
1369end
1370
1371local function cmd_fetch()
1372	local root, err = find_project_root()
1373	if not root then
1374		die(err --[[@as string]])
1375		return
1376	end
1377
1378	local git_dir = root .. "/.bare"
1379	local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git fetch --all --prune")
1380	io.write(output)
1381	if code ~= 0 then
1382		os.exit(EXIT_SYSTEM_ERROR)
1383	end
1384end
1385
1386---List directory entries (excluding . and ..)
1387---@param path string
1388---@return string[]
1389local function list_dir(path)
1390	local entries = {}
1391	local handle = io.popen("ls -A " .. path .. " 2>/dev/null")
1392	if not handle then
1393		return entries
1394	end
1395	for line in handle:lines() do
1396		if line ~= "" then
1397			table.insert(entries, line)
1398		end
1399	end
1400	handle:close()
1401	return entries
1402end
1403
1404---Check if path is a directory
1405---@param path string
1406---@return boolean
1407local function is_dir(path)
1408	local f = io.open(path, "r")
1409	if not f then
1410		return false
1411	end
1412	f:close()
1413	return run_cmd_silent("test -d " .. path)
1414end
1415
1416---Check if path is a file (not directory)
1417---@param path string
1418---@return boolean
1419local function is_file(path)
1420	local f = io.open(path, "r")
1421	if not f then
1422		return false
1423	end
1424	f:close()
1425	return run_cmd_silent("test -f " .. path)
1426end
1427
1428---@param args string[]
1429local function cmd_init(args)
1430	-- Parse arguments
1431	local dry_run = false
1432	local skip_confirm = false
1433	for _, a in ipairs(args) do
1434		if a == "--dry-run" then
1435			dry_run = true
1436		elseif a == "-y" or a == "--yes" then
1437			skip_confirm = true
1438		else
1439			die("unexpected argument: " .. a)
1440		end
1441	end
1442
1443	local cwd = get_cwd()
1444	if not cwd then
1445		die("failed to get current directory", EXIT_SYSTEM_ERROR)
1446		return
1447	end
1448
1449	-- Case 1: Already wt-managed (.git file pointing to .bare + .bare/ exists)
1450	local git_path = cwd .. "/.git"
1451	local bare_path = cwd .. "/.bare"
1452
1453	local bare_exists = is_dir(bare_path)
1454	local git_file = io.open(git_path, "r")
1455
1456	if git_file then
1457		local content = git_file:read("*a")
1458		git_file:close()
1459
1460		-- Check if it's a file (not directory) pointing to .bare
1461		if is_file(git_path) and content and content:match("gitdir:%s*%.?/?%.bare") then
1462			if bare_exists then
1463				print("Already using wt bare structure")
1464				os.exit(EXIT_SUCCESS)
1465			end
1466		end
1467
1468		-- Check if .git is a file pointing elsewhere (inside a worktree)
1469		if is_file(git_path) and content and content:match("^gitdir:") then
1470			-- It's a worktree, not project root
1471			die("inside a worktree; run from project root or use 'wt c' to clone fresh")
1472		end
1473	end
1474
1475	-- Check for .git directory
1476	local git_dir_exists = is_dir(git_path)
1477
1478	if not git_dir_exists then
1479		-- Case 5: No .git at all, or bare repo without .git dir
1480		if bare_exists then
1481			die("found .bare/ but no .git; manually create .git file with 'gitdir: ./.bare'")
1482		end
1483		die("not a git repository (no .git found)")
1484	end
1485
1486	-- Now we have a .git directory
1487	-- Case 3: Existing worktree setup (.git/worktrees/ exists)
1488	local worktrees_path = git_path .. "/worktrees"
1489	if is_dir(worktrees_path) then
1490		local worktrees = list_dir(worktrees_path)
1491		io.stderr:write("error: repository already uses git worktrees\n")
1492		io.stderr:write("\n")
1493		io.stderr:write("Converting an existing worktree setup is complex and error-prone.\n")
1494		io.stderr:write("Recommend: clone fresh with 'wt c <url>' and recreate worktrees.\n")
1495		if #worktrees > 0 then
1496			io.stderr:write("\nExisting worktrees:\n")
1497			for _, wt in ipairs(worktrees) do
1498				io.stderr:write("  " .. wt .. "\n")
1499			end
1500		end
1501		os.exit(EXIT_USER_ERROR)
1502	end
1503
1504	-- Case 4: Normal clone (.git/ directory, no worktrees)
1505	-- Check for uncommitted changes
1506	local status_out = run_cmd("git status --porcelain")
1507	if status_out ~= "" then
1508		die("uncommitted changes; commit or stash before converting")
1509	end
1510
1511	-- Detect default branch
1512	local default_branch = detect_cloned_default_branch(git_path)
1513
1514	-- Warnings
1515	local warnings = {}
1516
1517	-- Check for submodules
1518	if is_file(cwd .. "/.gitmodules") then
1519		table.insert(warnings, "submodules detected (.gitmodules exists); verify they work after conversion")
1520	end
1521
1522	-- Check for nested .git directories (excluding the main one)
1523	local nested_git_output, _ = run_cmd("find " .. cwd .. " -mindepth 2 -name .git -type d 2>/dev/null")
1524	if nested_git_output ~= "" then
1525		table.insert(warnings, "nested .git directories found; these may cause issues")
1526	end
1527
1528	-- Find orphaned files (files in root that will be deleted)
1529	local all_entries = list_dir(cwd)
1530	local orphaned = {}
1531	for _, entry in ipairs(all_entries) do
1532		if entry ~= ".git" and entry ~= ".bare" then
1533			table.insert(orphaned, entry)
1534		end
1535	end
1536
1537	-- Load global config for path style
1538	local global_config = load_global_config()
1539	local style = global_config.branch_path_style or "nested"
1540	local separator = global_config.flat_separator
1541	local worktree_path = branch_to_path(cwd, default_branch, style, separator)
1542
1543	if dry_run then
1544		print("Dry run - planned actions:")
1545		print("")
1546		print("1. Move .git/ to .bare/")
1547		print("2. Create .git file pointing to .bare/")
1548		print("3. Create worktree: " .. worktree_path .. " (" .. default_branch .. ")")
1549		if #orphaned > 0 then
1550			print("4. Remove " .. #orphaned .. " orphaned items from root:")
1551			for _, item in ipairs(orphaned) do
1552				print("   - " .. item)
1553			end
1554		end
1555		if #warnings > 0 then
1556			print("")
1557			print("Warnings:")
1558			for _, w in ipairs(warnings) do
1559				print("" .. w)
1560			end
1561		end
1562		os.exit(EXIT_SUCCESS)
1563	end
1564
1565	-- Show warnings
1566	for _, w in ipairs(warnings) do
1567		io.stderr:write("warning: " .. w .. "\n")
1568	end
1569
1570	-- Confirm with gum (unless -y/--yes)
1571	if not skip_confirm then
1572		local confirm_msg = "Convert to wt bare structure? This will move .git to .bare"
1573		if #orphaned > 0 then
1574			confirm_msg = confirm_msg .. " and remove " .. #orphaned .. " items from root"
1575		end
1576
1577		local confirm_code = os.execute("gum confirm '" .. confirm_msg .. "'")
1578		if confirm_code ~= true then
1579			print("Aborted")
1580			os.exit(EXIT_USER_ERROR)
1581		end
1582	end
1583
1584	-- Step 1: Move .git to .bare
1585	local output, code = run_cmd("mv " .. git_path .. " " .. bare_path)
1586	if code ~= 0 then
1587		die("failed to move .git to .bare: " .. output, EXIT_SYSTEM_ERROR)
1588	end
1589
1590	-- Step 2: Write .git file
1591	local git_file_handle = io.open(git_path, "w")
1592	if not git_file_handle then
1593		-- Try to recover
1594		run_cmd("mv " .. bare_path .. " " .. git_path)
1595		die("failed to create .git file", EXIT_SYSTEM_ERROR)
1596		return
1597	end
1598	git_file_handle:write("gitdir: ./.bare\n")
1599	git_file_handle:close()
1600
1601	-- Step 3: Detach HEAD so branch can be checked out in worktree
1602	-- Point bare repo's HEAD to a placeholder so main branch can be used by worktree
1603	run_cmd("GIT_DIR=" .. bare_path .. " git symbolic-ref HEAD refs/heads/__wt_detached_placeholder__")
1604
1605	-- Step 4: Create worktree for default branch
1606	output, code = run_cmd("GIT_DIR=" .. bare_path .. " git worktree add -- " .. worktree_path .. " " .. default_branch)
1607	if code ~= 0 then
1608		die("failed to create worktree: " .. output, EXIT_SYSTEM_ERROR)
1609	end
1610
1611	-- Step 5: Remove orphaned files from root
1612	for _, item in ipairs(orphaned) do
1613		local item_path = cwd .. "/" .. item
1614		output, code = run_cmd("rm -rf " .. item_path)
1615		if code ~= 0 then
1616			io.stderr:write("warning: failed to remove " .. item .. ": " .. output .. "\n")
1617		end
1618	end
1619
1620	-- Summary
1621	print("Converted to wt bare structure")
1622	print("Bare repo:  " .. bare_path)
1623	print("Worktree:   " .. worktree_path)
1624	if #orphaned > 0 then
1625		print("Removed:    " .. #orphaned .. " items from root")
1626	end
1627end
1628
1629-- Main entry point
1630
1631local function main()
1632	local command = arg[1]
1633
1634	if not command or command == "help" or command == "--help" or command == "-h" then
1635		print_usage()
1636		os.exit(EXIT_SUCCESS)
1637	end
1638
1639	-- Collect remaining args
1640	local subargs = {}
1641	for i = 2, #arg do
1642		table.insert(subargs, arg[i])
1643	end
1644
1645	if command == "c" then
1646		cmd_clone(subargs)
1647	elseif command == "n" then
1648		cmd_new(subargs)
1649	elseif command == "a" then
1650		cmd_add(subargs)
1651	elseif command == "r" then
1652		cmd_remove(subargs)
1653	elseif command == "l" then
1654		cmd_list()
1655	elseif command == "f" then
1656		cmd_fetch()
1657	elseif command == "init" then
1658		cmd_init(subargs)
1659	else
1660		die("unknown command: " .. command)
1661	end
1662end
1663
1664-- Export for testing when required as module
1665if pcall(debug.getlocal, 4, 1) then
1666	return {
1667		-- URL/project parsing
1668		extract_project_name = extract_project_name,
1669		resolve_url_template = resolve_url_template,
1670		-- Path manipulation
1671		branch_to_path = branch_to_path,
1672		split_path = split_path,
1673		relative_path = relative_path,
1674		path_inside = path_inside,
1675		-- Config loading
1676		load_global_config = load_global_config,
1677		load_project_config = load_project_config,
1678		-- Git output parsing (testable without git)
1679		parse_branch_remotes = parse_branch_remotes,
1680		parse_worktree_list = parse_worktree_list,
1681		escape_pattern = escape_pattern,
1682		-- Hook helpers
1683		summarize_hooks = summarize_hooks,
1684		load_hook_permissions = function(home_override)
1685			local home = home_override or os.getenv("HOME")
1686			if not home then
1687				return {}
1688			end
1689			local path = home .. "/.local/share/wt/hook-dirs.lua"
1690			local f = io.open(path, "r")
1691			if not f then
1692				return {}
1693			end
1694			local content = f:read("*a")
1695			f:close()
1696			local chunk = load("return " .. content, path, "t", {})
1697			if not chunk then
1698				return {}
1699			end
1700			local ok, result = pcall(chunk)
1701			if ok and type(result) == "table" then
1702				return result
1703			end
1704			return {}
1705		end,
1706		save_hook_permissions = function(perms, home_override)
1707			local home = home_override or os.getenv("HOME")
1708			if not home then
1709				return
1710			end
1711			local dir = home .. "/.local/share/wt"
1712			run_cmd_silent("mkdir -p " .. dir)
1713			local path = dir .. "/hook-dirs.lua"
1714			local f = io.open(path, "w")
1715			if not f then
1716				return
1717			end
1718			f:write("{\n")
1719			for k, v in pairs(perms) do
1720				f:write('\t["' .. k .. '"] = ' .. tostring(v) .. ",\n")
1721			end
1722			f:write("}\n")
1723			f:close()
1724		end,
1725		run_hooks = function(source, target, hooks, root, home_override)
1726			local home = home_override or os.getenv("HOME")
1727			if not home then
1728				return
1729			end
1730			local perm_path = home .. "/.local/share/wt/hook-dirs.lua"
1731			local perms = {}
1732			local pf = io.open(perm_path, "r")
1733			if pf then
1734				local content = pf:read("*a")
1735				pf:close()
1736				local chunk = load("return " .. content, perm_path, "t", {})
1737				if chunk then
1738					local ok, result = pcall(chunk)
1739					if ok and type(result) == "table" then
1740						perms = result
1741					end
1742				end
1743			end
1744			if perms[root] == false then
1745				io.stderr:write("hooks skipped (not allowed for this project)\n")
1746				return
1747			end
1748			if hooks.copy then
1749				for _, item in ipairs(hooks.copy) do
1750					local src = source .. "/" .. item
1751					local dst = target .. "/" .. item
1752					local parent = dst:match("(.+)/[^/]+$")
1753					if parent then
1754						run_cmd_silent("mkdir -p " .. parent)
1755					end
1756					run_cmd("cp -r " .. src .. " " .. dst)
1757				end
1758			end
1759			if hooks.symlink then
1760				for _, item in ipairs(hooks.symlink) do
1761					local src = source .. "/" .. item
1762					local dst = target .. "/" .. item
1763					local parent = dst:match("(.+)/[^/]+$")
1764					if parent then
1765						run_cmd_silent("mkdir -p " .. parent)
1766					end
1767					run_cmd("ln -s " .. src .. " " .. dst)
1768				end
1769			end
1770			if hooks.run then
1771				for _, cmd in ipairs(hooks.run) do
1772					run_cmd("cd " .. target .. " && " .. cmd)
1773				end
1774			end
1775		end,
1776		-- Project root detection
1777		find_project_root = function(cwd_override)
1778			local cwd = cwd_override or get_cwd()
1779			if not cwd then
1780				return nil, "failed to get current directory"
1781			end
1782			local path = cwd
1783			while path and path ~= "" and path ~= "/" do
1784				local bare_check = io.open(path .. "/.bare/HEAD", "r")
1785				if bare_check then
1786					bare_check:close()
1787					return path, nil
1788				end
1789				local git_file = io.open(path .. "/.git", "r")
1790				if git_file then
1791					local content = git_file:read("*a")
1792					git_file:close()
1793					if content and content:match("gitdir:%s*%.?/?%.bare") then
1794						return path, nil
1795					end
1796				end
1797				path = path:match("(.+)/[^/]+$")
1798			end
1799			return nil, "not in a wt-managed repository"
1800		end,
1801		detect_source_worktree = function(root, cwd_override)
1802			local cwd = cwd_override or get_cwd()
1803			if not cwd then
1804				return nil
1805			end
1806			if cwd == root then
1807				return nil
1808			end
1809			local git_file = io.open(cwd .. "/.git", "r")
1810			if git_file then
1811				git_file:close()
1812				return cwd
1813			end
1814			local path = cwd
1815			while path and path ~= "" and path ~= "/" and path ~= root do
1816				local gf = io.open(path .. "/.git", "r")
1817				if gf then
1818					gf:close()
1819					return path
1820				end
1821				path = path:match("(.+)/[^/]+$")
1822			end
1823			return nil
1824		end,
1825		-- Command execution (for integration tests)
1826		run_cmd = run_cmd,
1827		run_cmd_silent = run_cmd_silent,
1828		-- Exit codes
1829		EXIT_SUCCESS = EXIT_SUCCESS,
1830		EXIT_USER_ERROR = EXIT_USER_ERROR,
1831		EXIT_SYSTEM_ERROR = EXIT_SYSTEM_ERROR,
1832	}
1833end
1834
1835main()