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