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