wt

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