wt

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