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 detect_cloned_default_branch = git_mod.detect_cloned_default_branch
 34local get_default_branch = git_mod.get_default_branch
 35local parse_branch_remotes = git_mod.parse_branch_remotes
 36local parse_worktree_list = git_mod.parse_worktree_list
 37
 38local config_mod = require("wt.config")
 39local resolve_url_template = config_mod.resolve_url_template
 40local extract_project_name = config_mod.extract_project_name
 41local load_global_config = config_mod.load_global_config
 42local load_project_config = config_mod.load_project_config
 43
 44local hooks_mod = require("wt.hooks")
 45local load_hook_permissions = hooks_mod.load_hook_permissions
 46local save_hook_permissions = hooks_mod.save_hook_permissions
 47local summarize_hooks = hooks_mod.summarize_hooks
 48local run_hooks = hooks_mod.run_hooks
 49
 50local help_mod = require("wt.help")
 51local print_usage = help_mod.print_usage
 52local show_command_help = help_mod.show_command_help
 53
 54local fetch_mod = require("wt.cmd.fetch")
 55local cmd_fetch = fetch_mod.cmd_fetch
 56
 57local list_mod = require("wt.cmd.list")
 58local cmd_list = list_mod.cmd_list
 59
 60local remove_mod = require("wt.cmd.remove")
 61local cmd_remove = remove_mod.cmd_remove
 62
 63local add_mod = require("wt.cmd.add")
 64local cmd_add = add_mod.cmd_add
 65
 66---@param args string[]
 67local function cmd_clone(args)
 68	-- Parse arguments: <url> [--remote name]... [--own]
 69	local url = nil
 70	---@type string[]
 71	local remote_flags = {}
 72	local own = false
 73
 74	local i = 1
 75	while i <= #args do
 76		local a = args[i]
 77		if a == "--remote" then
 78			if not args[i + 1] then
 79				die("--remote requires a name")
 80			end
 81			table.insert(remote_flags, args[i + 1])
 82			i = i + 1
 83		elseif a == "--own" then
 84			own = true
 85		elseif not url then
 86			url = a
 87		else
 88			die("unexpected argument: " .. a)
 89		end
 90		i = i + 1
 91	end
 92
 93	if not url then
 94		die("usage: wt c <url> [--remote name]... [--own]")
 95		return
 96	end
 97
 98	-- Extract project name from URL
 99	local project_name = extract_project_name(url)
