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