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
  41local config_mod = require("wt.config")
  42local resolve_url_template = config_mod.resolve_url_template
  43local extract_project_name = config_mod.extract_project_name
  44local load_global_config = config_mod.load_global_config
  45local load_project_config = config_mod.load_project_config
  46
  47local hooks_mod = require("wt.hooks")
  48local load_hook_permissions = hooks_mod.load_hook_permissions
  49local save_hook_permissions = hooks_mod.save_hook_permissions
  50local summarize_hooks = hooks_mod.summarize_hooks
  51local run_hooks = hooks_mod.run_hooks
  52
  53local help_mod = require("wt.help")
  54local print_usage = help_mod.print_usage
  55local show_command_help = help_mod.show_command_help
  56
  57local fetch_mod = require("wt.cmd.fetch")
  58local cmd_fetch = fetch_mod.cmd_fetch
  59
  60---@param args string[]
  61local function cmd_clone(args)
  62	-- Parse arguments: <url> [--remote name]... [--own]
  63	local url = nil
  64	---@type string[]
  65	local remote_flags = {}
  66	local own = false
  67
  68	local i = 1
  69	while i <= #args do
  70		local a = args[i]
  71		if a == "--remote" then
  72			if not args[i + 1] then
  73				die("--remote requires a name")
  74			end
  75			table.insert(remote_flags, args[i + 1])
  76			i = i + 1
  77		elseif a == "--own" then
  78			own = true
  79		elseif not url then
  80			url = a
  81		else
  82			die("unexpected argument: " .. a)
  83		end
  84		i = i + 1
  85	end
  86
  87	if not url then
  88		die("usage: wt c <url> [--remote name]... [--own]")
  89		return
  90	end
  91
  92	-- Extract project name from URL
  93	local project_name = extract_project_name(url)
  94	if not project_name then
  95		die("could not extract project name from URL: " .. url)
  96		return
  97	end
  98
  99	-- Check if project directory already exists
 100	local cwd = get_cwd()
 101	if not cwd then
 102		die("failed to get current directory", EXIT_SYSTEM_ERROR)
 103	end
 104	local project_path = cwd .. "/" .. project_name
 105	local check = io.open(project_path, "r")
 106	if check then
 107		check:close()
 108		die("directory already exists: " .. project_path)
 109	end
 110
 111	-- Clone bare repo
 112	local bare_path = project_path .. "/.bare"
 113	local output, code = run_cmd("git clone --bare " .. url .. " " .. bare_path)
 114	if code ~= 0 then
 115		die("failed to clone: " .. output, EXIT_SYSTEM_ERROR)
 116	end
 117
 118	-- Write .git file pointing to .bare
 119	local git_file_handle = io.open(project_path .. "/.git", "w")
 120	if not git_file_handle then
 121		die("failed to create .git file", EXIT_SYSTEM_ERROR)
 122		return
 123	end
 124	git_file_handle:write("gitdir: ./.bare\n")
 125	git_file_handle:close()
 126
 127	-- Detect default branch
 128	local git_dir = bare_path
 129	local default_branch = detect_cloned_default_branch(git_dir)
 130
 131	-- Load global config
 132	local global_config = load_global_config()
 133
 134	-- Determine which remotes to use
 135	---@type string[]
 136	local selected_remotes = {}
 137
 138	if #remote_flags > 0 then
 139		selected_remotes = remote_flags
 140	elseif global_config.default_remotes then
 141		if type(global_config.default_remotes) == "table" then
 142			selected_remotes = global_config.default_remotes
 143		elseif global_config.default_remotes == "prompt" then
 144			if global_config.remotes then
 145				local keys = {}
 146				for k in pairs(global_config.remotes) do
 147					table.insert(keys, k)
 148				end
 149				table.sort(keys)
 150				if #keys > 0 then
 151					local input = table.concat(keys, "\n")
 152					local choose_type = own and "" or " --no-limit"
 153					local cmd = "echo '" .. input .. "' | gum choose" .. choose_type
 154					output, code = run_cmd(cmd)
 155					if code == 0 and output ~= "" then
 156						for line in output:gmatch("[^\n]+") do
 157							table.insert(selected_remotes, line)
 158						end
 159					end
 160				end
 161			end
 162		end
 163	elseif global_config.remotes then
 164		local keys = {}
 165		for k in pairs(global_config.remotes) do
 166			table.insert(keys, k)
 167		end
 168		table.sort(keys)
 169		if #keys > 0 then
 170			local input = table.concat(keys, "\n")
 171			local choose_type = own and "" or " --no-limit"
 172			local cmd = "echo '" .. input .. "' | gum choose" .. choose_type
 173			output, code = run_cmd(cmd)
 174			if code == 0 and output ~= "" then
 175				for line in output:gmatch("[^\n]+") do
 176					table.insert(selected_remotes, line)
 177				end
 178			end
 179		end
 180	end
 181
 182	-- Track configured remotes for summary
 183	---@type string[]
 184	local configured_remotes = {}
 185
 186	if own then
 187		-- User's own project: origin is their canonical remote
 188		if #selected_remotes > 0 then
 189			local first_remote = selected_remotes[1]
 190			-- Rename origin to first remote
 191			output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote rename origin " .. first_remote)
 192			if code ~= 0 then
 193				io.stderr:write("warning: failed to rename origin to " .. first_remote .. ": " .. output .. "\n")
 194			else
 195				-- Configure fetch refspec
 196				run_cmd(
 197					"GIT_DIR="
 198						.. git_dir
 199						.. " git config remote."
 200						.. first_remote
 201						.. ".fetch '+refs/heads/*:refs/remotes/"
 202						.. first_remote
 203						.. "/*'"
 204				)
 205				table.insert(configured_remotes, first_remote)
 206			end
 207
 208			-- Add additional remotes and push to them
 209			for j = 2, #selected_remotes do
 210				local remote_name = selected_remotes[j]
 211				local template = global_config.remotes and global_config.remotes[remote_name]
 212				if template then
 213					local remote_url = resolve_url_template(template, project_name)
 214					output, code =
 215						run_cmd("GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. remote_url)
 216					if code ~= 0 then
 217						io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
 218					else
 219						run_cmd(
 220							"GIT_DIR="
 221								.. git_dir
 222								.. " git config remote."
 223								.. remote_name
 224								.. ".fetch '+refs/heads/*:refs/remotes/"
 225								.. remote_name
 226								.. "/*'"
 227						)
 228						-- Push to additional remotes
 229						output, code =
 230							run_cmd("GIT_DIR=" .. git_dir .. " git push " .. remote_name .. " " .. default_branch)
 231						if code ~= 0 then
 232							io.stderr:write("warning: failed to push to " .. remote_name .. ": " .. output .. "\n")
 233						end
 234						table.insert(configured_remotes, remote_name)
 235					end
 236				else
 237					io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
 238				end
 239			end
 240		else
 241			-- No remotes selected, keep origin as-is
 242			run_cmd("GIT_DIR=" .. git_dir .. " git config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'")
 243			table.insert(configured_remotes, "origin")
 244		end
 245	else
 246		-- Contributing to someone else's project
 247		-- Rename origin to upstream
 248		output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote rename origin upstream")
 249		if code ~= 0 then
 250			io.stderr:write("warning: failed to rename origin to upstream: " .. output .. "\n")
 251		else
 252			run_cmd(
 253				"GIT_DIR=" .. git_dir .. " git config remote.upstream.fetch '+refs/heads/*:refs/remotes/upstream/*'"
 254			)
 255			table.insert(configured_remotes, "upstream")
 256		end
 257
 258		-- Add user's remotes and push to each
 259		for _, remote_name in ipairs(selected_remotes) do
 260			local template = global_config.remotes and global_config.remotes[remote_name]
 261			if template then
 262				local remote_url = resolve_url_template(template, project_name)
 263				output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. remote_url)
 264				if code ~= 0 then
 265					io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
 266				else
 267					run_cmd(
 268						"GIT_DIR="
 269							.. git_dir
 270							.. " git config remote."
 271							.. remote_name
 272							.. ".fetch '+refs/heads/*:refs/remotes/"
 273							.. remote_name
 274							.. "/*'"
 275					)
 276					-- Push to this remote
 277					output, code =
 278						run_cmd("GIT_DIR=" .. git_dir .. " git push " .. remote_name .. " " .. default_branch)
 279					if code ~= 0 then
 280						io.stderr:write("warning: failed to push to " .. remote_name .. ": " .. output .. "\n")
 281					end
 282					table.insert(configured_remotes, remote_name)
 283				end
 284			else
 285				io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
 286			end
 287		end
 288	end
 289
 290	-- Fetch all remotes
 291	run_cmd("GIT_DIR=" .. git_dir .. " git fetch --all")
 292
 293	-- Load config for path style
 294	local style = global_config.branch_path_style or "nested"
 295	local separator = global_config.flat_separator
 296	local worktree_path = branch_to_path(project_path, default_branch, style, separator)
 297
 298	-- Create initial worktree
 299	output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -- " .. worktree_path .. " " .. default_branch)
 300	if code ~= 0 then
 301		die("failed to create worktree: " .. output, EXIT_SYSTEM_ERROR)
 302	end
 303
 304	-- Print summary
 305	print("Created project: " .. project_path)
 306	print("Default branch:  " .. default_branch)
 307	print("Worktree:        " .. worktree_path)
 308	if #configured_remotes > 0 then
 309		print("Remotes:         " .. table.concat(configured_remotes, ", "))
 310	end
 311end
 312
 313---@param args string[]
 314local function cmd_new(args)
 315	-- Parse arguments: <project-name> [--remote name]...
 316	local project_name = nil
 317	---@type string[]
 318	local remote_flags = {}
 319
 320	local i = 1
 321	while i <= #args do
 322		local a = args[i]
 323		if a == "--remote" then
 324			if not args[i + 1] then
 325				die("--remote requires a name")
 326			end
 327			table.insert(remote_flags, args[i + 1])
 328			i = i + 1
 329		elseif not project_name then
 330			project_name = a
 331		else
 332			die("unexpected argument: " .. a)
 333		end
 334		i = i + 1
 335	end
 336
 337	if not project_name then
 338		die("usage: wt n <project-name> [--remote name]...")
 339		return
 340	end
 341
 342	-- Check if project directory already exists
 343	local cwd = get_cwd()
 344	if not cwd then
 345		die("failed to get current directory", EXIT_SYSTEM_ERROR)
 346	end
 347	local project_path = cwd .. "/" .. project_name
 348	local check = io.open(project_path, "r")
 349	if check then
 350		check:close()
 351		die("directory already exists: " .. project_path)
 352	end
 353
 354	-- Load global config
 355	local global_config = load_global_config()
 356
 357	-- Determine which remotes to use
 358	---@type string[]
 359	local selected_remotes = {}
 360
 361	if #remote_flags > 0 then
 362		-- Use explicitly provided remotes
 363		selected_remotes = remote_flags
 364	elseif global_config.default_remotes then
 365		if type(global_config.default_remotes) == "table" then
 366			selected_remotes = global_config.default_remotes
 367		elseif global_config.default_remotes == "prompt" then
 368			-- Prompt with gum choose
 369			if global_config.remotes then
 370				local keys = {}
 371				for k in pairs(global_config.remotes) do
 372					table.insert(keys, k)
 373				end
 374				table.sort(keys)
 375				if #keys > 0 then
 376					local input = table.concat(keys, "\n")
 377					local cmd = "echo '" .. input .. "' | gum choose --no-limit"
 378					local output, code = run_cmd(cmd)
 379					if code == 0 and output ~= "" then
 380						for line in output:gmatch("[^\n]+") do
 381							table.insert(selected_remotes, line)
 382						end
 383					end
 384				end
 385			end
 386		end
 387	elseif global_config.remotes then
 388		-- No default_remotes configured, prompt if remotes exist
 389		local keys = {}
 390		for k in pairs(global_config.remotes) do
 391			table.insert(keys, k)
 392		end
 393		table.sort(keys)
 394		if #keys > 0 then
 395			local input = table.concat(keys, "\n")
 396			local cmd = "echo '" .. input .. "' | gum choose --no-limit"
 397			local output, code = run_cmd(cmd)
 398			if code == 0 and output ~= "" then
 399				for line in output:gmatch("[^\n]+") do
 400					table.insert(selected_remotes, line)
 401				end
 402			end
 403		end
 404	end
 405
 406	-- Create project structure
 407	local bare_path = project_path .. "/.bare"
 408	local output, code = run_cmd("mkdir -p " .. bare_path)
 409	if code ~= 0 then
 410		die("failed to create directory: " .. output, EXIT_SYSTEM_ERROR)
 411	end
 412
 413	output, code = run_cmd("git init --bare " .. bare_path)
 414	if code ~= 0 then
 415		die("failed to init bare repo: " .. output, EXIT_SYSTEM_ERROR)
 416	end
 417
 418	-- Write .git file pointing to .bare
 419	local git_file_handle = io.open(project_path .. "/.git", "w")
 420	if not git_file_handle then
 421		die("failed to create .git file", EXIT_SYSTEM_ERROR)
 422		return
 423	end
 424	git_file_handle:write("gitdir: ./.bare\n")
 425	git_file_handle:close()
 426
 427	-- Add remotes
 428	local git_dir = bare_path
 429	for _, remote_name in ipairs(selected_remotes) do
 430		local template = global_config.remotes and global_config.remotes[remote_name]
 431		if template then
 432			local url = resolve_url_template(template, project_name)
 433			output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. url)
 434			if code ~= 0 then
 435				io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
 436			else
 437				-- Configure fetch refspec for the remote
 438				run_cmd(
 439					"GIT_DIR="
 440						.. git_dir
 441						.. " git config remote."
 442						.. remote_name
 443						.. ".fetch '+refs/heads/*:refs/remotes/"
 444						.. remote_name
 445						.. "/*'"
 446				)
 447			end
 448		else
 449			io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
 450		end
 451	end
 452
 453	-- Detect default branch
 454	local default_branch = get_default_branch()
 455
 456	-- Load config for path style
 457	local style = global_config.branch_path_style or "nested"
 458	local separator = global_config.flat_separator
 459	local worktree_path = branch_to_path(project_path, default_branch, style, separator)
 460
 461	-- Create orphan worktree
 462	output, code =
 463		run_cmd("GIT_DIR=" .. git_dir .. " git worktree add --orphan -b " .. default_branch .. " -- " .. worktree_path)
 464	if code ~= 0 then
 465		die("failed to create worktree: " .. output, EXIT_SYSTEM_ERROR)
 466	end
 467
 468	-- Print summary
 469	print("Created project: " .. project_path)
 470	print("Default branch:  " .. default_branch)
 471	print("Worktree:        " .. worktree_path)
 472	if #selected_remotes > 0 then
 473		print("Remotes:         " .. table.concat(selected_remotes, ", "))
 474	end
 475end
 476
 477---@param args string[]
 478local function cmd_add(args)
 479	-- Parse arguments: <branch> [-b [<start-point>]]
 480	---@type string|nil
 481	local branch = nil
 482	local create_branch = false
 483	---@type string|nil
 484	local start_point = nil
 485
 486	local i = 1
 487	while i <= #args do
 488		local a = args[i]
 489		if a == "-b" then
 490			create_branch = true
 491			-- Check if next arg is start-point (not another flag)
 492			if args[i + 1] and not args[i + 1]:match("^%-") then
 493				start_point = args[i + 1]
 494				i = i + 1
 495			end
 496		elseif not branch then
 497			branch = a
 498		else
 499			die("unexpected argument: " .. a)
 500		end
 501		i = i + 1
 502	end
 503
 504	if not branch then
 505		die("usage: wt a <branch> [-b [<start-point>]]")
 506		return
 507	end
 508
 509	local root, err = find_project_root()
 510	if not root then
 511		die(err --[[@as string]])
 512		return
 513	end
 514
 515	local git_dir = root .. "/.bare"
 516	local source_worktree = detect_source_worktree(root)
 517
 518	-- Load config for path style
 519	local global_config = load_global_config()
 520	local style = global_config.branch_path_style or "nested"
 521	local separator = global_config.flat_separator or "_"
 522
 523	local target_path = branch_to_path(root, branch, style, separator)
 524
 525	-- Check if target already exists
 526	local check = io.open(target_path .. "/.git", "r")
 527	if check then
 528		check:close()
 529		die("worktree already exists at " .. target_path)
 530	end
 531
 532	local output, code
 533	if create_branch then
 534		-- Create new branch with worktree
 535		if start_point then
 536			output, code = run_cmd(
 537				"GIT_DIR="
 538					.. git_dir
 539					.. " git worktree add -b "
 540					.. branch
 541					.. " -- "
 542					.. target_path
 543					.. " "
 544					.. start_point
 545			)
 546		else
 547			output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -b " .. branch .. " -- " .. target_path)
 548		end
 549	else
 550		-- Check if branch exists locally or on remotes
 551		local exists_local = branch_exists_local(git_dir, branch)
 552		local remotes = find_branch_remotes(git_dir, branch)
 553
 554		if not exists_local and #remotes == 0 then
 555			die("branch '" .. branch .. "' not found locally or on any remote (use -b to create)")
 556		end
 557
 558		if #remotes > 1 then
 559			die("branch '" .. branch .. "' exists on multiple remotes: " .. table.concat(remotes, ", "))
 560		end
 561
 562		output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -- " .. target_path .. " " .. branch)
 563	end
 564
 565	if code ~= 0 then
 566		die("failed to add worktree: " .. output, EXIT_SYSTEM_ERROR)
 567	end
 568
 569	-- Run hooks if we have a source worktree
 570	local project_config = load_project_config(root)
 571	if source_worktree then
 572		if project_config.hooks then
 573			run_hooks(source_worktree, target_path, project_config.hooks, root)
 574		end
 575	elseif project_config.hooks then
 576		io.stderr:write("warning: hooks skipped (run from inside a worktree to apply hooks)\n")
 577	end
 578
 579	print(target_path)
 580end
 581
 582---Check if cwd is inside (or equal to) a given path
 583---@param target string
 584---@return boolean
 585local function cwd_inside_path(target)
 586	local cwd = get_cwd()
 587	if not cwd then
 588		return false
 589	end
 590	return path_inside(cwd, target)
 591end
 592
 593---Get the bare repo's HEAD branch
 594---@param git_dir string
 595---@return string|nil branch name, nil on error
 596local function get_bare_head(git_dir)
 597	local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git symbolic-ref --short HEAD")
 598	if code ~= 0 then
 599		return nil
 600	end
 601	return (output:gsub("%s+$", ""))
 602end
 603
 604---@param args string[]
 605local function cmd_remove(args)
 606	-- Parse arguments: <branch> [-b] [-f]
 607	local branch = nil
 608	local delete_branch = false
 609	local force = false
 610
 611	for _, a in ipairs(args) do
 612		if a == "-b" then
 613			delete_branch = true
 614		elseif a == "-f" then
 615			force = true
 616		elseif not branch then
 617			branch = a
 618		else
 619			die("unexpected argument: " .. a)
 620		end
 621	end
 622
 623	if not branch then
 624		die("usage: wt r <branch> [-b] [-f]")
 625		return
 626	end
 627
 628	local root, err = find_project_root()
 629	if not root then
 630		die(err --[[@as string]])
 631		return
 632	end
 633
 634	local git_dir = root .. "/.bare"
 635
 636	-- Find worktree by querying git for actual location (not computed from config)
 637	local wt_output, wt_code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain")
 638	if wt_code ~= 0 then
 639		die("failed to list worktrees", EXIT_SYSTEM_ERROR)
 640		return
 641	end
 642
 643	local worktrees = parse_worktree_list(wt_output)
 644	local target_path = nil
 645	for _, wt in ipairs(worktrees) do
 646		if wt.branch == branch then
 647			target_path = wt.path
 648			break
 649		end
 650	end
 651
 652	if not target_path then
 653		die("no worktree found for branch '" .. branch .. "'")
 654		return
 655	end
 656
 657	-- Error if cwd is inside the worktree
 658	if cwd_inside_path(target_path) then
 659		die("cannot remove worktree while inside it")
 660	end
 661
 662	-- Check for uncommitted changes
 663	if not force then
 664		local status_out = run_cmd("git -C " .. target_path .. " status --porcelain")
 665		if status_out ~= "" then
 666			die("worktree has uncommitted changes (use -f to force)")
 667		end
 668	end
 669
 670	-- Remove worktree
 671	local remove_cmd = "GIT_DIR=" .. git_dir .. " git worktree remove"
 672	if force then
 673		remove_cmd = remove_cmd .. " --force"
 674	end
 675	remove_cmd = remove_cmd .. " -- " .. target_path
 676
 677	local output, code = run_cmd(remove_cmd)
 678	if code ~= 0 then
 679		die("failed to remove worktree: " .. output, EXIT_SYSTEM_ERROR)
 680	end
 681
 682	-- Delete branch if requested
 683	if delete_branch then
 684		-- Check if branch is bare repo's HEAD
 685		local bare_head = get_bare_head(git_dir)
 686		if bare_head and bare_head == branch then
 687			io.stderr:write("warning: cannot delete branch '" .. branch .. "' (it's the bare repo's HEAD)\n")
 688			print("Worktree removed; branch retained")
 689			return
 690		end
 691
 692		-- Check if branch is checked out elsewhere
 693		local checked_out = branch_checked_out_at(git_dir, branch)
 694		if checked_out then
 695			die("cannot delete branch '" .. branch .. "': checked out at " .. checked_out)
 696		end
 697
 698		-- Delete branch
 699		local delete_flag = force and "-D" or "-d"
 700		local del_output, del_code = run_cmd("GIT_DIR=" .. git_dir .. " git branch " .. delete_flag .. " " .. branch)
 701		if del_code ~= 0 then
 702			io.stderr:write("warning: failed to delete branch: " .. del_output .. "\n")
 703			print("Worktree removed; branch retained")
 704			return
 705		end
 706
 707		print("Worktree and branch '" .. branch .. "' removed")
 708	else
 709		print("Worktree removed")
 710	end
 711end
 712
 713local function cmd_list()
 714	local root, err = find_project_root()
 715	if not root then
 716		die(err --[[@as string]])
 717		return
 718	end
 719
 720	local git_dir = root .. "/.bare"
 721	local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain")
 722	if code ~= 0 then
 723		die("failed to list worktrees: " .. output, EXIT_SYSTEM_ERROR)
 724	end
 725
 726	-- Parse porcelain output into worktree entries
 727	---@type {path: string, head: string, branch: string}[]
 728	local worktrees = {}
 729	local current = {}
 730
 731	for line in output:gmatch("[^\n]+") do
 732		local key, value = line:match("^(%S+)%s*(.*)$")
 733		if key == "worktree" and value then
 734			if current.path then
 735				table.insert(worktrees, current)
 736			end
 737			-- Skip .bare directory
 738			if value:match("/%.bare$") then
 739				current = {}
 740			else
 741				current = { path = value, head = "", branch = "(detached)" }
 742			end
 743		elseif key == "HEAD" and value then
 744			current.head = value:sub(1, 7)
 745		elseif key == "branch" and value then
 746			current.branch = value:gsub("^refs/heads/", "")
 747		elseif key == "bare" then
 748			-- Skip bare repo entry
 749			current = {}
 750		end
 751	end
 752	if current.path then
 753		table.insert(worktrees, current)
 754	end
 755
 756	if #worktrees == 0 then
 757		print("No worktrees found")
 758		return
 759	end
 760
 761	-- Get current working directory
 762	local cwd = get_cwd() or ""
 763
 764	-- Build table rows with status
 765	local rows = {}
 766	for _, wt in ipairs(worktrees) do
 767		local rel_path = relative_path(cwd, wt.path)
 768
 769		-- Check dirty status
 770		local status_out = run_cmd("git -C " .. wt.path .. " status --porcelain")
 771		local status = status_out == "" and "clean" or "dirty"
 772
 773		table.insert(rows, rel_path .. "," .. wt.branch .. "," .. wt.head .. "," .. status)
 774	end
 775
 776	-- Output via gum table
 777	local table_input = "Path,Branch,HEAD,Status\n" .. table.concat(rows, "\n")
 778	table_input = table_input:gsub("EOF", "eof")
 779	local table_cmd = "gum table --print <<'EOF'\n" .. table_input .. "\nEOF"
 780	local table_handle = io.popen(table_cmd, "r")
 781	if not table_handle then
 782		return
 783	end
 784	io.write(table_handle:read("*a") or "")
 785	table_handle:close()
 786end
 787
 788---List directory entries (excluding . and ..)
 789---@param path string
 790---@return string[]
 791local function list_dir(path)
 792	local entries = {}
 793	local handle = io.popen("ls -A " .. path .. " 2>/dev/null")
 794	if not handle then
 795		return entries
 796	end
 797	for line in handle:lines() do
 798		if line ~= "" then
 799			table.insert(entries, line)
 800		end
 801	end
 802	handle:close()
 803	return entries
 804end
 805
 806---Check if path is a directory
 807---@param path string
 808---@return boolean
 809local function is_dir(path)
 810	local f = io.open(path, "r")
 811	if not f then
 812		return false
 813	end
 814	f:close()
 815	return run_cmd_silent("test -d " .. path)
 816end
 817
 818---Check if path is a file (not directory)
 819---@param path string
 820---@return boolean
 821local function is_file(path)
 822	local f = io.open(path, "r")
 823	if not f then
 824		return false
 825	end
 826	f:close()
 827	return run_cmd_silent("test -f " .. path)
 828end
 829
 830---@param args string[]
 831local function cmd_init(args)
 832	-- Parse arguments
 833	local dry_run = false
 834	local skip_confirm = false
 835	for _, a in ipairs(args) do
 836		if a == "--dry-run" then
 837			dry_run = true
 838		elseif a == "-y" or a == "--yes" then
 839			skip_confirm = true
 840		else
 841			die("unexpected argument: " .. a)
 842		end
 843	end
 844
 845	local cwd = get_cwd()
 846	if not cwd then
 847		die("failed to get current directory", EXIT_SYSTEM_ERROR)
 848		return
 849	end
 850
 851	-- Case 1: Already wt-managed (.git file pointing to .bare + .bare/ exists)
 852	local git_path = cwd .. "/.git"
 853	local bare_path = cwd .. "/.bare"
 854
 855	local bare_exists = is_dir(bare_path)
 856	local git_file = io.open(git_path, "r")
 857
 858	if git_file then
 859		local content = git_file:read("*a")
 860		git_file:close()
 861
 862		-- Check if it's a file (not directory) pointing to .bare
 863		if is_file(git_path) and content and content:match("gitdir:%s*%.?/?%.bare") then
 864			if bare_exists then
 865				print("Already using wt bare structure")
 866				os.exit(EXIT_SUCCESS)
 867			end
 868		end
 869
 870		-- Check if .git is a file pointing elsewhere (inside a worktree)
 871		if is_file(git_path) and content and content:match("^gitdir:") then
 872			-- It's a worktree, not project root
 873			die("inside a worktree; run from project root or use 'wt c' to clone fresh")
 874		end
 875	end
 876
 877	-- Check for .git directory
 878	local git_dir_exists = is_dir(git_path)
 879
 880	if not git_dir_exists then
 881		-- Case 5: No .git at all, or bare repo without .git dir
 882		if bare_exists then
 883			die("found .bare/ but no .git; manually create .git file with 'gitdir: ./.bare'")
 884		end
 885		die("not a git repository (no .git found)")
 886	end
 887
 888	-- Now we have a .git directory
 889	-- Case 3: Existing worktree setup (.git/worktrees/ exists)
 890	local worktrees_path = git_path .. "/worktrees"
 891	if is_dir(worktrees_path) then
 892		local worktrees = list_dir(worktrees_path)
 893		io.stderr:write("error: repository already uses git worktrees\n")
 894		io.stderr:write("\n")
 895		io.stderr:write("Converting an existing worktree setup is complex and error-prone.\n")
 896		io.stderr:write("Recommend: clone fresh with 'wt c <url>' and recreate worktrees.\n")
 897		if #worktrees > 0 then
 898			io.stderr:write("\nExisting worktrees:\n")
 899			for _, wt in ipairs(worktrees) do
 900				io.stderr:write("  " .. wt .. "\n")
 901			end
 902		end
 903		os.exit(EXIT_USER_ERROR)
 904	end
 905
 906	-- Case 4: Normal clone (.git/ directory, no worktrees)
 907	-- Check for uncommitted changes
 908	local status_out = run_cmd("git status --porcelain")
 909	if status_out ~= "" then
 910		die("uncommitted changes; commit or stash before converting")
 911	end
 912
 913	-- Detect default branch
 914	local default_branch = detect_cloned_default_branch(git_path)
 915
 916	-- Warnings
 917	local warnings = {}
 918
 919	-- Check for submodules
 920	if is_file(cwd .. "/.gitmodules") then
 921		table.insert(warnings, "submodules detected (.gitmodules exists); verify they work after conversion")
 922	end
 923
 924	-- Check for nested .git directories (excluding the main one)
 925	local nested_git_output, _ = run_cmd("find " .. cwd .. " -mindepth 2 -name .git -type d 2>/dev/null")
 926	if nested_git_output ~= "" then
 927		table.insert(warnings, "nested .git directories found; these may cause issues")
 928	end
 929
 930	-- Find orphaned files (files in root that will be deleted)
 931	local all_entries = list_dir(cwd)
 932	local orphaned = {}
 933	for _, entry in ipairs(all_entries) do
 934		if entry ~= ".git" and entry ~= ".bare" then
 935			table.insert(orphaned, entry)
 936		end
 937	end
 938
 939	-- Load global config for path style
 940	local global_config = load_global_config()
 941	local style = global_config.branch_path_style or "nested"
 942	local separator = global_config.flat_separator
 943	local worktree_path = branch_to_path(cwd, default_branch, style, separator)
 944
 945	if dry_run then
 946		print("Dry run - planned actions:")
 947		print("")
 948		print("1. Move .git/ to .bare/")
 949		print("2. Create .git file pointing to .bare/")
 950		print("3. Create worktree: " .. worktree_path .. " (" .. default_branch .. ")")
 951		if #orphaned > 0 then
 952			print("4. Remove " .. #orphaned .. " orphaned items from root:")
 953			for _, item in ipairs(orphaned) do
 954				print("   - " .. item)
 955			end
 956		end
 957		if #warnings > 0 then
 958			print("")
 959			print("Warnings:")
 960			for _, w in ipairs(warnings) do
 961				print("" .. w)
 962			end
 963		end
 964		os.exit(EXIT_SUCCESS)
 965	end
 966
 967	-- Show warnings
 968	for _, w in ipairs(warnings) do
 969		io.stderr:write("warning: " .. w .. "\n")
 970	end
 971
 972	-- Confirm with gum (unless -y/--yes)
 973	if not skip_confirm then
 974		local confirm_msg = "Convert to wt bare structure? This will move .git to .bare"
 975		if #orphaned > 0 then
 976			confirm_msg = confirm_msg .. " and remove " .. #orphaned .. " items from root"
 977		end
 978
 979		local confirm_code = os.execute("gum confirm '" .. confirm_msg .. "'")
 980		if confirm_code ~= true then
 981			print("Aborted")
 982			os.exit(EXIT_USER_ERROR)
 983		end
 984	end
 985
 986	-- Step 1: Move .git to .bare
 987	local output, code = run_cmd("mv " .. git_path .. " " .. bare_path)
 988	if code ~= 0 then
 989		die("failed to move .git to .bare: " .. output, EXIT_SYSTEM_ERROR)
 990	end
 991
 992	-- Step 2: Write .git file
 993	local git_file_handle = io.open(git_path, "w")
 994	if not git_file_handle then
 995		-- Try to recover
 996		run_cmd("mv " .. bare_path .. " " .. git_path)
 997		die("failed to create .git file", EXIT_SYSTEM_ERROR)
 998		return
 999	end
1000	git_file_handle:write("gitdir: ./.bare\n")
1001	git_file_handle:close()
1002
1003	-- Step 3: Detach HEAD so branch can be checked out in worktree
1004	-- Point bare repo's HEAD to a placeholder so main branch can be used by worktree
1005	run_cmd("GIT_DIR=" .. bare_path .. " git symbolic-ref HEAD refs/heads/__wt_detached_placeholder__")
1006
1007	-- Step 4: Create worktree for default branch
1008	output, code = run_cmd("GIT_DIR=" .. bare_path .. " git worktree add -- " .. worktree_path .. " " .. default_branch)
1009	if code ~= 0 then
1010		die("failed to create worktree: " .. output, EXIT_SYSTEM_ERROR)
1011	end
1012
1013	-- Step 5: Remove orphaned files from root
1014	for _, item in ipairs(orphaned) do
1015		local item_path = cwd .. "/" .. item
1016		output, code = run_cmd("rm -rf " .. item_path)
1017		if code ~= 0 then
1018			io.stderr:write("warning: failed to remove " .. item .. ": " .. output .. "\n")
1019		end
1020	end
1021
1022	-- Summary
1023	print("Converted to wt bare structure")
1024	print("Bare repo:  " .. bare_path)
1025	print("Worktree:   " .. worktree_path)
1026	if #orphaned > 0 then
1027		print("Removed:    " .. #orphaned .. " items from root")
1028	end
1029end
1030
1031-- Main entry point
1032
1033local function main()
1034	local command = arg[1]
1035
1036	if not command or command == "help" or command == "--help" or command == "-h" then
1037		print_usage()
1038		os.exit(EXIT_SUCCESS)
1039	end
1040
1041	---@cast command string
1042
1043	-- Collect remaining args
1044	local subargs = {}
1045	for i = 2, #arg do
1046		table.insert(subargs, arg[i])
1047	end
1048
1049	-- Check for --help on any command
1050	if subargs[1] == "--help" or subargs[1] == "-h" then
1051		show_command_help(command)
1052	end
1053
1054	if command == "c" then
1055		cmd_clone(subargs)
1056	elseif command == "n" then
1057		cmd_new(subargs)
1058	elseif command == "a" then
1059		cmd_add(subargs)
1060	elseif command == "r" then
1061		cmd_remove(subargs)
1062	elseif command == "l" then
1063		cmd_list()
1064	elseif command == "f" then
1065		cmd_fetch()
1066	elseif command == "init" then
1067		cmd_init(subargs)
1068	else
1069		die("unknown command: " .. command)
1070	end
1071end
1072
1073-- Export for testing when required as module
1074if pcall(debug.getlocal, 4, 1) then
1075	return {
1076		-- URL/project parsing
1077		extract_project_name = extract_project_name,
1078		resolve_url_template = resolve_url_template,
1079		-- Path manipulation
1080		branch_to_path = branch_to_path,
1081		split_path = split_path,
1082		relative_path = relative_path,
1083		path_inside = path_inside,
1084		-- Config loading
1085		load_global_config = load_global_config,
1086		load_project_config = load_project_config,
1087		-- Git output parsing (testable without git)
1088		parse_branch_remotes = parse_branch_remotes,
1089		parse_worktree_list = parse_worktree_list,
1090		escape_pattern = escape_pattern,
1091		-- Hook helpers (re-exported from wt.hooks)
1092		summarize_hooks = summarize_hooks,
1093		load_hook_permissions = load_hook_permissions,
1094		save_hook_permissions = save_hook_permissions,
1095		run_hooks = run_hooks,
1096		-- Project root detection (re-exported from wt.git)
1097		find_project_root = find_project_root,
1098		detect_source_worktree = detect_source_worktree,
1099		-- Command execution (for integration tests)
1100		run_cmd = run_cmd,
1101		run_cmd_silent = run_cmd_silent,
1102		-- Exit codes (re-exported from wt.exit)
1103		EXIT_SUCCESS = exit.EXIT_SUCCESS,
1104		EXIT_USER_ERROR = exit.EXIT_USER_ERROR,
1105		EXIT_SYSTEM_ERROR = exit.EXIT_SYSTEM_ERROR,
1106	}
1107end
1108
1109main()