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	local current = nil
1273	for line in output:gmatch("[^\n]+") do
1274		local key, value = line:match("^(%S+)%s*(.*)$")
1275		if key == "worktree" then
1276			if current then
1277				table.insert(worktrees, current)
1278			end
1279			current = { path = value }
1280		elseif current then
1281			if key == "branch" and value then
1282				current.branch = value:gsub("^refs/heads/", "")
1283			elseif key == "bare" then
1284				current.bare = true
1285			elseif key == "detached" then
1286				current.detached = true
1287			elseif key == "HEAD" then
1288				current.head = value
1289			end
1290		end
1291	end
1292	if current then
1293		table.insert(worktrees, current)
1294	end
1295	return worktrees
1296end
1297
1298---Check if branch is checked out in any worktree
1299---@param git_dir string
1300---@param branch string
1301---@return string|nil path if checked out, nil otherwise
1302local function branch_checked_out_at(git_dir, branch)
1303	local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain")
1304	if code ~= 0 then
1305		return nil
1306	end
1307	local worktrees = parse_worktree_list(output)
1308	for _, wt in ipairs(worktrees) do
1309		if wt.branch == branch then
1310			return wt.path
1311		end
1312	end
1313	return nil
1314end
1315
1316---@param args string[]
1317local function cmd_remove(args)
1318	-- Parse arguments: <branch> [-b] [-f]
1319	local branch = nil
1320	local delete_branch = false
1321	local force = false
1322
1323	for _, a in ipairs(args) do
1324		if a == "-b" then
1325			delete_branch = true
1326		elseif a == "-f" then
1327			force = true
1328		elseif not branch then
1329			branch = a
1330		else
1331			die("unexpected argument: " .. a)
1332		end
1333	end
1334
1335	if not branch then
1336		die("usage: wt r <branch> [-b] [-f]")
1337		return
1338	end
1339
1340	local root, err = find_project_root()
1341	if not root then
1342		die(err --[[@as string]])
1343		return
1344	end
1345
1346	local git_dir = root .. "/.bare"
1347
1348	-- Find worktree by querying git for actual location (not computed from config)
1349	local wt_output, wt_code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain")
1350	if wt_code ~= 0 then
1351		die("failed to list worktrees", EXIT_SYSTEM_ERROR)
1352		return
1353	end
1354
1355	local worktrees = parse_worktree_list(wt_output)
1356	local target_path = nil
1357	for _, wt in ipairs(worktrees) do
1358		if wt.branch == branch then
1359			target_path = wt.path
1360			break
1361		end
1362	end
1363
1364	if not target_path then
1365		die("no worktree found for branch '" .. branch .. "'")
1366		return
1367	end
1368
1369	-- Error if cwd is inside the worktree
1370	if cwd_inside_path(target_path) then
1371		die("cannot remove worktree while inside it")
1372	end
1373
1374	-- Check for uncommitted changes
1375	if not force then
1376		local status_out = run_cmd("git -C " .. target_path .. " status --porcelain")
1377		if status_out ~= "" then
1378			die("worktree has uncommitted changes (use -f to force)")
1379		end
1380	end
1381
1382	-- Remove worktree
1383	local remove_cmd = "GIT_DIR=" .. git_dir .. " git worktree remove"
1384	if force then
1385		remove_cmd = remove_cmd .. " --force"
1386	end
1387	remove_cmd = remove_cmd .. " -- " .. target_path
1388
1389	local output, code = run_cmd(remove_cmd)
1390	if code ~= 0 then
1391		die("failed to remove worktree: " .. output, EXIT_SYSTEM_ERROR)
1392	end
1393
1394	-- Delete branch if requested
1395	if delete_branch then
1396		-- Check if branch is bare repo's HEAD
1397		local bare_head = get_bare_head(git_dir)
1398		if bare_head and bare_head == branch then
1399			io.stderr:write("warning: cannot delete branch '" .. branch .. "' (it's the bare repo's HEAD)\n")
1400			print("Worktree removed; branch retained")
1401			return
1402		end
1403
1404		-- Check if branch is checked out elsewhere
1405		local checked_out = branch_checked_out_at(git_dir, branch)
1406		if checked_out then
1407			die("cannot delete branch '" .. branch .. "': checked out at " .. checked_out)
1408		end
1409
1410		-- Delete branch
1411		local delete_flag = force and "-D" or "-d"
1412		local del_output, del_code = run_cmd("GIT_DIR=" .. git_dir .. " git branch " .. delete_flag .. " " .. branch)
1413		if del_code ~= 0 then
1414			io.stderr:write("warning: failed to delete branch: " .. del_output .. "\n")
1415			print("Worktree removed; branch retained")
1416			return
1417		end
1418
1419		print("Worktree and branch '" .. branch .. "' removed")
1420	else
1421		print("Worktree removed")
1422	end
1423end
1424
1425local function cmd_list()
1426	local root, err = find_project_root()
1427	if not root then
1428		die(err --[[@as string]])
1429		return
1430	end
1431
1432	local git_dir = root .. "/.bare"
1433	local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain")
1434	if code ~= 0 then
1435		die("failed to list worktrees: " .. output, EXIT_SYSTEM_ERROR)
1436	end
1437
1438	-- Parse porcelain output into worktree entries
1439	---@type {path: string, head: string, branch: string}[]
1440	local worktrees = {}
1441	local current = {}
1442
1443	for line in output:gmatch("[^\n]+") do
1444		local key, value = line:match("^(%S+)%s*(.*)$")
1445		if key == "worktree" and value then
1446			if current.path then
1447				table.insert(worktrees, current)
1448			end
1449			-- Skip .bare directory
1450			if value:match("/%.bare$") then
1451				current = {}
1452			else
1453				current = { path = value, head = "", branch = "(detached)" }
1454			end
1455		elseif key == "HEAD" and value then
1456			current.head = value:sub(1, 7)
1457		elseif key == "branch" and value then
1458			current.branch = value:gsub("^refs/heads/", "")
1459		elseif key == "bare" then
1460			-- Skip bare repo entry
1461			current = {}
1462		end
1463	end
1464	if current.path then
1465		table.insert(worktrees, current)
1466	end
1467
1468	if #worktrees == 0 then
1469		print("No worktrees found")
1470		return
1471	end
1472
1473	-- Get current working directory
1474	local cwd = get_cwd() or ""
1475
1476	-- Build table rows with status
1477	local rows = {}
1478	for _, wt in ipairs(worktrees) do
1479		local rel_path = relative_path(cwd, wt.path)
1480
1481		-- Check dirty status
1482		local status_out = run_cmd("git -C " .. wt.path .. " status --porcelain")
1483		local status = status_out == "" and "clean" or "dirty"
1484
1485		table.insert(rows, rel_path .. "," .. wt.branch .. "," .. wt.head .. "," .. status)
1486	end
1487
1488	-- Output via gum table
1489	local table_input = "Path,Branch,HEAD,Status\n" .. table.concat(rows, "\n")
1490	table_input = table_input:gsub("EOF", "eof")
1491	local table_cmd = "gum table --print <<'EOF'\n" .. table_input .. "\nEOF"
1492	local table_handle = io.popen(table_cmd, "r")
1493	if not table_handle then
1494		return
1495	end
1496	io.write(table_handle:read("*a") or "")
1497	table_handle:close()
1498end
1499
1500local function cmd_fetch()
1501	local root, err = find_project_root()
1502	if not root then
1503		die(err --[[@as string]])
1504		return
1505	end
1506
1507	local git_dir = root .. "/.bare"
1508	local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git fetch --all --prune")
1509	io.write(output)
1510	if code ~= 0 then
1511		os.exit(EXIT_SYSTEM_ERROR)
1512	end
1513end
1514
1515---List directory entries (excluding . and ..)
1516---@param path string
1517---@return string[]
1518local function list_dir(path)
1519	local entries = {}
1520	local handle = io.popen("ls -A " .. path .. " 2>/dev/null")
1521	if not handle then
1522		return entries
1523	end
1524	for line in handle:lines() do
1525		if line ~= "" then
1526			table.insert(entries, line)
1527		end
1528	end
1529	handle:close()
1530	return entries
1531end
1532
1533---Check if path is a directory
1534---@param path string
1535---@return boolean
1536local function is_dir(path)
1537	local f = io.open(path, "r")
1538	if not f then
1539		return false
1540	end
1541	f:close()
1542	return run_cmd_silent("test -d " .. path)
1543end
1544
1545---Check if path is a file (not directory)
1546---@param path string
1547---@return boolean
1548local function is_file(path)
1549	local f = io.open(path, "r")
1550	if not f then
1551		return false
1552	end
1553	f:close()
1554	return run_cmd_silent("test -f " .. path)
1555end
1556
1557---@param args string[]
1558local function cmd_init(args)
1559	-- Parse arguments
1560	local dry_run = false
1561	local skip_confirm = false
1562	for _, a in ipairs(args) do
1563		if a == "--dry-run" then
1564			dry_run = true
1565		elseif a == "-y" or a == "--yes" then
1566			skip_confirm = true
1567		else
1568			die("unexpected argument: " .. a)
1569		end
1570	end
1571
1572	local cwd = get_cwd()
1573	if not cwd then
1574		die("failed to get current directory", EXIT_SYSTEM_ERROR)
1575		return
1576	end
1577
1578	-- Case 1: Already wt-managed (.git file pointing to .bare + .bare/ exists)
1579	local git_path = cwd .. "/.git"
1580	local bare_path = cwd .. "/.bare"
1581
1582	local bare_exists = is_dir(bare_path)
1583	local git_file = io.open(git_path, "r")
1584
1585	if git_file then
1586		local content = git_file:read("*a")
1587		git_file:close()
1588
1589		-- Check if it's a file (not directory) pointing to .bare
1590		if is_file(git_path) and content and content:match("gitdir:%s*%.?/?%.bare") then
1591			if bare_exists then
1592				print("Already using wt bare structure")
1593				os.exit(EXIT_SUCCESS)
1594			end
1595		end
1596
1597		-- Check if .git is a file pointing elsewhere (inside a worktree)
1598		if is_file(git_path) and content and content:match("^gitdir:") then
1599			-- It's a worktree, not project root
1600			die("inside a worktree; run from project root or use 'wt c' to clone fresh")
1601		end
1602	end
1603
1604	-- Check for .git directory
1605	local git_dir_exists = is_dir(git_path)
1606
1607	if not git_dir_exists then
1608		-- Case 5: No .git at all, or bare repo without .git dir
1609		if bare_exists then
1610			die("found .bare/ but no .git; manually create .git file with 'gitdir: ./.bare'")
1611		end
1612		die("not a git repository (no .git found)")
1613	end
1614
1615	-- Now we have a .git directory
1616	-- Case 3: Existing worktree setup (.git/worktrees/ exists)
1617	local worktrees_path = git_path .. "/worktrees"
1618	if is_dir(worktrees_path) then
1619		local worktrees = list_dir(worktrees_path)
1620		io.stderr:write("error: repository already uses git worktrees\n")
1621		io.stderr:write("\n")
1622		io.stderr:write("Converting an existing worktree setup is complex and error-prone.\n")
1623		io.stderr:write("Recommend: clone fresh with 'wt c <url>' and recreate worktrees.\n")
1624		if #worktrees > 0 then
1625			io.stderr:write("\nExisting worktrees:\n")
1626			for _, wt in ipairs(worktrees) do
1627				io.stderr:write("  " .. wt .. "\n")
1628			end
1629		end
1630		os.exit(EXIT_USER_ERROR)
1631	end
1632
1633	-- Case 4: Normal clone (.git/ directory, no worktrees)
1634	-- Check for uncommitted changes
1635	local status_out = run_cmd("git status --porcelain")
1636	if status_out ~= "" then
1637		die("uncommitted changes; commit or stash before converting")
1638	end
1639
1640	-- Detect default branch
1641	local default_branch = detect_cloned_default_branch(git_path)
1642
1643	-- Warnings
1644	local warnings = {}
1645
1646	-- Check for submodules
1647	if is_file(cwd .. "/.gitmodules") then
1648		table.insert(warnings, "submodules detected (.gitmodules exists); verify they work after conversion")
1649	end
1650
1651	-- Check for nested .git directories (excluding the main one)
1652	local nested_git_output, _ = run_cmd("find " .. cwd .. " -mindepth 2 -name .git -type d 2>/dev/null")
1653	if nested_git_output ~= "" then
1654		table.insert(warnings, "nested .git directories found; these may cause issues")
1655	end
1656
1657	-- Find orphaned files (files in root that will be deleted)
1658	local all_entries = list_dir(cwd)
1659	local orphaned = {}
1660	for _, entry in ipairs(all_entries) do
1661		if entry ~= ".git" and entry ~= ".bare" then
1662			table.insert(orphaned, entry)
1663		end
1664	end
1665
1666	-- Load global config for path style
1667	local global_config = load_global_config()
1668	local style = global_config.branch_path_style or "nested"
1669	local separator = global_config.flat_separator
1670	local worktree_path = branch_to_path(cwd, default_branch, style, separator)
1671
1672	if dry_run then
1673		print("Dry run - planned actions:")
1674		print("")
1675		print("1. Move .git/ to .bare/")
1676		print("2. Create .git file pointing to .bare/")
1677		print("3. Create worktree: " .. worktree_path .. " (" .. default_branch .. ")")
1678		if #orphaned > 0 then
1679			print("4. Remove " .. #orphaned .. " orphaned items from root:")
1680			for _, item in ipairs(orphaned) do
1681				print("   - " .. item)
1682			end
1683		end
1684		if #warnings > 0 then
1685			print("")
1686			print("Warnings:")
1687			for _, w in ipairs(warnings) do
1688				print("" .. w)
1689			end
1690		end
1691		os.exit(EXIT_SUCCESS)
1692	end
1693
1694	-- Show warnings
1695	for _, w in ipairs(warnings) do
1696		io.stderr:write("warning: " .. w .. "\n")
1697	end
1698
1699	-- Confirm with gum (unless -y/--yes)
1700	if not skip_confirm then
1701		local confirm_msg = "Convert to wt bare structure? This will move .git to .bare"
1702		if #orphaned > 0 then
1703			confirm_msg = confirm_msg .. " and remove " .. #orphaned .. " items from root"
1704		end
1705
1706		local confirm_code = os.execute("gum confirm '" .. confirm_msg .. "'")
1707		if confirm_code ~= true then
1708			print("Aborted")
1709			os.exit(EXIT_USER_ERROR)
1710		end
1711	end
1712
1713	-- Step 1: Move .git to .bare
1714	local output, code = run_cmd("mv " .. git_path .. " " .. bare_path)
1715	if code ~= 0 then
1716		die("failed to move .git to .bare: " .. output, EXIT_SYSTEM_ERROR)
1717	end
1718
1719	-- Step 2: Write .git file
1720	local git_file_handle = io.open(git_path, "w")
1721	if not git_file_handle then
1722		-- Try to recover
1723		run_cmd("mv " .. bare_path .. " " .. git_path)
1724		die("failed to create .git file", EXIT_SYSTEM_ERROR)
1725		return
1726	end
1727	git_file_handle:write("gitdir: ./.bare\n")
1728	git_file_handle:close()
1729
1730	-- Step 3: Detach HEAD so branch can be checked out in worktree
1731	-- Point bare repo's HEAD to a placeholder so main branch can be used by worktree
1732	run_cmd("GIT_DIR=" .. bare_path .. " git symbolic-ref HEAD refs/heads/__wt_detached_placeholder__")
1733
1734	-- Step 4: Create worktree for default branch
1735	output, code = run_cmd("GIT_DIR=" .. bare_path .. " git worktree add -- " .. worktree_path .. " " .. default_branch)
1736	if code ~= 0 then
1737		die("failed to create worktree: " .. output, EXIT_SYSTEM_ERROR)
1738	end
1739
1740	-- Step 5: Remove orphaned files from root
1741	for _, item in ipairs(orphaned) do
1742		local item_path = cwd .. "/" .. item
1743		output, code = run_cmd("rm -rf " .. item_path)
1744		if code ~= 0 then
1745			io.stderr:write("warning: failed to remove " .. item .. ": " .. output .. "\n")
1746		end
1747	end
1748
1749	-- Summary
1750	print("Converted to wt bare structure")
1751	print("Bare repo:  " .. bare_path)
1752	print("Worktree:   " .. worktree_path)
1753	if #orphaned > 0 then
1754		print("Removed:    " .. #orphaned .. " items from root")
1755	end
1756end
1757
1758-- Main entry point
1759
1760local function main()
1761	local command = arg[1]
1762
1763	if not command or command == "help" or command == "--help" or command == "-h" then
1764		print_usage()
1765		os.exit(EXIT_SUCCESS)
1766	end
1767
1768	-- Collect remaining args
1769	local subargs = {}
1770	for i = 2, #arg do
1771		table.insert(subargs, arg[i])
1772	end
1773
1774	-- Check for --help on any command
1775	if subargs[1] == "--help" or subargs[1] == "-h" then
1776		show_command_help(command)
1777	end
1778
1779	if command == "c" then
1780		cmd_clone(subargs)
1781	elseif command == "n" then
1782		cmd_new(subargs)
1783	elseif command == "a" then
1784		cmd_add(subargs)
1785	elseif command == "r" then
1786		cmd_remove(subargs)
1787	elseif command == "l" then
1788		cmd_list()
1789	elseif command == "f" then
1790		cmd_fetch()
1791	elseif command == "init" then
1792		cmd_init(subargs)
1793	else
1794		die("unknown command: " .. command)
1795	end
1796end
1797
1798-- Export for testing when required as module
1799if pcall(debug.getlocal, 4, 1) then
1800	return {
1801		-- URL/project parsing
1802		extract_project_name = extract_project_name,
1803		resolve_url_template = resolve_url_template,
1804		-- Path manipulation
1805		branch_to_path = branch_to_path,
1806		split_path = split_path,
1807		relative_path = relative_path,
1808		path_inside = path_inside,
1809		-- Config loading
1810		load_global_config = load_global_config,
1811		load_project_config = load_project_config,
1812		-- Git output parsing (testable without git)
1813		parse_branch_remotes = parse_branch_remotes,
1814		parse_worktree_list = parse_worktree_list,
1815		escape_pattern = escape_pattern,
1816		-- Hook helpers
1817		summarize_hooks = summarize_hooks,
1818		load_hook_permissions = function(home_override)
1819			local home = home_override or os.getenv("HOME")
1820			if not home then
1821				return {}
1822			end
1823			local path = home .. "/.local/share/wt/hook-dirs.lua"
1824			local f = io.open(path, "r")
1825			if not f then
1826				return {}
1827			end
1828			local content = f:read("*a")
1829			f:close()
1830			local chunk = load("return " .. content, path, "t", {})
1831			if not chunk then
1832				return {}
1833			end
1834			local ok, result = pcall(chunk)
1835			if ok and type(result) == "table" then
1836				return result
1837			end
1838			return {}
1839		end,
1840		save_hook_permissions = function(perms, home_override)
1841			local home = home_override or os.getenv("HOME")
1842			if not home then
1843				return
1844			end
1845			local dir = home .. "/.local/share/wt"
1846			run_cmd_silent("mkdir -p " .. dir)
1847			local path = dir .. "/hook-dirs.lua"
1848			local f = io.open(path, "w")
1849			if not f then
1850				return
1851			end
1852			f:write("{\n")
1853			for k, v in pairs(perms) do
1854				f:write('\t["' .. k .. '"] = ' .. tostring(v) .. ",\n")
1855			end
1856			f:write("}\n")
1857			f:close()
1858		end,
1859		run_hooks = function(source, target, hooks, root, home_override)
1860			local home = home_override or os.getenv("HOME")
1861			if not home then
1862				return
1863			end
1864			local perm_path = home .. "/.local/share/wt/hook-dirs.lua"
1865			local perms = {}
1866			local pf = io.open(perm_path, "r")
1867			if pf then
1868				local content = pf:read("*a")
1869				pf:close()
1870				local chunk = load("return " .. content, perm_path, "t", {})
1871				if chunk then
1872					local ok, result = pcall(chunk)
1873					if ok and type(result) == "table" then
1874						perms = result
1875					end
1876				end
1877			end
1878			if perms[root] == false then
1879				io.stderr:write("hooks skipped (not allowed for this project)\n")
1880				return
1881			end
1882			if hooks.copy then
1883				for _, item in ipairs(hooks.copy) do
1884					local src = source .. "/" .. item
1885					local dst = target .. "/" .. item
1886					local parent = dst:match("(.+)/[^/]+$")
1887					if parent then
1888						run_cmd_silent("mkdir -p " .. parent)
1889					end
1890					run_cmd("cp -r " .. src .. " " .. dst)
1891				end
1892			end
1893			if hooks.symlink then
1894				for _, item in ipairs(hooks.symlink) do
1895					local src = source .. "/" .. item
1896					local dst = target .. "/" .. item
1897					local parent = dst:match("(.+)/[^/]+$")
1898					if parent then
1899						run_cmd_silent("mkdir -p " .. parent)
1900					end
1901					run_cmd("ln -s " .. src .. " " .. dst)
1902				end
1903			end
1904			if hooks.run then
1905				for _, cmd in ipairs(hooks.run) do
1906					run_cmd("cd " .. target .. " && " .. cmd)
1907				end
1908			end
1909		end,
1910		-- Project root detection
1911		find_project_root = function(cwd_override)
1912			local cwd = cwd_override or get_cwd()
1913			if not cwd then
1914				return nil, "failed to get current directory"
1915			end
1916			local path = cwd
1917			while path and path ~= "" and path ~= "/" do
1918				local bare_check = io.open(path .. "/.bare/HEAD", "r")
1919				if bare_check then
1920					bare_check:close()
1921					return path, nil
1922				end
1923				local git_file = io.open(path .. "/.git", "r")
1924				if git_file then
1925					local content = git_file:read("*a")
1926					git_file:close()
1927					if content and content:match("gitdir:%s*%.?/?%.bare") then
1928						return path, nil
1929					end
1930				end
1931				path = path:match("(.+)/[^/]+$")
1932			end
1933			return nil, "not in a wt-managed repository"
1934		end,
1935		detect_source_worktree = function(root, cwd_override)
1936			local cwd = cwd_override or get_cwd()
1937			if not cwd then
1938				return nil
1939			end
1940			if cwd == root then
1941				return nil
1942			end
1943			local git_file = io.open(cwd .. "/.git", "r")
1944			if git_file then
1945				git_file:close()
1946				return cwd
1947			end
1948			local path = cwd
1949			while path and path ~= "" and path ~= "/" and path ~= root do
1950				local gf = io.open(path .. "/.git", "r")
1951				if gf then
1952					gf:close()
1953					return path
1954				end
1955				path = path:match("(.+)/[^/]+$")
1956			end
1957			return nil
1958		end,
1959		-- Command execution (for integration tests)
1960		run_cmd = run_cmd,
1961		run_cmd_silent = run_cmd_silent,
1962		-- Exit codes
1963		EXIT_SUCCESS = EXIT_SUCCESS,
1964		EXIT_USER_ERROR = EXIT_USER_ERROR,
1965		EXIT_SYSTEM_ERROR = EXIT_SYSTEM_ERROR,
1966	}
1967end
1968
1969main()