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	---@type string|nil
 964	local url
 965	---@type string[]
 966	local remote_flags = {}
 967	local own = false
 968
 969	local i = 1
 970	-- Flags can appear anywhere in the argument list
 971	local positional = {}
 972	while i <= #args do
 973		local a = args[i]
 974		if a == "--remote" then
 975			if not args[i + 1] then
 976				shell.die("--remote requires a name")
 977			end
 978			table.insert(remote_flags, args[i + 1])
 979			i = i + 1
 980		elseif a == "--own" then
 981			own = true
 982		elseif a:match("^%-") then
 983			shell.die("unknown flag: " .. a)
 984		else
 985			table.insert(positional, a)
 986		end
 987		i = i + 1
 988	end
 989
 990	if #positional == 0 then
 991		shell.die("usage: wt c <url> [--remote name]... [--own]")
 992		return
 993	elseif #positional > 1 then
 994		shell.die("unexpected argument: " .. positional[2])
 995	end
 996	url = positional[1]
 997
 998	-- Extract project name from URL
 999	local project_name = config.extract_project_name(url)
1000	if not project_name then
1001		shell.die("could not extract project name from URL: " .. url)
1002		return
1003	end
1004
1005	-- Check if project directory already exists
1006	local cwd = shell.get_cwd()
1007	if not cwd then
1008		shell.die("failed to get current directory", exit.EXIT_SYSTEM_ERROR)
1009	end
1010	---@cast cwd string
1011	local project_path = cwd .. "/" .. project_name
1012	local check = io.open(project_path, "r")
1013	if check then
1014		check:close()
1015		shell.die("directory already exists: " .. project_path)
1016	end
1017
1018	-- Clone bare repo
1019	local bare_path = project_path .. "/.bare"
1020	local output, code = shell.run_cmd("git clone --bare " .. url .. " " .. bare_path)
1021	if code ~= 0 then
1022		shell.die("failed to clone: " .. output, exit.EXIT_SYSTEM_ERROR)
1023	end
1024
1025	-- Write .git file pointing to .bare
1026	local git_file_handle = io.open(project_path .. "/.git", "w")
1027	if not git_file_handle then
1028		shell.die("failed to create .git file", exit.EXIT_SYSTEM_ERROR)
1029		return
1030	end
1031	git_file_handle:write("gitdir: ./.bare\n")
1032	git_file_handle:close()
1033
1034	-- Detect default branch
1035	local git_dir = bare_path
1036	local default_branch = git.detect_cloned_default_branch(git_dir)
1037
1038	-- Load global config
1039	local global_config = config.load_global_config()
1040
1041	-- Determine which remotes to use
1042	---@type string[]
1043	local selected_remotes = {}
1044
1045	if #remote_flags > 0 then
1046		selected_remotes = remote_flags
1047	elseif global_config.default_remotes then
1048		if type(global_config.default_remotes) == "table" then
1049			selected_remotes = global_config.default_remotes --[[@as string[] ]]
1050		---@diagnostic disable-next-line: unnecessary-if
1051		elseif global_config.default_remotes == "prompt" then
1052			if global_config.remotes then
1053				local keys = {}
1054				for k in pairs(global_config.remotes) do
1055					table.insert(keys, k)
1056				end
1057				table.sort(keys)
1058				if #keys > 0 then
1059					local input = table.concat(keys, "\n")
1060					local choose_type = own and "" or " --no-limit"
1061					local cmd = "echo '" .. input .. "' | gum choose" .. choose_type
1062					output, code = shell.run_cmd(cmd)
1063					if code == 0 and output ~= "" then
1064						for line in output:gmatch("[^\n]+") do
1065							table.insert(selected_remotes, line)
1066						end
1067					end
1068				end
1069			end
1070		end
1071	elseif global_config.remotes then
1072		local keys = {}
1073		for k in pairs(global_config.remotes) do
1074			table.insert(keys, k)
1075		end
1076		table.sort(keys)
1077		if #keys > 0 then
1078			local input = table.concat(keys, "\n")
1079			local choose_type = own and "" or " --no-limit"
1080			local cmd = "echo '" .. input .. "' | gum choose" .. choose_type
1081			output, code = shell.run_cmd(cmd)
1082			if code == 0 and output ~= "" then
1083				for line in output:gmatch("[^\n]+") do
1084					table.insert(selected_remotes, line)
1085				end
1086			end
1087		end
1088	end
1089
1090	-- Track configured remotes for summary
1091	---@type string[]
1092	local configured_remotes = {}
1093
1094	if own then
1095		-- User's own project: origin is their canonical remote
1096		if #selected_remotes > 0 then
1097			local first_remote = selected_remotes[1]
1098			-- Rename origin to first remote
1099			output, code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git remote rename origin " .. first_remote)
1100			if code ~= 0 then
1101				io.stderr:write("warning: failed to rename origin to " .. first_remote .. ": " .. output .. "\n")
1102			else
1103				-- Configure fetch refspec
1104				shell.run_cmd(
1105					"GIT_DIR="
1106						.. git_dir
1107						.. " git config remote."
1108						.. first_remote
1109						.. ".fetch '+refs/heads/*:refs/remotes/"
1110						.. first_remote
1111						.. "/*'"
1112				)
1113				table.insert(configured_remotes, first_remote)
1114			end
1115
1116			-- Add additional remotes and push to them
1117			local remotes = global_config.remotes
1118			for j = 2, #selected_remotes do
1119				local remote_name = selected_remotes[j]
1120				if remotes then
1121					local template = remotes[remote_name]
1122					if template then
1123						local remote_url = config.resolve_url_template(template, project_name)
1124						output, code = shell.run_cmd(
1125							"GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. remote_url
1126						)
1127						if code ~= 0 then
1128							io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
1129						else
1130							shell.run_cmd(
1131								"GIT_DIR="
1132									.. git_dir
1133									.. " git config remote."
1134									.. remote_name
1135									.. ".fetch '+refs/heads/*:refs/remotes/"
1136									.. remote_name
1137									.. "/*'"
1138							)
1139							-- Push to additional remotes
1140							output, code = shell.run_cmd(
1141								"GIT_DIR=" .. git_dir .. " git push " .. remote_name .. " " .. default_branch
1142							)
1143							if code ~= 0 then
1144								io.stderr:write("warning: failed to push to " .. remote_name .. ": " .. output .. "\n")
1145							end
1146							table.insert(configured_remotes, remote_name)
1147						end
1148					else
1149						io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
1150					end
1151				else
1152					io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
1153				end
1154			end
1155		else
1156			-- No remotes selected, keep origin as-is
1157			shell.run_cmd(
1158				"GIT_DIR=" .. git_dir .. " git config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'"
1159			)
1160			table.insert(configured_remotes, "origin")
1161		end
1162	else
1163		-- Contributing to someone else's project
1164		-- Rename origin to upstream
1165		output, code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git remote rename origin upstream")
1166		if code ~= 0 then
1167			io.stderr:write("warning: failed to rename origin to upstream: " .. output .. "\n")
1168		else
1169			shell.run_cmd(
1170				"GIT_DIR=" .. git_dir .. " git config remote.upstream.fetch '+refs/heads/*:refs/remotes/upstream/*'"
1171			)
1172			table.insert(configured_remotes, "upstream")
1173		end
1174
1175		-- Add user's remotes and push to each
1176		local remotes = global_config.remotes
1177		for _, remote_name in ipairs(selected_remotes) do
1178			if remotes then
1179				local template = remotes[remote_name]
1180				if template then
1181					local remote_url = config.resolve_url_template(template, project_name)
1182					output, code =
1183						shell.run_cmd("GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. remote_url)
1184					if code ~= 0 then
1185						io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
1186					else
1187						shell.run_cmd(
1188							"GIT_DIR="
1189								.. git_dir
1190								.. " git config remote."
1191								.. remote_name
1192								.. ".fetch '+refs/heads/*:refs/remotes/"
1193								.. remote_name
1194								.. "/*'"
1195						)
1196						-- Push to this remote
1197						output, code =
1198							shell.run_cmd("GIT_DIR=" .. git_dir .. " git push " .. remote_name .. " " .. default_branch)
1199						if code ~= 0 then
1200							io.stderr:write("warning: failed to push to " .. remote_name .. ": " .. output .. "\n")
1201						end
1202						table.insert(configured_remotes, remote_name)
1203					end
1204				else
1205					io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
1206				end
1207			else
1208				io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
1209			end
1210		end
1211	end
1212
1213	-- Fetch all remotes
1214	shell.run_cmd("GIT_DIR=" .. git_dir .. " git fetch --all")
1215
1216	-- Load config for path style
1217	local style = global_config.branch_path_style or "nested"
1218	local separator = global_config.flat_separator
1219	local worktree_path = path_mod.branch_to_path(project_path, default_branch, style, separator)
1220
1221	-- Create initial worktree
1222	output, code =
1223		shell.run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -- " .. worktree_path .. " " .. default_branch)
1224	if code ~= 0 then
1225		shell.die("failed to create worktree: " .. output, exit.EXIT_SYSTEM_ERROR)
1226	end
1227
1228	-- Print summary
1229	print("Created project: " .. project_path)
1230	print("Default branch:  " .. default_branch)
1231	print("Worktree:        " .. worktree_path)
1232	if #configured_remotes > 0 then
1233		print("Remotes:         " .. table.concat(configured_remotes, ", "))
1234	end
1235end
1236
1237return M
1238]=]
1239
1240_EMBEDDED_MODULES["wt.cmd.new"] = [=[-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
1241--
1242-- SPDX-License-Identifier: GPL-3.0-or-later
1243
1244local exit = require("wt.exit")
1245local shell = require("wt.shell")
1246local git = require("wt.git")
1247local path_mod = require("wt.path")
1248local config = require("wt.config")
1249
1250---@class wt.cmd.new
1251local M = {}
1252
1253---Create a new project with bare repo structure
1254---@param args string[]
1255function M.cmd_new(args)
1256	-- Parse arguments: <project-name> [--remote name]...
1257	---@type string|nil
1258	local project_name
1259	---@type string[]
1260	local remote_flags = {}
1261
1262	local i = 1
1263	-- Flags can appear anywhere in the argument list
1264	local positional = {}
1265	while i <= #args do
1266		local a = args[i]
1267		if a == "--remote" then
1268			if not args[i + 1] then
1269				shell.die("--remote requires a name")
1270			end
1271			table.insert(remote_flags, args[i + 1])
1272			i = i + 1
1273		elseif a:match("^%-") then
1274			shell.die("unknown flag: " .. a)
1275		else
1276			table.insert(positional, a)
1277		end
1278		i = i + 1
1279	end
1280
1281	if #positional == 0 then
1282		shell.die("usage: wt n <project-name> [--remote name]...")
1283		return
1284	elseif #positional > 1 then
1285		shell.die("unexpected argument: " .. positional[2])
1286	end
1287	project_name = positional[1]
1288
1289	-- Check if project directory already exists
1290	local cwd = shell.get_cwd()
1291	if not cwd then
1292		shell.die("failed to get current directory", exit.EXIT_SYSTEM_ERROR)
1293	end
1294	---@cast cwd string
1295	local project_path = cwd .. "/" .. project_name
1296	local check = io.open(project_path, "r")
1297	if check then
1298		check:close()
1299		shell.die("directory already exists: " .. project_path)
1300	end
1301
1302	-- Load global config
1303	local global_config = config.load_global_config()
1304
1305	-- Determine which remotes to use
1306	---@type string[]
1307	local selected_remotes = {}
1308
1309	if #remote_flags > 0 then
1310		-- Use explicitly provided remotes
1311		selected_remotes = remote_flags
1312	elseif global_config.default_remotes then
1313		if type(global_config.default_remotes) == "table" then
1314			selected_remotes = global_config.default_remotes --[[@as string[] ]]
1315		---@diagnostic disable-next-line: unnecessary-if
1316		elseif global_config.default_remotes == "prompt" then
1317			-- Prompt with gum choose
1318			if global_config.remotes then
1319				local keys = {}
1320				for k in pairs(global_config.remotes) do
1321					table.insert(keys, k)
1322				end
1323				table.sort(keys)
1324				if #keys > 0 then
1325					local input = table.concat(keys, "\n")
1326					local cmd = "echo '" .. input .. "' | gum choose --no-limit"
1327					local output, code = shell.run_cmd(cmd)
1328					if code == 0 and output ~= "" then
1329						for line in output:gmatch("[^\n]+") do
1330							table.insert(selected_remotes, line)
1331						end
1332					end
1333				end
1334			end
1335		end
1336	elseif global_config.remotes then
1337		-- No default_remotes configured, prompt if remotes exist
1338		local keys = {}
1339		for k in pairs(global_config.remotes) do
1340			table.insert(keys, k)
1341		end
1342		table.sort(keys)
1343		if #keys > 0 then
1344			local input = table.concat(keys, "\n")
1345			local cmd = "echo '" .. input .. "' | gum choose --no-limit"
1346			local output, code = shell.run_cmd(cmd)
1347			if code == 0 and output ~= "" then
1348				for line in output:gmatch("[^\n]+") do
1349					table.insert(selected_remotes, line)
1350				end
1351			end
1352		end
1353	end
1354
1355	-- Create project structure
1356	local bare_path = project_path .. "/.bare"
1357	local output, code = shell.run_cmd("mkdir -p " .. bare_path)
1358	if code ~= 0 then
1359		shell.die("failed to create directory: " .. output, exit.EXIT_SYSTEM_ERROR)
1360	end
1361
1362	output, code = shell.run_cmd("git init --bare " .. bare_path)
1363	if code ~= 0 then
1364		shell.die("failed to init bare repo: " .. output, exit.EXIT_SYSTEM_ERROR)
1365	end
1366
1367	-- Write .git file pointing to .bare
1368	local git_file_handle = io.open(project_path .. "/.git", "w")
1369	if not git_file_handle then
1370		shell.die("failed to create .git file", exit.EXIT_SYSTEM_ERROR)
1371		return
1372	end
1373	git_file_handle:write("gitdir: ./.bare\n")
1374	git_file_handle:close()
1375
1376	-- Add remotes
1377	local git_dir = bare_path
1378	local remotes = global_config.remotes
1379	for _, remote_name in ipairs(selected_remotes) do
1380		if remotes then
1381			local template = remotes[remote_name]
1382			if template then
1383				local url = config.resolve_url_template(template, project_name)
1384				output, code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. url)
1385				if code ~= 0 then
1386					io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
1387				else
1388					-- Configure fetch refspec for the remote
1389					shell.run_cmd(
1390						"GIT_DIR="
1391							.. git_dir
1392							.. " git config remote."
1393							.. remote_name
1394							.. ".fetch '+refs/heads/*:refs/remotes/"
1395							.. remote_name
1396							.. "/*'"
1397					)
1398				end
1399			else
1400				io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
1401			end
1402		else
1403			io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
1404		end
1405	end
1406
1407	-- Detect default branch
1408	local default_branch = git.get_default_branch()
1409
1410	-- Load config for path style
1411	local style = global_config.branch_path_style or "nested"
1412	local separator = global_config.flat_separator
1413	local worktree_path = path_mod.branch_to_path(project_path, default_branch, style, separator)
1414
1415	-- Create orphan worktree
1416	output, code = shell.run_cmd(
1417		"GIT_DIR=" .. git_dir .. " git worktree add --orphan -b " .. default_branch .. " -- " .. worktree_path
1418	)
1419	if code ~= 0 then
1420		shell.die("failed to create worktree: " .. output, exit.EXIT_SYSTEM_ERROR)
1421	end
1422
1423	-- Print summary
1424	print("Created project: " .. project_path)
1425	print("Default branch:  " .. default_branch)
1426	print("Worktree:        " .. worktree_path)
1427	if #selected_remotes > 0 then
1428		print("Remotes:         " .. table.concat(selected_remotes, ", "))
1429	end
1430end
1431
1432return M
1433]=]
1434
1435_EMBEDDED_MODULES["wt.cmd.add"] = [=[-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
1436--
1437-- SPDX-License-Identifier: GPL-3.0-or-later
1438
1439local exit = require("wt.exit")
1440local shell = require("wt.shell")
1441local git = require("wt.git")
1442local path_mod = require("wt.path")
1443local config = require("wt.config")
1444local hooks = require("wt.hooks")
1445
1446---@class wt.cmd.add
1447local M = {}
1448
1449---Add a worktree for an existing or new branch
1450---@param args string[]
1451function M.cmd_add(args)
1452	-- Parse arguments: <branch> [-b [<start-point>]]
1453	---@type string|nil
1454	local branch = nil
1455	local create_branch = false
1456	---@type string|nil
1457	local start_point = nil
1458
1459	-- Collect flags and positional args (flags can appear anywhere)
1460	local positional = {}
1461	for _, a in ipairs(args) do
1462		if a == "-b" then
1463			create_branch = true
1464		elseif a:match("^%-") then
1465			shell.die("unknown flag: " .. a)
1466		else
1467			table.insert(positional, a)
1468		end
1469	end
1470
1471	-- Assign positional args: <branch> [<start-point>]
1472	if #positional == 0 then
1473		shell.die("usage: wt a <branch> [-b [<start-point>]]")
1474		return
1475	elseif #positional == 1 then
1476		branch = positional[1]
1477	elseif #positional == 2 then
1478		branch = positional[1]
1479		start_point = positional[2]
1480	else
1481		shell.die("unexpected argument: " .. positional[3])
1482	end
1483
1484	local root, err = git.find_project_root()
1485	if not root then
1486		shell.die(err --[[@as string]])
1487		return
1488	end
1489
1490	local git_dir = root .. "/.bare"
1491	local source_worktree = git.detect_source_worktree(root)
1492
1493	-- Load config for path style
1494	local global_config = config.load_global_config()
1495	local style = global_config.branch_path_style or "nested"
1496	local separator = global_config.flat_separator or "_"
1497
1498	local target_path = path_mod.branch_to_path(root, branch, style, separator)
1499
1500	-- Check if worktree already exists for this branch (idempotent behavior)
1501	local existing_wt = git.find_worktree_by_branch(git_dir, branch)
1502	if existing_wt then
1503		-- Validate the worktree path actually exists (not stale metadata)
1504		local wt_check = io.open(existing_wt.path .. "/.git", "r")
1505		if not wt_check then
1506			shell.die(
1507				"worktree for '"
1508					.. branch
1509					.. "' is registered but missing at "
1510					.. existing_wt.path
1511					.. "\nhint: run `git worktree prune` to clean up stale entries"
1512			)
1513		end
1514		wt_check:close()
1515
1516		local project_config = config.load_project_config(root)
1517		io.stderr:write("worktree already exists at " .. existing_wt.path .. "\n")
1518		-- Warn if path differs from current config
1519		if existing_wt.path ~= target_path then
1520			io.stderr:write("note: current config would place it at " .. target_path .. "\n")
1521		end
1522		-- If running from root and hooks exist, offer to run them now
1523		if not source_worktree and project_config.hooks then
1524			io.stderr:write("hint: run `wt a` from inside a worktree to apply hooks from .wt.lua\n")
1525		end
1526		print(existing_wt.path)
1527		return
1528	end
1529
1530	-- Check if target path has a .git but for a different branch (conflict)
1531	local check = io.open(target_path .. "/.git", "r")
1532	if check then
1533		check:close()
1534		shell.die("directory already exists at " .. target_path .. " but is not a worktree for '" .. branch .. "'")
1535	end
1536
1537	local output, code
1538	if create_branch then
1539		-- Default start-point to source worktree's branch if inside one
1540		if not start_point and source_worktree then
1541			start_point = git.get_worktree_branch(git_dir, source_worktree)
1542		end
1543
1544		-- Check if start_point resolves to a valid commit (catches orphan branch case)
1545		if start_point and not git.ref_has_commits(git_dir, start_point) then
1546			shell.die("'" .. start_point .. "' has no commits yet; make an initial commit first")
1547		end
1548
1549		-- Create new branch with worktree
1550		if start_point then
1551			output, code = shell.run_cmd(
1552				"GIT_DIR="
1553					.. git_dir
1554					.. " git worktree add -b "
1555					.. branch
1556					.. " -- "
1557					.. target_path
1558					.. " "
1559					.. start_point
1560			)
1561		else
1562			output, code =
1563				shell.run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -b " .. branch .. " -- " .. target_path)
1564		end
1565	else
1566		-- Check if branch exists locally or on remotes
1567		local exists_local = git.branch_exists_local(git_dir, branch)
1568		local remotes = git.find_branch_remotes(git_dir, branch)
1569
1570		if not exists_local and #remotes == 0 then
1571			shell.die("branch '" .. branch .. "' not found locally or on any remote (use -b to create)")
1572		end
1573
1574		if #remotes > 1 then
1575			local hint = "use `-b " .. remotes[1] .. "/" .. branch .. "` to specify which remote to track"
1576			shell.die(
1577				"branch '" .. branch .. "' exists on multiple remotes: " .. table.concat(remotes, ", ") .. "\n" .. hint
1578			)
1579		end
1580
1581		output, code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -- " .. target_path .. " " .. branch)
1582	end
1583
1584	if code ~= 0 then
1585		shell.die("failed to add worktree: " .. output, exit.EXIT_SYSTEM_ERROR)
1586	end
1587
1588	-- Run hooks if we have a source worktree
1589	local project_config = config.load_project_config(root)
1590	if source_worktree then
1591		if project_config.hooks then
1592			hooks.run_hooks(source_worktree, target_path, project_config.hooks, root)
1593		end
1594	elseif project_config.hooks then
1595		io.stderr:write("hint: hooks skipped; run `wt a` from inside a worktree to apply hooks from .wt.lua\n")
1596	end
1597
1598	print(target_path)
1599end
1600
1601return M
1602]=]
1603
1604_EMBEDDED_MODULES["wt.cmd.remove"] = [=[-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
1605--
1606-- SPDX-License-Identifier: GPL-3.0-or-later
1607
1608local exit = require("wt.exit")
1609local shell = require("wt.shell")
1610local git = require("wt.git")
1611local path_mod = require("wt.path")
1612
1613---@class wt.cmd.remove
1614local M = {}
1615
1616---Check if cwd is inside (or equal to) a given path
1617---@param target string
1618---@return boolean
1619local function cwd_inside_path(target)
1620	local cwd = shell.get_cwd()
1621	if not cwd then
1622		return false
1623	end
1624	return path_mod.path_inside(cwd, target)
1625end
1626
1627---Get the bare repo's HEAD branch
1628---@param git_dir string
1629---@return string|nil branch name, nil on error
1630local function get_bare_head(git_dir)
1631	local output, code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git symbolic-ref --short HEAD")
1632	if code ~= 0 then
1633		return nil
1634	end
1635	return (output:gsub("%s+$", ""))
1636end
1637
1638---Remove empty parent directories between target and project root
1639---@param target_path string the removed worktree path
1640---@param project_root string the project root (stop here)
1641local function cleanup_empty_parents(target_path, project_root)
1642	project_root = project_root:gsub("/$", "")
1643	local parent = path_mod.parent_dir(target_path)
1644	while parent and parent ~= project_root and path_mod.path_inside(parent, project_root) do
1645		shell.run_cmd_silent("rmdir " .. shell.quote(parent))
1646		parent = path_mod.parent_dir(parent)
1647	end
1648end
1649
1650---Remove a worktree and optionally its branch
1651---@param args string[]
1652function M.cmd_remove(args)
1653	local branch = nil
1654	local delete_branch = false
1655	local force = false
1656
1657	for _, a in ipairs(args) do
1658		if a == "-b" then
1659			delete_branch = true
1660		elseif a == "-f" then
1661			force = true
1662		elseif not branch then
1663			branch = a
1664		else
1665			shell.die("unexpected argument: " .. a)
1666		end
1667	end
1668
1669	if not branch then
1670		shell.die("usage: wt r <branch> [-b] [-f]")
1671		return
1672	end
1673
1674	local root, err = git.find_project_root()
1675	if not root then
1676		shell.die(err --[[@as string]])
1677		return
1678	end
1679
1680	local git_dir = root .. "/.bare"
1681
1682	local wt_output, wt_code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain")
1683	if wt_code ~= 0 then
1684		shell.die("failed to list worktrees", exit.EXIT_SYSTEM_ERROR)
1685		return
1686	end
1687
1688	local worktrees = git.parse_worktree_list(wt_output)
1689	local target_path = nil
1690	for _, wt in ipairs(worktrees) do
1691		if wt.branch == branch then
1692			target_path = wt.path
1693			break
1694		end
1695	end
1696
1697	if not target_path then
1698		shell.die("no worktree found for branch '" .. branch .. "'")
1699		return
1700	end
1701
1702	if cwd_inside_path(target_path) then
1703		shell.die("cannot remove worktree while inside it")
1704	end
1705
1706	if not force then
1707		local status_out = shell.run_cmd("git -C " .. target_path .. " status --porcelain")
1708		if status_out ~= "" then
1709			shell.die("worktree has uncommitted changes (use -f to force)")
1710		end
1711	end
1712
1713	local remove_cmd = "GIT_DIR=" .. git_dir .. " git worktree remove"
1714	if force then
1715		remove_cmd = remove_cmd .. " --force"
1716	end
1717	remove_cmd = remove_cmd .. " -- " .. target_path
1718
1719	local output, code = shell.run_cmd(remove_cmd)
1720	if code ~= 0 then
1721		shell.die("failed to remove worktree: " .. output, exit.EXIT_SYSTEM_ERROR)
1722	end
1723
1724	cleanup_empty_parents(target_path, root)
1725
1726	if delete_branch then
1727		local bare_head = get_bare_head(git_dir)
1728		if bare_head and bare_head == branch then
1729			io.stderr:write("warning: cannot delete branch '" .. branch .. "' (it's the bare repo's HEAD)\n")
1730			print("Worktree removed; branch retained")
1731			return
1732		end
1733
1734		local checked_out = git.branch_checked_out_at(git_dir, branch)
1735		if checked_out then
1736			shell.die("cannot delete branch '" .. branch .. "': checked out at " .. checked_out)
1737		end
1738
1739		local delete_flag = force and "-D" or "-d"
1740		local del_output, del_code =
1741			shell.run_cmd("GIT_DIR=" .. git_dir .. " git branch " .. delete_flag .. " " .. branch)
1742		if del_code ~= 0 then
1743			io.stderr:write("warning: failed to delete branch: " .. del_output .. "\n")
1744			print("Worktree removed; branch retained")
1745			return
1746		end
1747
1748		print("Worktree and branch '" .. branch .. "' removed")
1749	else
1750		print("Worktree removed")
1751	end
1752end
1753
1754return M
1755]=]
1756
1757_EMBEDDED_MODULES["wt.cmd.list"] = [=[-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
1758--
1759-- SPDX-License-Identifier: GPL-3.0-or-later
1760
1761local exit = require("wt.exit")
1762local shell = require("wt.shell")
1763local git = require("wt.git")
1764local path_mod = require("wt.path")
1765
1766---@class wt.cmd.list
1767local M = {}
1768
1769---List all worktrees with status
1770function M.cmd_list()
1771	local root, err = git.find_project_root()
1772	if not root then
1773		shell.die(err --[[@as string]])
1774		return
1775	end
1776
1777	local git_dir = root .. "/.bare"
1778	local output, code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain")
1779	if code ~= 0 then
1780		shell.die("failed to list worktrees: " .. output, exit.EXIT_SYSTEM_ERROR)
1781	end
1782
1783	---@type {path: string, head: string, branch: string}[]
1784	local worktrees = {}
1785	local current = {}
1786
1787	for line in output:gmatch("[^\n]+") do
1788		local key, value = line:match("^(%S+)%s*(.*)$")
1789		if key == "worktree" and value then
1790			if current.path then
1791				table.insert(worktrees, current)
1792			end
1793			if value:match("/%.bare$") then
1794				current = {}
1795			else
1796				current = { path = value, head = "", branch = "(detached)" }
1797			end
1798		elseif key == "HEAD" and value then
1799			current.head = value:sub(1, 7)
1800		elseif key == "branch" and value then
1801			current.branch = value:gsub("^refs/heads/", "")
1802		elseif key == "bare" then
1803			current = {}
1804		end
1805	end
1806	if current.path then
1807		table.insert(worktrees, current)
1808	end
1809
1810	if #worktrees == 0 then
1811		print("No worktrees found")
1812		return
1813	end
1814
1815	local cwd = shell.get_cwd() or ""
1816
1817	local rows = {}
1818	for _, wt in ipairs(worktrees) do
1819		local rel_path = path_mod.relative_path(cwd, wt.path)
1820
1821		local status_out = shell.run_cmd("git -C " .. wt.path .. " status --porcelain")
1822		local status = status_out == "" and "clean" or "dirty"
1823
1824		table.insert(rows, rel_path .. "," .. wt.branch .. "," .. wt.head .. "," .. status)
1825	end
1826
1827	local table_input = "Path,Branch,HEAD,Status\n" .. table.concat(rows, "\n")
1828	table_input = table_input:gsub("EOF", "eof")
1829	local table_cmd = "gum table --print <<'EOF'\n" .. table_input .. "\nEOF"
1830	local table_handle = io.popen(table_cmd, "r")
1831	if not table_handle then
1832		return
1833	end
1834	io.write(table_handle:read("*a") or "")
1835	table_handle:close()
1836end
1837
1838return M
1839]=]
1840
1841_EMBEDDED_MODULES["wt.cmd.fetch"] = [=[-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
1842--
1843-- SPDX-License-Identifier: GPL-3.0-or-later
1844
1845local exit = require("wt.exit")
1846local shell = require("wt.shell")
1847local git = require("wt.git")
1848
1849---@class wt.cmd.fetch
1850local M = {}
1851
1852---Get list of configured remotes
1853---@param git_dir string
1854---@return string[] remotes
1855local function get_remotes(git_dir)
1856	local output, code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git remote")
1857	if code ~= 0 then
1858		return {}
1859	end
1860	local remotes = {}
1861	for line in output:gmatch("[^\n]+") do
1862		local trimmed = line:match("^%s*(.-)%s*$")
1863		if trimmed and trimmed ~= "" then
1864			table.insert(remotes, trimmed)
1865		end
1866	end
1867	return remotes
1868end
1869
1870---Fetch all remotes with pruning, tolerating partial failures
1871function M.cmd_fetch()
1872	local root, err = git.find_project_root()
1873	if not root then
1874		shell.die(err --[[@as string]])
1875		return
1876	end
1877
1878	local git_dir = root .. "/.bare"
1879	local remotes = get_remotes(git_dir)
1880
1881	if #remotes == 0 then
1882		shell.die("no remotes configured", exit.EXIT_USER_ERROR)
1883		return
1884	end
1885
1886	local succeeded = 0
1887	local failures = {}
1888
1889	for _, remote in ipairs(remotes) do
1890		local output, code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git fetch --prune " .. shell.quote(remote))
1891		if code == 0 then
1892			succeeded = succeeded + 1
1893			io.write(output)
1894		else
1895			table.insert(failures, { remote = remote, output = output })
1896		end
1897	end
1898
1899	for _, f in ipairs(failures) do
1900		io.stderr:write("warning: failed to fetch " .. f.remote .. "\n")
1901		if f.output and f.output ~= "" then
1902			io.stderr:write(f.output)
1903		end
1904	end
1905
1906	if succeeded == 0 then
1907		io.stderr:write("error: all remotes failed to fetch\n")
1908		os.exit(exit.EXIT_SYSTEM_ERROR)
1909	end
1910end
1911
1912return M
1913]=]
1914
1915_EMBEDDED_MODULES["wt.cmd.init"] = [[-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
1916--
1917-- SPDX-License-Identifier: GPL-3.0-or-later
1918
1919local exit = require("wt.exit")
1920local shell = require("wt.shell")
1921local git = require("wt.git")
1922local path_mod = require("wt.path")
1923local config = require("wt.config")
1924
1925---@class wt.cmd.init
1926local M = {}
1927
1928---List directory entries (excluding . and ..)
1929---@param path string
1930---@return string[]
1931local function list_dir(path)
1932	local entries = {}
1933	local handle = io.popen("ls -A " .. path .. " 2>/dev/null")
1934	if not handle then
1935		return entries
1936	end
1937	for line in handle:lines() do
1938		if line ~= "" then
1939			table.insert(entries, line)
1940		end
1941	end
1942	handle:close()
1943	return entries
1944end
1945
1946---Check if path is a directory
1947---@param path string
1948---@return boolean
1949local function is_dir(path)
1950	local f = io.open(path, "r")
1951	if not f then
1952		return false
1953	end
1954	f:close()
1955	return shell.run_cmd_silent("test -d " .. path)
1956end
1957
1958---Check if path is a file (not directory)
1959---@param path string
1960---@return boolean
1961local function is_file(path)
1962	local f = io.open(path, "r")
1963	if not f then
1964		return false
1965	end
1966	f:close()
1967	return shell.run_cmd_silent("test -f " .. path)
1968end
1969
1970---Convert existing git repository to wt bare structure
1971---@param args string[]
1972function M.cmd_init(args)
1973	-- Parse arguments
1974	local dry_run = false
1975	local skip_confirm = false
1976	for _, a in ipairs(args) do
1977		if a == "--dry-run" then
1978			dry_run = true
1979		elseif a == "-y" or a == "--yes" then
1980			skip_confirm = true
1981		else
1982			shell.die("unexpected argument: " .. a)
1983		end
1984	end
1985
1986	local cwd = shell.get_cwd()
1987	if not cwd then
1988		shell.die("failed to get current directory", exit.EXIT_SYSTEM_ERROR)
1989		return
1990	end
1991
1992	-- Case 1: Already wt-managed (.git file pointing to .bare + .bare/ exists)
1993	local git_path = cwd .. "/.git"
1994	local bare_path = cwd .. "/.bare"
1995
1996	local bare_exists = is_dir(bare_path)
1997	local git_file = io.open(git_path, "r")
1998
1999	if git_file then
2000		local content = git_file:read("*a")
2001		git_file:close()
2002
2003		-- Check if it's a file (not directory) pointing to .bare
2004		if is_file(git_path) and content and content:match("gitdir:%s*%.?/?%.bare") then
2005			if bare_exists then
2006				print("Already using wt bare structure")
2007				os.exit(exit.EXIT_SUCCESS)
2008			end
2009		end
2010
2011		-- Check if .git is a file pointing elsewhere (inside a worktree)
2012		if is_file(git_path) and content and content:match("^gitdir:") then
2013			-- It's a worktree, not project root
2014			shell.die("inside a worktree; run from project root or use 'wt c' to clone fresh")
2015		end
2016	end
2017
2018	-- Check for .git directory
2019	local git_dir_exists = is_dir(git_path)
2020
2021	if not git_dir_exists then
2022		-- Case 5: No .git at all, or bare repo without .git dir
2023		if bare_exists then
2024			shell.die("found .bare/ but no .git; manually create .git file with 'gitdir: ./.bare'")
2025		end
2026		shell.die("not a git repository (no .git found)")
2027	end
2028
2029	-- Now we have a .git directory
2030	-- Case 3: Existing worktree setup (.git/worktrees/ exists)
2031	local worktrees_path = git_path .. "/worktrees"
2032	if is_dir(worktrees_path) then
2033		local worktrees = list_dir(worktrees_path)
2034		io.stderr:write("error: repository already uses git worktrees\n")
2035		io.stderr:write("\n")
2036		io.stderr:write("Converting an existing worktree setup is complex and error-prone.\n")
2037		io.stderr:write("Recommend: clone fresh with 'wt c <url>' and recreate worktrees.\n")
2038		if #worktrees > 0 then
2039			io.stderr:write("\nExisting worktrees:\n")
2040			for _, wt in ipairs(worktrees) do
2041				io.stderr:write("  " .. wt .. "\n")
2042			end
2043		end
2044		os.exit(exit.EXIT_USER_ERROR)
2045	end
2046
2047	-- Case 4: Normal clone (.git/ directory, no worktrees)
2048	-- Check for uncommitted changes
2049	local status_out = shell.run_cmd("git status --porcelain")
2050	if status_out ~= "" then
2051		io.stderr:write("error: uncommitted changes\n")
2052		io.stderr:write("hint: commit, or stash and restore after:\n")
2053		io.stderr:write("  git stash -u && wt init && cd <worktree> && git stash pop\n")
2054		os.exit(exit.EXIT_USER_ERROR)
2055	end
2056
2057	-- Detect default branch
2058	local default_branch = git.detect_cloned_default_branch(git_path)
2059
2060	-- Warnings
2061	local warnings = {}
2062
2063	-- Check for submodules
2064	if is_file(cwd .. "/.gitmodules") then
2065		table.insert(warnings, "submodules detected (.gitmodules exists); verify they work after conversion")
2066	end
2067
2068	-- Check for nested .git directories (excluding the main one)
2069	local nested_git_output, _ = shell.run_cmd("find " .. cwd .. " -mindepth 2 -name .git -type d 2>/dev/null")
2070	if nested_git_output ~= "" then
2071		table.insert(warnings, "nested .git directories found; these may cause issues")
2072	end
2073
2074	-- Find orphaned files (files in root that will be deleted)
2075	local all_entries = list_dir(cwd)
2076	local orphaned = {}
2077	for _, entry in ipairs(all_entries) do
2078		if entry ~= ".git" and entry ~= ".bare" then
2079			table.insert(orphaned, entry)
2080		end
2081	end
2082
2083	-- Load global config for path style
2084	local global_config = config.load_global_config()
2085	local style = global_config.branch_path_style or "nested"
2086	local separator = global_config.flat_separator
2087	local worktree_path = path_mod.branch_to_path(cwd, default_branch, style, separator)
2088
2089	if dry_run then
2090		print("Dry run - planned actions:")
2091		print("")
2092		print("1. Move .git/ to .bare/")
2093		print("2. Create .git file pointing to .bare/")
2094		print("3. Create worktree: " .. worktree_path .. " (" .. default_branch .. ")")
2095		if #orphaned > 0 then
2096			print("4. Remove " .. #orphaned .. " orphaned items from root:")
2097			for _, item in ipairs(orphaned) do
2098				print("   - " .. item)
2099			end
2100		end
2101		if #warnings > 0 then
2102			print("")
2103			print("Warnings:")
2104			for _, w in ipairs(warnings) do
2105				print("" .. w)
2106			end
2107		end
2108		os.exit(exit.EXIT_SUCCESS)
2109	end
2110
2111	-- Show warnings
2112	for _, w in ipairs(warnings) do
2113		io.stderr:write("warning: " .. w .. "\n")
2114	end
2115
2116	-- Prominent warning about file deletion
2117	if #orphaned > 0 then
2118		io.stderr:write("\n")
2119		io.stderr:write("WARNING: The following " .. #orphaned .. " items will be DELETED from project root:\n")
2120		for _, item in ipairs(orphaned) do
2121			io.stderr:write("  - " .. item .. "\n")
2122		end
2123		io.stderr:write("\n")
2124		io.stderr:write("These files are preserved in the new worktree at:\n")
2125		io.stderr:write("  " .. worktree_path .. "\n")
2126		io.stderr:write("\n")
2127	end
2128
2129	-- Confirm with gum (unless -y/--yes)
2130	if not skip_confirm then
2131		local confirm_code = os.execute("gum confirm 'Convert to wt bare structure?'")
2132		if confirm_code ~= true then
2133			print("Aborted")
2134			os.exit(exit.EXIT_USER_ERROR)
2135		end
2136	end
2137
2138	-- Step 1: Move .git to .bare
2139	local output, code = shell.run_cmd("mv " .. git_path .. " " .. bare_path)
2140	if code ~= 0 then
2141		shell.die("failed to move .git to .bare: " .. output, exit.EXIT_SYSTEM_ERROR)
2142	end
2143
2144	-- Step 2: Write .git file
2145	local git_file_handle = io.open(git_path, "w")
2146	if not git_file_handle then
2147		-- Try to recover
2148		shell.run_cmd("mv " .. bare_path .. " " .. git_path)
2149		shell.die("failed to create .git file", exit.EXIT_SYSTEM_ERROR)
2150		return
2151	end
2152	git_file_handle:write("gitdir: ./.bare\n")
2153	git_file_handle:close()
2154
2155	-- Step 3: Detach HEAD so branch can be checked out in worktree
2156	-- Point bare repo's HEAD to a placeholder so main branch can be used by worktree
2157	shell.run_cmd("GIT_DIR=" .. bare_path .. " git symbolic-ref HEAD refs/heads/__wt_detached_placeholder__")
2158
2159	-- Step 4: Create worktree for default branch
2160	output, code =
2161		shell.run_cmd("GIT_DIR=" .. bare_path .. " git worktree add -- " .. worktree_path .. " " .. default_branch)
2162	if code ~= 0 then
2163		shell.die("failed to create worktree: " .. output, exit.EXIT_SYSTEM_ERROR)
2164	end
2165
2166	-- Step 5: Remove orphaned files from root
2167	for _, item in ipairs(orphaned) do
2168		local item_path = cwd .. "/" .. item
2169		output, code = shell.run_cmd("rm -rf " .. item_path)
2170		if code ~= 0 then
2171			io.stderr:write("warning: failed to remove " .. item .. ": " .. output .. "\n")
2172		end
2173	end
2174
2175	-- Summary
2176	print("Converted to wt bare structure")
2177	print("Bare repo:  " .. bare_path)
2178	print("Worktree:   " .. worktree_path)
2179	if #orphaned > 0 then
2180		print("Removed:    " .. #orphaned .. " items from root")
2181	end
2182end
2183
2184return M
2185]]
2186
2187
2188if _VERSION < "Lua 5.2" then
2189	io.stderr:write("error: wt requires Lua 5.2 or later\n")
2190	os.exit(1)
2191end
2192
2193local exit = require("wt.exit")
2194local shell = require("wt.shell")
2195local path_mod = require("wt.path")
2196local git_mod = require("wt.git")
2197local config_mod = require("wt.config")
2198local hooks_mod = require("wt.hooks")
2199local help_mod = require("wt.help")
2200local fetch_mod = require("wt.cmd.fetch")
2201local list_mod = require("wt.cmd.list")
2202local remove_mod = require("wt.cmd.remove")
2203local add_mod = require("wt.cmd.add")
2204local new_mod = require("wt.cmd.new")
2205local clone_mod = require("wt.cmd.clone")
2206local init_mod = require("wt.cmd.init")
2207
2208-- Main entry point
2209
2210local function main()
2211	local command = arg[1]
2212
2213	if not command or command == "help" or command == "--help" or command == "-h" then
2214		help_mod.print_usage()
2215		os.exit(exit.EXIT_SUCCESS)
2216	end
2217
2218	---@cast command string
2219
2220	-- Collect remaining args
2221	local subargs = {}
2222	for i = 2, #arg do
2223		table.insert(subargs, arg[i])
2224	end
2225
2226	-- Check for --help on any command
2227	if subargs[1] == "--help" or subargs[1] == "-h" then
2228		help_mod.show_command_help(command)
2229	end
2230
2231	if command == "c" then
2232		clone_mod.cmd_clone(subargs)
2233	elseif command == "n" then
2234		new_mod.cmd_new(subargs)
2235	elseif command == "a" then
2236		add_mod.cmd_add(subargs)
2237	elseif command == "r" then
2238		remove_mod.cmd_remove(subargs)
2239	elseif command == "l" then
2240		list_mod.cmd_list()
2241	elseif command == "f" then
2242		fetch_mod.cmd_fetch()
2243	elseif command == "init" then
2244		init_mod.cmd_init(subargs)
2245	else
2246		shell.die("unknown command: " .. command)
2247	end
2248end
2249
2250-- Export for testing when required as module
2251if pcall(debug.getlocal, 4, 1) then
2252	---@diagnostic disable: duplicate-set-field
2253	return {
2254		-- URL/project parsing
2255		extract_project_name = config_mod.extract_project_name,
2256		resolve_url_template = config_mod.resolve_url_template,
2257		-- Path manipulation
2258		branch_to_path = path_mod.branch_to_path,
2259		split_path = path_mod.split_path,
2260		relative_path = path_mod.relative_path,
2261		path_inside = path_mod.path_inside,
2262		-- Config loading
2263		load_global_config = config_mod.load_global_config,
2264		load_project_config = config_mod.load_project_config,
2265		-- Git output parsing (testable without git)
2266		parse_branch_remotes = git_mod.parse_branch_remotes,
2267		parse_worktree_list = git_mod.parse_worktree_list,
2268		escape_pattern = path_mod.escape_pattern,
2269		-- Hook helpers (re-exported from wt.hooks)
2270		summarize_hooks = hooks_mod.summarize_hooks,
2271		load_hook_permissions = hooks_mod.load_hook_permissions,
2272		save_hook_permissions = hooks_mod.save_hook_permissions,
2273		run_hooks = hooks_mod.run_hooks,
2274		-- Project root detection (re-exported from wt.git)
2275		find_project_root = git_mod.find_project_root,
2276		detect_source_worktree = git_mod.detect_source_worktree,
2277		-- Command execution (for integration tests)
2278		run_cmd = shell.run_cmd,
2279		run_cmd_silent = shell.run_cmd_silent,
2280		-- Exit codes (re-exported from wt.exit)
2281		EXIT_SUCCESS = exit.EXIT_SUCCESS,
2282		EXIT_USER_ERROR = exit.EXIT_USER_ERROR,
2283		EXIT_SYSTEM_ERROR = exit.EXIT_SYSTEM_ERROR,
2284	}
2285end
2286
2287main()