refactor(wt): extract cmd.init module

Amolith created

Assisted-by: Claude Opus 4.5 via Amp

Change summary

dist/wt             | 189 ++++++++++++++++++---------------
src/main.lua        | 248 --------------------------------------------
src/wt/cmd/init.lua | 259 +++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 364 insertions(+), 332 deletions(-)

Detailed changes

dist/wt 🔗

@@ -1696,70 +1696,18 @@ end
 return M
 ]]
 
-
-if _VERSION < "Lua 5.2" then
-	io.stderr:write("error: wt requires Lua 5.2 or later\n")
-	os.exit(1)
-end
+_EMBEDDED_MODULES["wt.cmd.init"] = [[-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+--
+-- SPDX-License-Identifier: GPL-3.0-or-later
 
 local exit = require("wt.exit")
-local EXIT_SUCCESS = exit.EXIT_SUCCESS
-local EXIT_USER_ERROR = exit.EXIT_USER_ERROR
-local EXIT_SYSTEM_ERROR = exit.EXIT_SYSTEM_ERROR
-
 local shell = require("wt.shell")
-local run_cmd = shell.run_cmd
-local run_cmd_silent = shell.run_cmd_silent
-local get_cwd = shell.get_cwd
-local die = shell.die
-
+local git = require("wt.git")
 local path_mod = require("wt.path")
-local branch_to_path = path_mod.branch_to_path
-local split_path = path_mod.split_path
-local relative_path = path_mod.relative_path
-local path_inside = path_mod.path_inside
-local escape_pattern = path_mod.escape_pattern
-
-local git_mod = require("wt.git")
-local find_project_root = git_mod.find_project_root
-local detect_source_worktree = git_mod.detect_source_worktree
-local detect_cloned_default_branch = git_mod.detect_cloned_default_branch
-local parse_branch_remotes = git_mod.parse_branch_remotes
-local parse_worktree_list = git_mod.parse_worktree_list
-
-local config_mod = require("wt.config")
-local resolve_url_template = config_mod.resolve_url_template
-local extract_project_name = config_mod.extract_project_name
-local load_global_config = config_mod.load_global_config
-local load_project_config = config_mod.load_project_config
-
-local hooks_mod = require("wt.hooks")
-local load_hook_permissions = hooks_mod.load_hook_permissions
-local save_hook_permissions = hooks_mod.save_hook_permissions
-local summarize_hooks = hooks_mod.summarize_hooks
-local run_hooks = hooks_mod.run_hooks
-
-local help_mod = require("wt.help")
-local print_usage = help_mod.print_usage
-local show_command_help = help_mod.show_command_help
-
-local fetch_mod = require("wt.cmd.fetch")
-local cmd_fetch = fetch_mod.cmd_fetch
-
-local list_mod = require("wt.cmd.list")
-local cmd_list = list_mod.cmd_list
-
-local remove_mod = require("wt.cmd.remove")
-local cmd_remove = remove_mod.cmd_remove
-
-local add_mod = require("wt.cmd.add")
-local cmd_add = add_mod.cmd_add
-
-local new_mod = require("wt.cmd.new")
-local cmd_new = new_mod.cmd_new
+local config = require("wt.config")
 
-local clone_mod = require("wt.cmd.clone")
-local cmd_clone = clone_mod.cmd_clone
+---@class wt.cmd.init
+local M = {}
 
 ---List directory entries (excluding . and ..)
 ---@param path string
@@ -1788,7 +1736,7 @@ local function is_dir(path)
 		return false
 	end
 	f:close()
-	return run_cmd_silent("test -d " .. path)
+	return shell.run_cmd_silent("test -d " .. path)
 end
 
 ---Check if path is a file (not directory)
@@ -1800,11 +1748,12 @@ local function is_file(path)
 		return false
 	end
 	f:close()
-	return run_cmd_silent("test -f " .. path)
+	return shell.run_cmd_silent("test -f " .. path)
 end
 
+---Convert existing git repository to wt bare structure
 ---@param args string[]
-local function cmd_init(args)
+function M.cmd_init(args)
 	-- Parse arguments
 	local dry_run = false
 	local skip_confirm = false
@@ -1814,13 +1763,13 @@ local function cmd_init(args)
 		elseif a == "-y" or a == "--yes" then
 			skip_confirm = true
 		else
-			die("unexpected argument: " .. a)
+			shell.die("unexpected argument: " .. a)
 		end
 	end
 
-	local cwd = get_cwd()
+	local cwd = shell.get_cwd()
 	if not cwd then
-		die("failed to get current directory", EXIT_SYSTEM_ERROR)
+		shell.die("failed to get current directory", exit.EXIT_SYSTEM_ERROR)
 		return
 	end
 
@@ -1839,14 +1788,14 @@ local function cmd_init(args)
 		if is_file(git_path) and content and content:match("gitdir:%s*%.?/?%.bare") then
 			if bare_exists then
 				print("Already using wt bare structure")
-				os.exit(EXIT_SUCCESS)
+				os.exit(exit.EXIT_SUCCESS)
 			end
 		end
 
 		-- Check if .git is a file pointing elsewhere (inside a worktree)
 		if is_file(git_path) and content and content:match("^gitdir:") then
 			-- It's a worktree, not project root
-			die("inside a worktree; run from project root or use 'wt c' to clone fresh")
+			shell.die("inside a worktree; run from project root or use 'wt c' to clone fresh")
 		end
 	end
 
@@ -1856,9 +1805,9 @@ local function cmd_init(args)
 	if not git_dir_exists then
 		-- Case 5: No .git at all, or bare repo without .git dir
 		if bare_exists then
-			die("found .bare/ but no .git; manually create .git file with 'gitdir: ./.bare'")
+			shell.die("found .bare/ but no .git; manually create .git file with 'gitdir: ./.bare'")
 		end
-		die("not a git repository (no .git found)")
+		shell.die("not a git repository (no .git found)")
 	end
 
 	-- Now we have a .git directory
@@ -1876,18 +1825,18 @@ local function cmd_init(args)
 				io.stderr:write("  " .. wt .. "\n")
 			end
 		end
-		os.exit(EXIT_USER_ERROR)
+		os.exit(exit.EXIT_USER_ERROR)
 	end
 
 	-- Case 4: Normal clone (.git/ directory, no worktrees)
 	-- Check for uncommitted changes
-	local status_out = run_cmd("git status --porcelain")
+	local status_out = shell.run_cmd("git status --porcelain")
 	if status_out ~= "" then
-		die("uncommitted changes; commit or stash before converting")
+		shell.die("uncommitted changes; commit or stash before converting")
 	end
 
 	-- Detect default branch
-	local default_branch = detect_cloned_default_branch(git_path)
+	local default_branch = git.detect_cloned_default_branch(git_path)
 
 	-- Warnings
 	local warnings = {}
@@ -1898,7 +1847,7 @@ local function cmd_init(args)
 	end
 
 	-- Check for nested .git directories (excluding the main one)
-	local nested_git_output, _ = run_cmd("find " .. cwd .. " -mindepth 2 -name .git -type d 2>/dev/null")
+	local nested_git_output, _ = shell.run_cmd("find " .. cwd .. " -mindepth 2 -name .git -type d 2>/dev/null")
 	if nested_git_output ~= "" then
 		table.insert(warnings, "nested .git directories found; these may cause issues")
 	end
@@ -1913,10 +1862,10 @@ local function cmd_init(args)
 	end
 
 	-- Load global config for path style
-	local global_config = load_global_config()
+	local global_config = config.load_global_config()
 	local style = global_config.branch_path_style or "nested"
 	local separator = global_config.flat_separator
-	local worktree_path = branch_to_path(cwd, default_branch, style, separator)
+	local worktree_path = path_mod.branch_to_path(cwd, default_branch, style, separator)
 
 	if dry_run then
 		print("Dry run - planned actions:")
@@ -1937,7 +1886,7 @@ local function cmd_init(args)
 				print("  ⚠ " .. w)
 			end
 		end
-		os.exit(EXIT_SUCCESS)
+		os.exit(exit.EXIT_SUCCESS)
 	end
 
 	-- Show warnings
@@ -1955,22 +1904,22 @@ local function cmd_init(args)
 		local confirm_code = os.execute("gum confirm '" .. confirm_msg .. "'")
 		if confirm_code ~= true then
 			print("Aborted")
-			os.exit(EXIT_USER_ERROR)
+			os.exit(exit.EXIT_USER_ERROR)
 		end
 	end
 
 	-- Step 1: Move .git to .bare
-	local output, code = run_cmd("mv " .. git_path .. " " .. bare_path)
+	local output, code = shell.run_cmd("mv " .. git_path .. " " .. bare_path)
 	if code ~= 0 then
-		die("failed to move .git to .bare: " .. output, EXIT_SYSTEM_ERROR)
+		shell.die("failed to move .git to .bare: " .. output, exit.EXIT_SYSTEM_ERROR)
 	end
 
 	-- Step 2: Write .git file
 	local git_file_handle = io.open(git_path, "w")
 	if not git_file_handle then
 		-- Try to recover
-		run_cmd("mv " .. bare_path .. " " .. git_path)
-		die("failed to create .git file", EXIT_SYSTEM_ERROR)
+		shell.run_cmd("mv " .. bare_path .. " " .. git_path)
+		shell.die("failed to create .git file", exit.EXIT_SYSTEM_ERROR)
 		return
 	end
 	git_file_handle:write("gitdir: ./.bare\n")
@@ -1978,18 +1927,19 @@ local function cmd_init(args)
 
 	-- Step 3: Detach HEAD so branch can be checked out in worktree
 	-- Point bare repo's HEAD to a placeholder so main branch can be used by worktree
-	run_cmd("GIT_DIR=" .. bare_path .. " git symbolic-ref HEAD refs/heads/__wt_detached_placeholder__")
+	shell.run_cmd("GIT_DIR=" .. bare_path .. " git symbolic-ref HEAD refs/heads/__wt_detached_placeholder__")
 
 	-- Step 4: Create worktree for default branch
-	output, code = run_cmd("GIT_DIR=" .. bare_path .. " git worktree add -- " .. worktree_path .. " " .. default_branch)
+	output, code =
+		shell.run_cmd("GIT_DIR=" .. bare_path .. " git worktree add -- " .. worktree_path .. " " .. default_branch)
 	if code ~= 0 then
-		die("failed to create worktree: " .. output, EXIT_SYSTEM_ERROR)
+		shell.die("failed to create worktree: " .. output, exit.EXIT_SYSTEM_ERROR)
 	end
 
 	-- Step 5: Remove orphaned files from root
 	for _, item in ipairs(orphaned) do
 		local item_path = cwd .. "/" .. item
-		output, code = run_cmd("rm -rf " .. item_path)
+		output, code = shell.run_cmd("rm -rf " .. item_path)
 		if code ~= 0 then
 			io.stderr:write("warning: failed to remove " .. item .. ": " .. output .. "\n")
 		end
@@ -2004,6 +1954,73 @@ local function cmd_init(args)
 	end
 end
 
+return M
+]]
+
+
+if _VERSION < "Lua 5.2" then
+	io.stderr:write("error: wt requires Lua 5.2 or later\n")
+	os.exit(1)
+end
+
+local exit = require("wt.exit")
+local EXIT_SUCCESS = exit.EXIT_SUCCESS
+
+local shell = require("wt.shell")
+local run_cmd = shell.run_cmd
+local run_cmd_silent = shell.run_cmd_silent
+local die = shell.die
+
+local path_mod = require("wt.path")
+local branch_to_path = path_mod.branch_to_path
+local split_path = path_mod.split_path
+local relative_path = path_mod.relative_path
+local path_inside = path_mod.path_inside
+local escape_pattern = path_mod.escape_pattern
+
+local git_mod = require("wt.git")
+local find_project_root = git_mod.find_project_root
+local detect_source_worktree = git_mod.detect_source_worktree
+local parse_branch_remotes = git_mod.parse_branch_remotes
+local parse_worktree_list = git_mod.parse_worktree_list
+
+local config_mod = require("wt.config")
+local resolve_url_template = config_mod.resolve_url_template
+local extract_project_name = config_mod.extract_project_name
+local load_global_config = config_mod.load_global_config
+local load_project_config = config_mod.load_project_config
+
+local hooks_mod = require("wt.hooks")
+local load_hook_permissions = hooks_mod.load_hook_permissions
+local save_hook_permissions = hooks_mod.save_hook_permissions
+local summarize_hooks = hooks_mod.summarize_hooks
+local run_hooks = hooks_mod.run_hooks
+
+local help_mod = require("wt.help")
+local print_usage = help_mod.print_usage
+local show_command_help = help_mod.show_command_help
+
+local fetch_mod = require("wt.cmd.fetch")
+local cmd_fetch = fetch_mod.cmd_fetch
+
+local list_mod = require("wt.cmd.list")
+local cmd_list = list_mod.cmd_list
+
+local remove_mod = require("wt.cmd.remove")
+local cmd_remove = remove_mod.cmd_remove
+
+local add_mod = require("wt.cmd.add")
+local cmd_add = add_mod.cmd_add
+
+local new_mod = require("wt.cmd.new")
+local cmd_new = new_mod.cmd_new
+
+local clone_mod = require("wt.cmd.clone")
+local cmd_clone = clone_mod.cmd_clone
+
+local init_mod = require("wt.cmd.init")
+local cmd_init = init_mod.cmd_init
+
 -- Main entry point
 
 local function main()

