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