100	if not project_name then
101		die("could not extract project name from URL: " .. url)
102		return
103	end
104
105	-- Check if project directory already exists
106	local cwd = get_cwd()
107	if not cwd then
108		die("failed to get current directory", EXIT_SYSTEM_ERROR)
109	end
110	local project_path = cwd .. "/" .. project_name
111	local check = io.open(project_path, "r")
112	if check then
113		check:close()
114		die("directory already exists: " .. project_path)
115	end
116
117	-- Clone bare repo
118	local bare_path = project_path .. "/.bare"
119	local output, code = run_cmd("git clone --bare " .. url .. " " .. bare_path)
120	if code ~= 0 then
121		die("failed to clone: " .. output, EXIT_SYSTEM_ERROR)
122	end
123
124	-- Write .git file pointing to .bare
125	local git_file_handle = io.open(project_path .. "/.git", "w")
126	if not git_file_handle then
127		die("failed to create .git file", EXIT_SYSTEM_ERROR)
128		return
129	end
130	git_file_handle:write("gitdir: ./.bare\n")
131	git_file_handle:close()
132
133	-- Detect default branch
134	local git_dir = bare_path
135	local default_branch = detect_cloned_default_branch(git_dir)
136
137	-- Load global config
138	local global_config = load_global_config()
139
140	-- Determine which remotes to use
141	---@type string[]
142	local selected_remotes = {}
143
144	if #remote_flags > 0 then
145		selected_remotes = remote_flags
146	elseif global_config.default_remotes then
147		if type(global_config.default_remotes) == "table" then
148			selected_remotes = global_config.default_remotes
149		elseif global_config.default_remotes == "prompt" then
150			if global_config.remotes then
151				local keys = {}
152				for k in pairs(global_config.remotes) do
153					table.insert(keys, k)
154				end
155				table.sort(keys)
156				if #keys > 0 then
157					local input = table.concat(keys, "\n")
158					local choose_type = own and "" or " --no-limit"
159					local cmd = "echo '" .. input .. "' | gum choose" .. choose_type
160					output, code = run_cmd(cmd)
161					if code == 0 and output ~= "" then
162						for line in output:gmatch("[^\n]+") do
163							table.insert(selected_remotes, line)
164						end
165					end
166				end
167			end
168		end
169	elseif global_config.remotes then
170		local keys = {}
171		for k in pairs(global_config.remotes) do
172			table.insert(keys, k)
173		end
174		table.sort(keys)
175		if #keys > 0 then
176			local input = table.concat(keys, "\n")
177			local choose_type = own and "" or " --no-limit"
178			local cmd = "echo '" .. input .. "' | gum choose" .. choose_type
179			output, code = run_cmd(cmd)
180			if code == 0 and output ~= "" then
181				for line in output:gmatch("[^\n]+") do
182					table.insert(selected_remotes, line)
183				end
184			end
185		end
186	end
187
188	-- Track configured remotes for summary
189	---@type string[]
190	local configured_remotes = {}
191
192	if own then
193		-- User's own project: origin is their canonical remote
194		if #selected_remotes > 0 then
195			local first_remote = selected_remotes[1]
196			-- Rename origin to first remote
197			output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote rename origin " .. first_remote)
198			if code ~= 0 then
199				io.stderr:write("warning: failed to rename origin to " .. first_remote .. ": " .. output .. "\n")
200			else
201				-- Configure fetch refspec
202				run_cmd(
203					"GIT_DIR="
204						.. git_dir
205						.. " git config remote."
206						.. first_remote
207						.. ".fetch '+refs/heads/*:refs/remotes/"
208						.. first_remote
209						.. "/*'"
210				)
211				table.insert(configured_remotes, first_remote)
212			end
213
214			-- Add additional remotes and push to them
215			for j = 2, #selected_remotes do
216				local remote_name = selected_remotes[j]
217				local template = global_config.remotes and global_config.remotes[remote_name]
218				if template then
219					local remote_url = resolve_url_template(template, project_name)
220					output, code =
221						run_cmd("GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. remote_url)
222					if code ~= 0 then
223						io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
224					else
225						run_cmd(
226							"GIT_DIR="
227								.. git_dir
228								.. " git config remote."
229								.. remote_name
230								.. ".fetch '+refs/heads/*:refs/remotes/"
231								.. remote_name
232								.. "/*'"
233						)
234						-- Push to additional remotes
235						output, code =
236							run_cmd("GIT_DIR=" .. git_dir .. " git push " .. remote_name .. " " .. default_branch)
237						if code ~= 0 then
238							io.stderr:write("warning: failed to push to " .. remote_name .. ": " .. output .. "\n")
239						end
240						table.insert(configured_remotes, remote_name)
241					end
242				else
243					io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
244				end
245			end
246		else
247			-- No remotes selected, keep origin as-is
248			run_cmd("GIT_DIR=" .. git_dir .. " git config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'")
249			table.insert(configured_remotes, "origin")
250		end
251	else
252		-- Contributing to someone else's project
253		-- Rename origin to upstream
254		output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote rename origin upstream")
255		if code ~= 0 then
256			io.stderr:write("warning: failed to rename origin to upstream: " .. output .. "\n")
257		else
258			run_cmd(
259				"GIT_DIR=" .. git_dir .. " git config remote.upstream.fetch '+refs/heads/*:refs/remotes/upstream/*'"
260			)
261			table.insert(configured_remotes, "upstream")
262		end
263
264		-- Add user's remotes and push to each
265		for _, remote_name in ipairs(selected_remotes) do
266			local template = global_config.remotes and global_config.remotes[remote_name]
267			if template then
268				local remote_url = resolve_url_template(template, project_name)
269				output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. remote_url)
270				if code ~= 0 then
271					io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
272				else
273					run_cmd(
274						"GIT_DIR="
275							.. git_dir
276							.. " git config remote."
277							.. remote_name
278							.. ".fetch '+refs/heads/*:refs/remotes/"
279							.. remote_name
280							.. "/*'"
281					)
282					-- Push to this remote
283					output, code =
284						run_cmd("GIT_DIR=" .. git_dir .. " git push " .. remote_name .. " " .. default_branch)
285					if code ~= 0 then
286						io.stderr:write("warning: failed to push to " .. remote_name .. ": " .. output .. "\n")
287					end
288					table.insert(configured_remotes, remote_name)
289				end
290			else
291				io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
292			end
293		end
294	end
295
296	-- Fetch all remotes
297	run_cmd("GIT_DIR=" .. git_dir .. " git fetch --all")
298
299	-- Load config for path style
300	local style = global_config.branch_path_style or "nested"
301	local separator = global_config.flat_separator
302	local worktree_path = branch_to_path(project_path, default_branch, style, separator)
303
304	-- Create initial worktree
305	output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -- " .. worktree_path .. " " .. default_branch)
306	if code ~= 0 then
307		die("failed to create worktree: " .. output, EXIT_SYSTEM_ERROR)
308	end
309
310	-- Print summary
311	print("Created project: " .. project_path)
312	print("Default branch:  " .. default_branch)
313	print("Worktree:        " .. worktree_path)
314	if #configured_remotes > 0 then
315		print("Remotes:         " .. table.concat(configured_remotes, ", "))
316	end
317end
318
319---@param args string[]
320local function cmd_new(args)
321	-- Parse arguments: <project-name> [--remote name]...
322	local project_name = nil
323	---@type string[]
324	local remote_flags = {}
325
326	local i = 1
327	while i <= #args do
328		local a = args[i]
329		if a == "--remote" then
330			if not args[i + 1] then
331				die("--remote requires a name")
332			end
333			table.insert(remote_flags, args[i + 1])
334			i = i + 1
335		elseif not project_name then
336			project_name = a
337		else
338			die("unexpected argument: " .. a)
339		end
340		i = i + 1
341	end
342
343	if not project_name then
344		die("usage: wt n <project-name> [--remote name]...")
345		return
346	end
347
348	-- Check if project directory already exists
349	local cwd = get_cwd()
350	if not cwd then
351		die("failed to get current directory", EXIT_SYSTEM_ERROR)
352	end
353	local project_path = cwd .. "/" .. project_name
354	local check = io.open(project_path, "r")
355	if check then
356		check:close()
357		die("directory already exists: " .. project_path)
358	end
359
360	-- Load global config
361	local global_config = load_global_config()
362
363	-- Determine which remotes to use
364	---@type string[]
365	local selected_remotes = {}
366
367	if #remote_flags > 0 then
368		-- Use explicitly provided remotes
369		selected_remotes = remote_flags
370	elseif global_config.default_remotes then
371		if type(global_config.default_remotes) == "table" then
372			selected_remotes = global_config.default_remotes
373		elseif global_config.default_remotes == "prompt" then
374			-- Prompt with gum choose
375			if global_config.remotes then
376				local keys = {}
377				for k in pairs(global_config.remotes) do
378					table.insert(keys, k)
379				end
380				table.sort(keys)
381				if #keys > 0 then
382					local input = table.concat(keys, "\n")
383					local cmd = "echo '" .. input .. "' | gum choose --no-limit"
384					local output, code = run_cmd(cmd)
385					if code == 0 and output ~= "" then
386						for line in output:gmatch("[^\n]+") do
387							table.insert(selected_remotes, line)
388						end
389					end
390				end
391			end
392		end
393	elseif global_config.remotes then
394		-- No default_remotes configured, prompt if remotes exist
395		local keys = {}
396		for k in pairs(global_config.remotes) do
397			table.insert(keys, k)
398		end
399		table.sort(keys)
400		if #keys > 0 then
401			local input = table.concat(keys, "\n")
402			local cmd = "echo '" .. input .. "' | gum choose --no-limit"
403			local output, code = run_cmd(cmd)
404			if code == 0 and output ~= "" then
405				for line in output:gmatch("[^\n]+") do
406					table.insert(selected_remotes, line)
407				end
408			end
409		end
410	end
411
412	-- Create project structure
413	local bare_path = project_path .. "/.bare"
414	local output, code = run_cmd("mkdir -p " .. bare_path)
415	if code ~= 0 then
416		die("failed to create directory: " .. output, EXIT_SYSTEM_ERROR)
417	end
418
419	output, code = run_cmd("git init --bare " .. bare_path)
420	if code ~= 0 then
421		die("failed to init bare repo: " .. output, EXIT_SYSTEM_ERROR)
422	end
423
424	-- Write .git file pointing to .bare
425	local git_file_handle = io.open(project_path .. "/.git", "w")
426	if not git_file_handle then
427		die("failed to create .git file", EXIT_SYSTEM_ERROR)
428		return
429	end
430	git_file_handle:write("gitdir: ./.bare\n")
431	git_file_handle:close()
432
433	-- Add remotes
434	local git_dir = bare_path
435	for _, remote_name in ipairs(selected_remotes) do
436		local template = global_config.remotes and global_config.remotes[remote_name]
437		if template then
438			local url = resolve_url_template(template, project_name)
439			output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. url)
440			if code ~= 0 then
441				io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
442			else
443				-- Configure fetch refspec for the remote
444				run_cmd(
445					"GIT_DIR="
446						.. git_dir
447						.. " git config remote."
448						.. remote_name
449						.. ".fetch '+refs/heads/*:refs/remotes/"
450						.. remote_name
451						.. "/*'"
452				)
453			end
454		else
455			io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
456		end
457	end
458
459	-- Detect default branch
460	local default_branch = get_default_branch()
461
462	-- Load config for path style
463	local style = global_config.branch_path_style or "nested"
464	local separator = global_config.flat_separator
465	local worktree_path = branch_to_path(project_path, default_branch, style, separator)
466
467	-- Create orphan worktree
468	output, code =
469		run_cmd("GIT_DIR=" .. git_dir .. " git worktree add --orphan -b " .. default_branch .. " -- " .. worktree_path)
470	if code ~= 0 then
471		die("failed to create worktree: " .. output, EXIT_SYSTEM_ERROR)
472	end
473
474	-- Print summary
475	print("Created project: " .. project_path)
476	print("Default branch:  " .. default_branch)
477	print("Worktree:        " .. worktree_path)
478	if #selected_remotes > 0 then
479		print("Remotes:         " .. table.concat(selected_remotes, ", "))
480	end
481end
482
483---List directory entries (excluding . and ..)
484---@param path string
485---@return string[]
486local function list_dir(path)
487	local entries = {}
488	local handle = io.popen("ls -A " .. path .. " 2>/dev/null")
489	if not handle then
490		return entries
491	end
492	for line in handle:lines() do
493		if line ~= "" then
494			table.insert(entries, line)
495		end
496	end
497	handle:close()
498	return entries
499end
500
501---Check if path is a directory
502---@param path string
503---@return boolean
504local function is_dir(path)
505	local f = io.open(path, "r")
506	if not f then
507		return false
508	end
509	f:close()
510	return run_cmd_silent("test -d " .. path)
511end
512
513---Check if path is a file (not directory)
514---@param path string
515---@return boolean
516local function is_file(path)
517	local f = io.open(path, "r")
518	if not f then
519		return false
520	end
521	f:close()
522	return run_cmd_silent("test -f " .. path)
523end
524
525---@param args string[]
526local function cmd_init(args)
527	-- Parse arguments
528	local dry_run = false
529	local skip_confirm = false
530	for _, a in ipairs(args) do
531		if a == "--dry-run" then
532			dry_run = true
533		elseif a == "-y" or a == "--yes" then
534			skip_confirm = true
535		else
536			die("unexpected argument: " .. a)
537		end
538	end
539
540	local cwd = get_cwd()
541	if not cwd then
542		die("failed to get current directory", EXIT_SYSTEM_ERROR)
543		return
544	end
545
546	-- Case 1: Already wt-managed (.git file pointing to .bare + .bare/ exists)
547	local git_path = cwd .. "/.git"
548	local bare_path = cwd .. "/.bare"
549
550	local bare_exists = is_dir(bare_path)
551	local git_file = io.open(git_path, "r")
552
553	if git_file then
554		local content = git_file:read("*a")
555		git_file:close()
556
557		-- Check if it's a file (not directory) pointing to .bare
558		if is_file(git_path) and content and content:match("gitdir:%s*%.?/?%.bare") then
559			if bare_exists then
560				print("Already using wt bare structure")
561				os.exit(EXIT_SUCCESS)
562			end
563		end
564
565		-- Check if .git is a file pointing elsewhere (inside a worktree)
566		if is_file(git_path) and content and content:match("^gitdir:") then
567			-- It's a worktree, not project root
568			die("inside a worktree; run from project root or use 'wt c' to clone fresh")
569		end
570	end
571
572	-- Check for .git directory
573	local git_dir_exists = is_dir(git_path)
574
575	if not git_dir_exists then
576		-- Case 5: No .git at all, or bare repo without .git dir
577		if bare_exists then
578			die("found .bare/ but no .git; manually create .git file with 'gitdir: ./.bare'")
579		end
580		die("not a git repository (no .git found)")
581	end
582
583	-- Now we have a .git directory
584	-- Case 3: Existing worktree setup (.git/worktrees/ exists)
585	local worktrees_path = git_path .. "/worktrees"
586	if is_dir(worktrees_path) then
587		local worktrees = list_dir(worktrees_path)
588		io.stderr:write("error: repository already uses git worktrees\n")
589		io.stderr:write("\n")
590		io.stderr:write("Converting an existing worktree setup is complex and error-prone.\n")
591		io.stderr:write("Recommend: clone fresh with 'wt c <url>' and recreate worktrees.\n")
592		if #worktrees > 0 then
593			io.stderr:write("\nExisting worktrees:\n")
594			for _, wt in ipairs(worktrees) do
595				io.stderr:write("  " .. wt .. "\n")
596			end
597		end
598		os.exit(EXIT_USER_ERROR)
599	end
600
601	-- Case 4: Normal clone (.git/ directory, no worktrees)
602	-- Check for uncommitted changes
603	local status_out = run_cmd("git status --porcelain")
604	if status_out ~= "" then
605		die("uncommitted changes; commit or stash before converting")
606	end
607
608	-- Detect default branch
609	local default_branch = detect_cloned_default_branch(git_path)
610
611	-- Warnings
612	local warnings = {}
613
614	-- Check for submodules
615	if is_file(cwd .. "/.gitmodules") then
616		table.insert(warnings, "submodules detected (.gitmodules exists); verify they work after conversion")
617	end
618
619	-- Check for nested .git directories (excluding the main one)
620	local nested_git_output, _ = run_cmd("find " .. cwd .. " -mindepth 2 -name .git -type d 2>/dev/null")
621	if nested_git_output ~= "" then
622		table.insert(warnings, "nested .git directories found; these may cause issues")
623	end
624
625	-- Find orphaned files (files in root that will be deleted)
626	local all_entries = list_dir(cwd)
627	local orphaned = {}
628	for _, entry in ipairs(all_entries) do
629		if entry ~= ".git" and entry ~= ".bare" then
630			table.insert(orphaned, entry)
631		end
632	end
633
634	-- Load global config for path style
635	local global_config = load_global_config()
636	local style = global_config.branch_path_style or "nested"
637	local separator = global_config.flat_separator
638	local worktree_path = branch_to_path(cwd, default_branch, style, separator)
639
640	if dry_run then
641		print("Dry run - planned actions:")
642		print("")
643		print("1. Move .git/ to .bare/")
644		print("2. Create .git file pointing to .bare/")
645		print("3. Create worktree: " .. worktree_path .. " (" .. default_branch .. ")")
646		if #orphaned > 0 then
647			print("4. Remove " .. #orphaned .. " orphaned items from root:")
648			for _, item in ipairs(orphaned) do
649				print("   - " .. item)
650			end
651		end
652		if #warnings > 0 then
653			print("")
654			print("Warnings:")
655			for _, w in ipairs(warnings) do
656				print("" .. w)
657			end
658		end
659		os.exit(EXIT_SUCCESS)
660	end
661
662	-- Show warnings
663	for _, w in ipairs(warnings) do
664		io.stderr:write("warning: " .. w .. "\n")
665	end
666
667	-- Confirm with gum (unless -y/--yes)
668	if not skip_confirm then
669		local confirm_msg = "Convert to wt bare structure? This will move .git to .bare"
670		if #orphaned > 0 then
671			confirm_msg = confirm_msg .. " and remove " .. #orphaned .. " items from root"
672		end
673
674		local confirm_code = os.execute("gum confirm '" .. confirm_msg .. "'")
675		if confirm_code ~= true then
676			print("Aborted")
677			os.exit(EXIT_USER_ERROR)
678		end
679	end
680
681	-- Step 1: Move .git to .bare
682	local output, code = run_cmd("mv " .. git_path .. " " .. bare_path)
683	if code ~= 0 then
684		die("failed to move .git to .bare: " .. output, EXIT_SYSTEM_ERROR)
685	end
686
687	-- Step 2: Write .git file
688	local git_file_handle = io.open(git_path, "w")
689	if not git_file_handle then
690		-- Try to recover
691		run_cmd("mv " .. bare_path .. " " .. git_path)
692		die("failed to create .git file", EXIT_SYSTEM_ERROR)
693		return
694	end
695	git_file_handle:write("gitdir: ./.bare\n")
696	git_file_handle:close()
697
698	-- Step 3: Detach HEAD so branch can be checked out in worktree
699	-- Point bare repo's HEAD to a placeholder so main branch can be used by worktree
700	run_cmd("GIT_DIR=" .. bare_path .. " git symbolic-ref HEAD refs/heads/__wt_detached_placeholder__")
701
702	-- Step 4: Create worktree for default branch
703	output, code = run_cmd("GIT_DIR=" .. bare_path .. " git worktree add -- " .. worktree_path .. " " .. default_branch)
704	if code ~= 0 then
705		die("failed to create worktree: " .. output, EXIT_SYSTEM_ERROR)
706	end
707
708	-- Step 5: Remove orphaned files from root
709	for _, item in ipairs(orphaned) do
710		local item_path = cwd .. "/" .. item
711		output, code = run_cmd("rm -rf " .. item_path)
712		if code ~= 0 then
713			io.stderr:write("warning: failed to remove " .. item .. ": " .. output .. "\n")
714		end
715	end
716
717	-- Summary
718	print("Converted to wt bare structure")
719	print("Bare repo:  " .. bare_path)
720	print("Worktree:   " .. worktree_path)
721	if #orphaned > 0 then
722		print("Removed:    " .. #orphaned .. " items from root")
723	end
724end
725
726-- Main entry point
727
728local function main()
729	local command = arg[1]
730
731	if not command or command == "help" or command == "--help" or command == "-h" then
732		print_usage()
733		os.exit(EXIT_SUCCESS)
734	end
735
736	---@cast command string
737
738	-- Collect remaining args
739	local subargs = {}
740	for i = 2, #arg do
741		table.insert(subargs, arg[i])
742	end
743
744	-- Check for --help on any command
745	if subargs[1] == "--help" or subargs[1] == "-h" then
746		show_command_help(command)
747	end
748
749	if command == "c" then
750		cmd_clone(subargs)
751	elseif command == "n" then
752		cmd_new(subargs)
753	elseif command == "a" then
754		cmd_add(subargs)
755	elseif command == "r" then
756		cmd_remove(subargs)
757	elseif command == "l" then
758		cmd_list()
759	elseif command == "f" then
760		cmd_fetch()
761	elseif command == "init" then
762		cmd_init(subargs)
763	else
764		die("unknown command: " .. command)
765	end
766end
767
768-- Export for testing when required as module
769if pcall(debug.getlocal, 4, 1) then
770	return {
771		-- URL/project parsing
772		extract_project_name = extract_project_name,
773		resolve_url_template = resolve_url_template,
774		-- Path manipulation
775		branch_to_path = branch_to_path,
776		split_path = split_path,
777		relative_path = relative_path,
778		path_inside = path_inside,
779		-- Config loading
780		load_global_config = load_global_config,
781		load_project_config = load_project_config,
782		-- Git output parsing (testable without git)
783		parse_branch_remotes = parse_branch_remotes,
784		parse_worktree_list = parse_worktree_list,
785		escape_pattern = escape_pattern,
786		-- Hook helpers (re-exported from wt.hooks)
787		summarize_hooks = summarize_hooks,
788		load_hook_permissions = load_hook_permissions,
789		save_hook_permissions = save_hook_permissions,
790		run_hooks = run_hooks,
791		-- Project root detection (re-exported from wt.git)
792		find_project_root = find_project_root,
793		detect_source_worktree = detect_source_worktree,
794		-- Command execution (for integration tests)
795		run_cmd = run_cmd,
796		run_cmd_silent = run_cmd_silent,
797		-- Exit codes (re-exported from wt.exit)
798		EXIT_SUCCESS = exit.EXIT_SUCCESS,
799		EXIT_USER_ERROR = exit.EXIT_USER_ERROR,
800		EXIT_SYSTEM_ERROR = exit.EXIT_SYSTEM_ERROR,
801	}
802end
803
804main()