main.lua

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