src/main.lua 🔗

@@ -11,13 +11,10 @@ end
 
 local exit = require("wt.exit")
 local EXIT_SUCCESS = exit.EXIT_SUCCESS
-local EXIT_USER_ERROR = exit.EXIT_USER_ERROR
-local EXIT_SYSTEM_ERROR = exit.EXIT_SYSTEM_ERROR
 
 local shell = require("wt.shell")
 local run_cmd = shell.run_cmd
 local run_cmd_silent = shell.run_cmd_silent
-local get_cwd = shell.get_cwd
 local die = shell.die
 
 local path_mod = require("wt.path")
@@ -30,7 +27,6 @@ local escape_pattern = path_mod.escape_pattern
 local git_mod = require("wt.git")
 local find_project_root = git_mod.find_project_root
 local detect_source_worktree = git_mod.detect_source_worktree
-local detect_cloned_default_branch = git_mod.detect_cloned_default_branch
 local parse_branch_remotes = git_mod.parse_branch_remotes
 local parse_worktree_list = git_mod.parse_worktree_list
 
@@ -68,248 +64,8 @@ local cmd_new = new_mod.cmd_new
 local clone_mod = require("wt.cmd.clone")
 local cmd_clone = clone_mod.cmd_clone
 
----List directory entries (excluding . and ..)
----@param path string
----@return string[]
-local function list_dir(path)
-	local entries = {}
-	local handle = io.popen("ls -A " .. path .. " 2>/dev/null")
-	if not handle then
-		return entries
-	end
-	for line in handle:lines() do
-		if line ~= "" then
-			table.insert(entries, line)
-		end
-	end
-	handle:close()
-	return entries
-end
-
----Check if path is a directory
----@param path string
----@return boolean
-local function is_dir(path)
-	local f = io.open(path, "r")
-	if not f then
-		return false
-	end
-	f:close()
-	return run_cmd_silent("test -d " .. path)
-end
-
----Check if path is a file (not directory)
----@param path string
----@return boolean
-local function is_file(path)
-	local f = io.open(path, "r")
-	if not f then
-		return false
-	end
-	f:close()
-	return run_cmd_silent("test -f " .. path)
-end
-
----@param args string[]
-local function cmd_init(args)
-	-- Parse arguments
-	local dry_run = false
-	local skip_confirm = false
-	for _, a in ipairs(args) do
-		if a == "--dry-run" then
-			dry_run = true
-		elseif a == "-y" or a == "--yes" then
-			skip_confirm = true
-		else
-			die("unexpected argument: " .. a)
-		end
-	end
-
-	local cwd = get_cwd()
-	if not cwd then
-		die("failed to get current directory", EXIT_SYSTEM_ERROR)
-		return
-	end
-
-	-- Case 1: Already wt-managed (.git file pointing to .bare + .bare/ exists)
-	local git_path = cwd .. "/.git"
-	local bare_path = cwd .. "/.bare"
-
-	local bare_exists = is_dir(bare_path)
-	local git_file = io.open(git_path, "r")
-
-	if git_file then
-		local content = git_file:read("*a")
-		git_file:close()
-
-		-- Check if it's a file (not directory) pointing to .bare
-		if is_file(git_path) and content and content:match("gitdir:%s*%.?/?%.bare") then
-			if bare_exists then
-				print("Already using wt bare structure")
-				os.exit(EXIT_SUCCESS)
-			end
-		end
-
-		-- Check if .git is a file pointing elsewhere (inside a worktree)
-		if is_file(git_path) and content and content:match("^gitdir:") then
-			-- It's a worktree, not project root
-			die("inside a worktree; run from project root or use 'wt c' to clone fresh")
-		end
-	end
-
-	-- Check for .git directory
-	local git_dir_exists = is_dir(git_path)
-
-	if not git_dir_exists then
-		-- Case 5: No .git at all, or bare repo without .git dir
-		if bare_exists then
-			die("found .bare/ but no .git; manually create .git file with 'gitdir: ./.bare'")
-		end
-		die("not a git repository (no .git found)")
-	end
-
-	-- Now we have a .git directory
-	-- Case 3: Existing worktree setup (.git/worktrees/ exists)
-	local worktrees_path = git_path .. "/worktrees"
-	if is_dir(worktrees_path) then
-		local worktrees = list_dir(worktrees_path)
-		io.stderr:write("error: repository already uses git worktrees\n")
-		io.stderr:write("\n")
-		io.stderr:write("Converting an existing worktree setup is complex and error-prone.\n")
-		io.stderr:write("Recommend: clone fresh with 'wt c <url>' and recreate worktrees.\n")
-		if #worktrees > 0 then
-			io.stderr:write("\nExisting worktrees:\n")
-			for _, wt in ipairs(worktrees) do
-				io.stderr:write("  " .. wt .. "\n")
-			end
-		end
-		os.exit(EXIT_USER_ERROR)
-	end
-
-	-- Case 4: Normal clone (.git/ directory, no worktrees)
-	-- Check for uncommitted changes
-	local status_out = run_cmd("git status --porcelain")
-	if status_out ~= "" then
-		die("uncommitted changes; commit or stash before converting")
-	end
-
-	-- Detect default branch
-	local default_branch = detect_cloned_default_branch(git_path)
-
-	-- Warnings
-	local warnings = {}
-
-	-- Check for submodules
-	if is_file(cwd .. "/.gitmodules") then
-		table.insert(warnings, "submodules detected (.gitmodules exists); verify they work after conversion")
-	end
-
-	-- Check for nested .git directories (excluding the main one)
-	local nested_git_output, _ = run_cmd("find " .. cwd .. " -mindepth 2 -name .git -type d 2>/dev/null")
-	if nested_git_output ~= "" then
-		table.insert(warnings, "nested .git directories found; these may cause issues")
-	end
-
-	-- Find orphaned files (files in root that will be deleted)
-	local all_entries = list_dir(cwd)
-	local orphaned = {}
-	for _, entry in ipairs(all_entries) do
-		if entry ~= ".git" and entry ~= ".bare" then
-			table.insert(orphaned, entry)
-		end
-	end
-
-	-- Load global config for path style
-	local global_config = load_global_config()
-	local style = global_config.branch_path_style or "nested"
-	local separator = global_config.flat_separator
-	local worktree_path = branch_to_path(cwd, default_branch, style, separator)
-
-	if dry_run then
-		print("Dry run - planned actions:")
-		print("")
-		print("1. Move .git/ to .bare/")
-		print("2. Create .git file pointing to .bare/")
-		print("3. Create worktree: " .. worktree_path .. " (" .. default_branch .. ")")
-		if #orphaned > 0 then
-			print("4. Remove " .. #orphaned .. " orphaned items from root:")
-			for _, item in ipairs(orphaned) do
-				print("   - " .. item)
-			end
-		end
-		if #warnings > 0 then
-			print("")
-			print("Warnings:")
-			for _, w in ipairs(warnings) do
-				print("  ⚠ " .. w)
-			end
-		end
-		os.exit(EXIT_SUCCESS)
-	end
-
-	-- Show warnings
-	for _, w in ipairs(warnings) do
-		io.stderr:write("warning: " .. w .. "\n")
-	end
-
-	-- Confirm with gum (unless -y/--yes)
-	if not skip_confirm then
-		local confirm_msg = "Convert to wt bare structure? This will move .git to .bare"
-		if #orphaned > 0 then
-			confirm_msg = confirm_msg .. " and remove " .. #orphaned .. " items from root"
-		end
-
-		local confirm_code = os.execute("gum confirm '" .. confirm_msg .. "'")
-		if confirm_code ~= true then
-			print("Aborted")
-			os.exit(EXIT_USER_ERROR)
-		end
-	end
-
-	-- Step 1: Move .git to .bare
-	local output, code = run_cmd("mv " .. git_path .. " " .. bare_path)
-	if code ~= 0 then
-		die("failed to move .git to .bare: " .. output, EXIT_SYSTEM_ERROR)
-	end
-
-	-- Step 2: Write .git file
-	local git_file_handle = io.open(git_path, "w")
-	if not git_file_handle then
-		-- Try to recover
-		run_cmd("mv " .. bare_path .. " " .. git_path)
-		die("failed to create .git file", EXIT_SYSTEM_ERROR)
-		return
-	end
-	git_file_handle:write("gitdir: ./.bare\n")
-	git_file_handle:close()
-
-	-- Step 3: Detach HEAD so branch can be checked out in worktree
-	-- Point bare repo's HEAD to a placeholder so main branch can be used by worktree
-	run_cmd("GIT_DIR=" .. bare_path .. " git symbolic-ref HEAD refs/heads/__wt_detached_placeholder__")
-
-	-- Step 4: Create worktree for default branch
-	output, code = run_cmd("GIT_DIR=" .. bare_path .. " git worktree add -- " .. worktree_path .. " " .. default_branch)
-	if code ~= 0 then
-		die("failed to create worktree: " .. output, EXIT_SYSTEM_ERROR)
-	end
-
-	-- Step 5: Remove orphaned files from root
-	for _, item in ipairs(orphaned) do
-		local item_path = cwd .. "/" .. item
-		output, code = run_cmd("rm -rf " .. item_path)
-		if code ~= 0 then
-			io.stderr:write("warning: failed to remove " .. item .. ": " .. output .. "\n")
-		end
-	end
-
-	-- Summary
-	print("Converted to wt bare structure")
-	print("Bare repo:  " .. bare_path)
-	print("Worktree:   " .. worktree_path)
-	if #orphaned > 0 then
-		print("Removed:    " .. #orphaned .. " items from root")
-	end
-end
+local init_mod = require("wt.cmd.init")
+local cmd_init = init_mod.cmd_init
 
 -- Main entry point
 

src/wt/cmd/init.lua 🔗

@@ -0,0 +1,259 @@
+-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+--
+-- SPDX-License-Identifier: GPL-3.0-or-later
+
+local exit = require("wt.exit")
+local shell = require("wt.shell")
+local git = require("wt.git")
+local path_mod = require("wt.path")
+local config = require("wt.config")
+
+---@class wt.cmd.init
+local M = {}
+
+---List directory entries (excluding . and ..)
+---@param path string
+---@return string[]
+local function list_dir(path)
+	local entries = {}
+	local handle = io.popen("ls -A " .. path .. " 2>/dev/null")
+	if not handle then
+		return entries
+	end
+	for line in handle:lines() do
+		if line ~= "" then
+			table.insert(entries, line)
+		end
+	end
+	handle:close()
+	return entries
+end
+
+---Check if path is a directory
+---@param path string
+---@return boolean
+local function is_dir(path)
+	local f = io.open(path, "r")
+	if not f then
+		return false
+	end
+	f:close()
+	return shell.run_cmd_silent("test -d " .. path)
+end
+
+---Check if path is a file (not directory)
+---@param path string
+---@return boolean
+local function is_file(path)
+	local f = io.open(path, "r")
+	if not f then
+		return false
+	end
+	f:close()
+	return shell.run_cmd_silent("test -f " .. path)
+end
+
+---Convert existing git repository to wt bare structure
+---@param args string[]
+function M.cmd_init(args)
+	-- Parse arguments
+	local dry_run = false
+	local skip_confirm = false
+	for _, a in ipairs(args) do
+		if a == "--dry-run" then
+			dry_run = true
+		elseif a == "-y" or a == "--yes" then
+			skip_confirm = true
+		else
+			shell.die("unexpected argument: " .. a)
+		end
+	end
+
+	local cwd = shell.get_cwd()
+	if not cwd then
+		shell.die("failed to get current directory", exit.EXIT_SYSTEM_ERROR)
+		return
+	end
+
+	-- Case 1: Already wt-managed (.git file pointing to .bare + .bare/ exists)
+	local git_path = cwd .. "/.git"
+	local bare_path = cwd .. "/.bare"
+
+	local bare_exists = is_dir(bare_path)
+	local git_file = io.open(git_path, "r")
+
+	if git_file then
+		local content = git_file:read("*a")
+		git_file:close()
+
+		-- Check if it's a file (not directory) pointing to .bare
+		if is_file(git_path) and content and content:match("gitdir:%s*%.?/?%.bare") then
+			if bare_exists then
+				print("Already using wt bare structure")
+				os.exit(exit.EXIT_SUCCESS)
+			end
+		end
+
+		-- Check if .git is a file pointing elsewhere (inside a worktree)
+		if is_file(git_path) and content and content:match("^gitdir:") then
+			-- It's a worktree, not project root
+			shell.die("inside a worktree; run from project root or use 'wt c' to clone fresh")
+		end
+	end
+
+	-- Check for .git directory
+	local git_dir_exists = is_dir(git_path)
+
+	if not git_dir_exists then
+		-- Case 5: No .git at all, or bare repo without .git dir
+		if bare_exists then
+			shell.die("found .bare/ but no .git; manually create .git file with 'gitdir: ./.bare'")
+		end
+		shell.die("not a git repository (no .git found)")
+	end
+
+	-- Now we have a .git directory
+	-- Case 3: Existing worktree setup (.git/worktrees/ exists)
+	local worktrees_path = git_path .. "/worktrees"
+	if is_dir(worktrees_path) then
+		local worktrees = list_dir(worktrees_path)
+		io.stderr:write("error: repository already uses git worktrees\n")
+		io.stderr:write("\n")
+		io.stderr:write("Converting an existing worktree setup is complex and error-prone.\n")
+		io.stderr:write("Recommend: clone fresh with 'wt c <url>' and recreate worktrees.\n")
+		if #worktrees > 0 then
+			io.stderr:write("\nExisting worktrees:\n")
+			for _, wt in ipairs(worktrees) do
+				io.stderr:write("  " .. wt .. "\n")
+			end
+		end
+		os.exit(exit.EXIT_USER_ERROR)
+	end
+
+	-- Case 4: Normal clone (.git/ directory, no worktrees)
+	-- Check for uncommitted changes
+	local status_out = shell.run_cmd("git status --porcelain")
+	if status_out ~= "" then
+		shell.die("uncommitted changes; commit or stash before converting")
+	end
+
+	-- Detect default branch
+	local default_branch = git.detect_cloned_default_branch(git_path)
+
+	-- Warnings
+	local warnings = {}
+
+	-- Check for submodules
+	if is_file(cwd .. "/.gitmodules") then
+		table.insert(warnings, "submodules detected (.gitmodules exists); verify they work after conversion")
+	end
+
+	-- Check for nested .git directories (excluding the main one)
+	local nested_git_output, _ = shell.run_cmd("find " .. cwd .. " -mindepth 2 -name .git -type d 2>/dev/null")
+	if nested_git_output ~= "" then
+		table.insert(warnings, "nested .git directories found; these may cause issues")
+	end
+
+	-- Find orphaned files (files in root that will be deleted)
+	local all_entries = list_dir(cwd)
+	local orphaned = {}
+	for _, entry in ipairs(all_entries) do
+		if entry ~= ".git" and entry ~= ".bare" then
+			table.insert(orphaned, entry)
+		end
+	end
+
+	-- Load global config for path style
+	local global_config = config.load_global_config()
+	local style = global_config.branch_path_style or "nested"
+	local separator = global_config.flat_separator
+	local worktree_path = path_mod.branch_to_path(cwd, default_branch, style, separator)
+
+	if dry_run then
+		print("Dry run - planned actions:")
+		print("")
+		print("1. Move .git/ to .bare/")
+		print("2. Create .git file pointing to .bare/")
+		print("3. Create worktree: " .. worktree_path .. " (" .. default_branch .. ")")
+		if #orphaned > 0 then
+			print("4. Remove " .. #orphaned .. " orphaned items from root:")
+			for _, item in ipairs(orphaned) do
+				print("   - " .. item)
+			end
+		end
+		if #warnings > 0 then
+			print("")
+			print("Warnings:")
+			for _, w in ipairs(warnings) do
+				print("  ⚠ " .. w)
+			end
+		end
+		os.exit(exit.EXIT_SUCCESS)
+	end
+
+	-- Show warnings
+	for _, w in ipairs(warnings) do
+		io.stderr:write("warning: " .. w .. "\n")
+	end
+
+	-- Confirm with gum (unless -y/--yes)
+	if not skip_confirm then
+		local confirm_msg = "Convert to wt bare structure? This will move .git to .bare"
+		if #orphaned > 0 then
+			confirm_msg = confirm_msg .. " and remove " .. #orphaned .. " items from root"
+		end
+
+		local confirm_code = os.execute("gum confirm '" .. confirm_msg .. "'")
+		if confirm_code ~= true then
+			print("Aborted")
+			os.exit(exit.EXIT_USER_ERROR)
+		end
+	end
+
+	-- Step 1: Move .git to .bare
+	local output, code = shell.run_cmd("mv " .. git_path .. " " .. bare_path)
+	if code ~= 0 then
+		shell.die("failed to move .git to .bare: " .. output, exit.EXIT_SYSTEM_ERROR)
+	end
+
+	-- Step 2: Write .git file
+	local git_file_handle = io.open(git_path, "w")
+	if not git_file_handle then
+		-- Try to recover
+		shell.run_cmd("mv " .. bare_path .. " " .. git_path)
+		shell.die("failed to create .git file", exit.EXIT_SYSTEM_ERROR)
+		return
+	end
+	git_file_handle:write("gitdir: ./.bare\n")
+	git_file_handle:close()
+
+	-- Step 3: Detach HEAD so branch can be checked out in worktree
+	-- Point bare repo's HEAD to a placeholder so main branch can be used by worktree
+	shell.run_cmd("GIT_DIR=" .. bare_path .. " git symbolic-ref HEAD refs/heads/__wt_detached_placeholder__")
+
+	-- Step 4: Create worktree for default branch
+	output, code =
+		shell.run_cmd("GIT_DIR=" .. bare_path .. " git worktree add -- " .. worktree_path .. " " .. default_branch)
+	if code ~= 0 then
+		shell.die("failed to create worktree: " .. output, exit.EXIT_SYSTEM_ERROR)
+	end
+
+	-- Step 5: Remove orphaned files from root
+	for _, item in ipairs(orphaned) do
+		local item_path = cwd .. "/" .. item
+		output, code = shell.run_cmd("rm -rf " .. item_path)
+		if code ~= 0 then
+			io.stderr:write("warning: failed to remove " .. item .. ": " .. output .. "\n")
+		end
+	end
+
+	-- Summary
+	print("Converted to wt bare structure")
+	print("Bare repo:  " .. bare_path)
+	print("Worktree:   " .. worktree_path)
+	if #orphaned > 0 then
+		print("Removed:    " .. #orphaned .. " items from root")
+	end
+end
+
+return M