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