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