main.lua

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