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