main.lua

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