fix(init): preserve untracked/ignored files

Amolith created

Previously, wt init would delete globally-ignored and untracked files
from the project root after creating the worktree. Since git worktree
add only creates tracked files, these files were permanently lost.

Now wt init:
- Uses git status --porcelain=v1 -z --ignored=matching to detect all
file states
- Aborts only on tracked modifications (staged/unstaged changes)
- Copies untracked and ignored files to the worktree before cleanup
- Checks for destination conflicts to prevent overwriting tracked files
- Properly quotes all shell arguments to handle special characters

Assisted-by: Claude Opus 4.5 via Amp

Change summary

dist/wt                | 197 ++++++++++++++++++++++++++++++++++++++-----
spec/cmd_init_spec.lua | 137 ++++++++++++++++++++++++++++++
src/wt/cmd/init.lua    | 197 ++++++++++++++++++++++++++++++++++++++-----
3 files changed, 479 insertions(+), 52 deletions(-)

Detailed changes

dist/wt 🔗

@@ -1940,7 +1940,7 @@ local M = {}
 ---@return string[]
 local function list_dir(path)
 	local entries = {}
-	local handle = io.popen("ls -A " .. path .. " 2>/dev/null")
+	local handle = io.popen("ls -A " .. shell.quote(path) .. " 2>/dev/null")
 	if not handle then
 		return entries
 	end
@@ -1953,6 +1953,87 @@ local function list_dir(path)
 	return entries
 end
 
+---@class FileStatus
+---@field path string
+---@field status string
+
+---Parse git status output to classify files
+---@return FileStatus[] tracked_changes Files with tracked modifications (abort condition)
+---@return FileStatus[] untracked Untracked files (to preserve)
+---@return FileStatus[] ignored Ignored files (to preserve)
+local function parse_git_status()
+	local tracked_changes = {}
+	local untracked = {}
+	local ignored = {}
+
+	local handle = io.popen("git status --porcelain=v1 -z --ignored=matching 2>&1")
+	if not handle then
+		return tracked_changes, untracked, ignored
+	end
+
+	local output = handle:read("*a") or ""
+	handle:close()
+
+	-- Parse NUL-delimited records: "XY path\0" or "XY old\0new\0" for renames
+	local i = 1
+	while i <= #output do
+		-- Find next NUL
+		local nul_pos = output:find("\0", i, true)
+		if not nul_pos then
+			break
+		end
+
+		local record = output:sub(i, nul_pos - 1)
+		if #record >= 3 then
+			local xy = record:sub(1, 2)
+			local file_path = record:sub(4)
+
+			if xy == "??" then
+				table.insert(untracked, { path = file_path, status = xy })
+			elseif xy == "!!" then
+				table.insert(ignored, { path = file_path, status = xy })
+			else
+				table.insert(tracked_changes, { path = file_path, status = xy })
+			end
+
+			-- Handle renames (R) and copies (C) which have two paths
+			if xy:sub(1, 1) == "R" or xy:sub(1, 1) == "C" then
+				-- Skip the next NUL-delimited field (the new path)
+				local next_nul = output:find("\0", nul_pos + 1, true)
+				if next_nul then
+					nul_pos = next_nul
+				end
+			end
+		end
+
+		i = nul_pos + 1
+	end
+
+	return tracked_changes, untracked, ignored
+end
+
+---Copy a file or directory to destination, preserving metadata
+---@param src string Source path
+---@param dest string Destination path
+---@return boolean success
+---@return string? error_msg
+local function copy_preserve(src, dest)
+	-- Create parent directory if needed
+	local parent = dest:match("(.+)/[^/]+$")
+	if parent then
+		local _, code = shell.run_cmd("mkdir -p " .. shell.quote(parent))
+		if code ~= 0 then
+			return false, "failed to create parent directory"
+		end
+	end
+
+	local output, code = shell.run_cmd("cp -a " .. shell.quote(src) .. " " .. shell.quote(dest))
+	if code ~= 0 then
+		return false, output
+	end
+	return true, nil
+end
+
 ---Check if path is a directory
 ---@param path string
 ---@return boolean
@@ -2055,15 +2136,29 @@ function M.cmd_init(args)
 	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
+	-- Parse git status to classify files
+	local tracked_changes, untracked, ignored = parse_git_status()
+
+	-- Abort on tracked modifications (staged or unstaged)
+	if #tracked_changes > 0 then
 		io.stderr:write("error: uncommitted changes\n")
-		io.stderr:write("hint: commit, or stash and restore after:\n")
+		for _, f in ipairs(tracked_changes) do
+			io.stderr:write("  " .. f.status .. " " .. f.path .. "\n")
+		end
+		io.stderr:write("\nhint: commit, or stash and restore after:\n")
 		io.stderr:write("  git stash -u && wt init && cd <worktree> && git stash pop\n")
 		os.exit(exit.EXIT_USER_ERROR)
 	end
 
+	-- Collect files to preserve (untracked + ignored)
+	local to_preserve = {}
+	for _, f in ipairs(untracked) do
+		table.insert(to_preserve, { path = f.path, kind = "untracked" })
+	end
+	for _, f in ipairs(ignored) do
+		table.insert(to_preserve, { path = f.path, kind = "ignored" })
+	end
+
 	-- Detect default branch
 	local default_branch = git.detect_cloned_default_branch(git_path)
 
@@ -2076,12 +2171,13 @@ function M.cmd_init(args)
 	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")
+	local nested_git_output, _ =
+		shell.run_cmd("find " .. shell.quote(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)
+	-- Find orphaned files (files in root that will be deleted after preservation)
 	local all_entries = list_dir(cwd)
 	local orphaned = {}
 	for _, entry in ipairs(all_entries) do
@@ -2102,12 +2198,16 @@ function M.cmd_init(args)
 		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)
+		if #to_preserve > 0 then
+			print("4. Copy " .. #to_preserve .. " untracked/ignored items to worktree:")
+			for _, item in ipairs(to_preserve) do
+				print("   - " .. item.path .. " (" .. item.kind .. ")")
 			end
 		end
+		if #orphaned > 0 then
+			local step = #to_preserve > 0 and "5" or "4"
+			print(step .. ". Remove " .. #orphaned .. " items from root after worktree is ready")
+		end
 		if #warnings > 0 then
 			print("")
 			print("Warnings:")
@@ -2123,16 +2223,22 @@ function M.cmd_init(args)
 		io.stderr:write("warning: " .. w .. "\n")
 	end
 
-	-- Prominent warning about file deletion
-	if #orphaned > 0 then
+	-- Show what will be preserved
+	if #to_preserve > 0 then
 		io.stderr:write("\n")
-		io.stderr:write("WARNING: The following " .. #orphaned .. " items will be DELETED from project root:\n")
-		for _, item in ipairs(orphaned) do
-			io.stderr:write("  - " .. item .. "\n")
+		io.stderr:write(
+			"The following " .. #to_preserve .. " untracked/ignored items will be copied to the new worktree:\n"
+		)
+		for _, item in ipairs(to_preserve) do
+			io.stderr:write("  - " .. item.path .. " (" .. item.kind .. ")\n")
 		end
 		io.stderr:write("\n")
-		io.stderr:write("These files are preserved in the new worktree at:\n")
-		io.stderr:write("  " .. worktree_path .. "\n")
+	end
+
+	-- Show what will be deleted from root
+	if #orphaned > 0 then
+		io.stderr:write("After copying, " .. #orphaned .. " items will be removed from project root.\n")
+		io.stderr:write("Worktree location: " .. worktree_path .. "\n")
 		io.stderr:write("\n")
 	end
 
@@ -2146,7 +2252,7 @@ function M.cmd_init(args)
 	end
 
 	-- Step 1: Move .git to .bare
-	local output, code = shell.run_cmd("mv " .. git_path .. " " .. bare_path)
+	local output, code = shell.run_cmd("mv " .. shell.quote(git_path) .. " " .. shell.quote(bare_path))
 	if code ~= 0 then
 		shell.die("failed to move .git to .bare: " .. output, exit.EXIT_SYSTEM_ERROR)
 	end
@@ -2155,7 +2261,7 @@ function M.cmd_init(args)
 	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.run_cmd("mv " .. shell.quote(bare_path) .. " " .. shell.quote(git_path))
 		shell.die("failed to create .git file", exit.EXIT_SYSTEM_ERROR)
 		return
 	end
@@ -2164,19 +2270,55 @@ function M.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
-	shell.run_cmd("GIT_DIR=" .. bare_path .. " git symbolic-ref HEAD refs/heads/__wt_detached_placeholder__")
+	shell.run_cmd(
+		"GIT_DIR=" .. shell.quote(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)
+	output, code = shell.run_cmd(
+		"GIT_DIR="
+			.. shell.quote(bare_path)
+			.. " git worktree add -- "
+			.. shell.quote(worktree_path)
+			.. " "
+			.. shell.quote(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
+	-- Step 5: Copy untracked/ignored files to worktree before deleting
+	local copy_failed = false
+	for _, item in ipairs(to_preserve) do
+		local src = cwd .. "/" .. item.path
+		local dest = worktree_path .. "/" .. item.path
+
+		-- Check for conflict: destination already exists
+		local dest_exists = shell.run_cmd_silent("test -e " .. shell.quote(dest))
+		if dest_exists then
+			io.stderr:write("error: destination already exists: " .. dest .. "\n")
+			io.stderr:write("hint: this may be a tracked file; cannot overwrite\n")
+			copy_failed = true
+		else
+			local ok, err = copy_preserve(src, dest)
+			if not ok then
+				io.stderr:write("error: failed to copy " .. item.path .. ": " .. (err or "unknown error") .. "\n")
+				copy_failed = true
+			end
+		end
+	end
+
+	if copy_failed then
+		io.stderr:write("\nConversion partially complete but some files could not be preserved.\n")
+		io.stderr:write("The worktree exists at: " .. worktree_path .. "\n")
+		io.stderr:write("Original files remain in project root; manually copy them before deleting.\n")
+		os.exit(exit.EXIT_SYSTEM_ERROR)
+	end
+
+	-- Step 6: 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)
+		output, code = shell.run_cmd("rm -rf " .. shell.quote(item_path))
 		if code ~= 0 then
 			io.stderr:write("warning: failed to remove " .. item .. ": " .. output .. "\n")
 		end
@@ -2186,8 +2328,11 @@ function M.cmd_init(args)
 	print("Converted to wt bare structure")
 	print("Bare repo:  " .. bare_path)
 	print("Worktree:   " .. worktree_path)
+	if #to_preserve > 0 then
+		print("Preserved:  " .. #to_preserve .. " untracked/ignored items")
+	end
 	if #orphaned > 0 then
-		print("Removed:    " .. #orphaned .. " items from root")
+		print("Cleaned:    " .. #orphaned .. " items from root")
 	end
 end
 

spec/cmd_init_spec.lua 🔗

@@ -353,4 +353,141 @@ describe("cmd_init", function()
 			assert.is_truthy(output:match("submodule") or output:match("[Ww]arning"))
 		end)
 	end)
+
+	describe("preserving untracked and ignored files", function()
+		it("preserves untracked files in the new worktree", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = temp_dir .. "/preserve-untracked"
+			os.execute("mkdir -p " .. project)
+			git("init " .. project)
+			git("-C " .. project .. " commit --allow-empty -m 'initial'")
+
+			-- Create an untracked file
+			local f = io.open(project .. "/untracked.txt", "w")
+			if f then
+				f:write("untracked content\n")
+				f:close()
+			end
+
+			local _, code = wt.run_cmd("cd " .. project .. " && lua " .. original_cwd .. "/src/main.lua init -y 2>&1")
+			assert.are.equal(0, code)
+
+			-- Find the worktree path (main or master)
+			local worktree = project .. "/main"
+			local check = io.open(worktree .. "/.git", "r")
+			if not check then
+				worktree = project .. "/master"
+				check = io.open(worktree .. "/.git", "r")
+			end
+			if check then
+				check:close()
+			end
+
+			-- Verify untracked file was preserved in worktree
+			local preserved = io.open(worktree .. "/untracked.txt", "r")
+			assert.is_not_nil(preserved, "untracked file should be preserved in worktree")
+			if preserved then
+				local content = preserved:read("*a")
+				preserved:close()
+				assert.are.equal("untracked content\n", content)
+			end
+
+			-- Verify it was removed from root
+			local root_file = io.open(project .. "/untracked.txt", "r")
+			assert.is_nil(root_file, "untracked file should be removed from root")
+		end)
+
+		it("preserves ignored files in the new worktree", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = temp_dir .. "/preserve-ignored"
+			os.execute("mkdir -p " .. project)
+			git("init " .. project)
+
+			-- Create .gitignore and commit it
+			local gitignore = io.open(project .. "/.gitignore", "w")
+			if gitignore then
+				gitignore:write("*.secret\n")
+				gitignore:write("cache/\n")
+				gitignore:close()
+			end
+			git("-C " .. project .. " add .gitignore")
+			git("-C " .. project .. " commit -m 'add gitignore'")
+
+			-- Create ignored files
+			local secret = io.open(project .. "/config.secret", "w")
+			if secret then
+				secret:write("secret data\n")
+				secret:close()
+			end
+			os.execute("mkdir -p " .. project .. "/cache")
+			local cache_file = io.open(project .. "/cache/data.bin", "w")
+			if cache_file then
+				cache_file:write("cached data\n")
+				cache_file:close()
+			end
+
+			local _, code = wt.run_cmd("cd " .. project .. " && lua " .. original_cwd .. "/src/main.lua init -y 2>&1")
+			assert.are.equal(0, code)
+
+			-- Find the worktree path
+			local worktree = project .. "/main"
+			local check = io.open(worktree .. "/.git", "r")
+			if not check then
+				worktree = project .. "/master"
+				check = io.open(worktree .. "/.git", "r")
+			end
+			if check then
+				check:close()
+			end
+
+			-- Verify ignored file was preserved
+			local preserved_secret = io.open(worktree .. "/config.secret", "r")
+			assert.is_not_nil(preserved_secret, "ignored file should be preserved in worktree")
+			if preserved_secret then
+				local content = preserved_secret:read("*a")
+				preserved_secret:close()
+				assert.are.equal("secret data\n", content)
+			end
+
+			-- Verify ignored directory was preserved
+			local preserved_cache = io.open(worktree .. "/cache/data.bin", "r")
+			assert.is_not_nil(preserved_cache, "ignored directory should be preserved in worktree")
+			if preserved_cache then
+				preserved_cache:close()
+			end
+
+			-- Verify removed from root
+			local root_secret = io.open(project .. "/config.secret", "r")
+			assert.is_nil(root_secret, "ignored file should be removed from root")
+		end)
+
+		it("shows untracked/ignored files in dry-run output", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = temp_dir .. "/dry-run-preserve"
+			os.execute("mkdir -p " .. project)
+			git("init " .. project)
+			git("-C " .. project .. " commit --allow-empty -m 'initial'")
+
+			-- Create untracked file
+			local f = io.open(project .. "/notes.txt", "w")
+			if f then
+				f:write("notes\n")
+				f:close()
+			end
+
+			local cmd = "cd " .. project .. " && lua " .. original_cwd .. "/src/main.lua init --dry-run 2>&1"
+			local output, code = wt.run_cmd(cmd)
+			assert.are.equal(0, code)
+			assert.is_truthy(output:match("notes.txt") or output:match("untracked"))
+		end)
+	end)
 end)

src/wt/cmd/init.lua 🔗

@@ -16,7 +16,7 @@ local M = {}
 ---@return string[]
 local function list_dir(path)
 	local entries = {}
-	local handle = io.popen("ls -A " .. path .. " 2>/dev/null")
+	local handle = io.popen("ls -A " .. shell.quote(path) .. " 2>/dev/null")
 	if not handle then
 		return entries
 	end
@@ -29,6 +29,87 @@ local function list_dir(path)
 	return entries
 end
 
+---@class FileStatus
+---@field path string
+---@field status string
+
+---Parse git status output to classify files
+---@return FileStatus[] tracked_changes Files with tracked modifications (abort condition)
+---@return FileStatus[] untracked Untracked files (to preserve)
+---@return FileStatus[] ignored Ignored files (to preserve)
+local function parse_git_status()
+	local tracked_changes = {}
+	local untracked = {}
+	local ignored = {}
+
+	local handle = io.popen("git status --porcelain=v1 -z --ignored=matching 2>&1")
+	if not handle then
+		return tracked_changes, untracked, ignored
+	end
+
+	local output = handle:read("*a") or ""
+	handle:close()
+
+	-- Parse NUL-delimited records: "XY path\0" or "XY old\0new\0" for renames
+	local i = 1
+	while i <= #output do
+		-- Find next NUL
+		local nul_pos = output:find("\0", i, true)
+		if not nul_pos then
+			break
+		end
+
+		local record = output:sub(i, nul_pos - 1)
+		if #record >= 3 then
+			local xy = record:sub(1, 2)
+			local file_path = record:sub(4)
+
+			if xy == "??" then
+				table.insert(untracked, { path = file_path, status = xy })
+			elseif xy == "!!" then
+				table.insert(ignored, { path = file_path, status = xy })
+			else
+				table.insert(tracked_changes, { path = file_path, status = xy })
+			end
+
+			-- Handle renames (R) and copies (C) which have two paths
+			if xy:sub(1, 1) == "R" or xy:sub(1, 1) == "C" then
+				-- Skip the next NUL-delimited field (the new path)
+				local next_nul = output:find("\0", nul_pos + 1, true)
+				if next_nul then
+					nul_pos = next_nul
+				end
+			end
+		end
+
+		i = nul_pos + 1
+	end
+
+	return tracked_changes, untracked, ignored
+end
+
+---Copy a file or directory to destination, preserving metadata
+---@param src string Source path
+---@param dest string Destination path
+---@return boolean success
+---@return string? error_msg
+local function copy_preserve(src, dest)
+	-- Create parent directory if needed
+	local parent = dest:match("(.+)/[^/]+$")
+	if parent then
+		local _, code = shell.run_cmd("mkdir -p " .. shell.quote(parent))
+		if code ~= 0 then
+			return false, "failed to create parent directory"
+		end
+	end
+
+	local output, code = shell.run_cmd("cp -a " .. shell.quote(src) .. " " .. shell.quote(dest))
+	if code ~= 0 then
+		return false, output
+	end
+	return true, nil
+end
+
 ---Check if path is a directory
 ---@param path string
 ---@return boolean
@@ -131,15 +212,29 @@ function M.cmd_init(args)
 	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
+	-- Parse git status to classify files
+	local tracked_changes, untracked, ignored = parse_git_status()
+
+	-- Abort on tracked modifications (staged or unstaged)
+	if #tracked_changes > 0 then
 		io.stderr:write("error: uncommitted changes\n")
-		io.stderr:write("hint: commit, or stash and restore after:\n")
+		for _, f in ipairs(tracked_changes) do
+			io.stderr:write("  " .. f.status .. " " .. f.path .. "\n")
+		end
+		io.stderr:write("\nhint: commit, or stash and restore after:\n")
 		io.stderr:write("  git stash -u && wt init && cd <worktree> && git stash pop\n")
 		os.exit(exit.EXIT_USER_ERROR)
 	end
 
+	-- Collect files to preserve (untracked + ignored)
+	local to_preserve = {}
+	for _, f in ipairs(untracked) do
+		table.insert(to_preserve, { path = f.path, kind = "untracked" })
+	end
+	for _, f in ipairs(ignored) do
+		table.insert(to_preserve, { path = f.path, kind = "ignored" })
+	end
+
 	-- Detect default branch
 	local default_branch = git.detect_cloned_default_branch(git_path)
 
@@ -152,12 +247,13 @@ function M.cmd_init(args)
 	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")
+	local nested_git_output, _ =
+		shell.run_cmd("find " .. shell.quote(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)
+	-- Find orphaned files (files in root that will be deleted after preservation)
 	local all_entries = list_dir(cwd)
 	local orphaned = {}
 	for _, entry in ipairs(all_entries) do
@@ -178,12 +274,16 @@ function M.cmd_init(args)
 		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)
+		if #to_preserve > 0 then
+			print("4. Copy " .. #to_preserve .. " untracked/ignored items to worktree:")
+			for _, item in ipairs(to_preserve) do
+				print("   - " .. item.path .. " (" .. item.kind .. ")")
 			end
 		end
+		if #orphaned > 0 then
+			local step = #to_preserve > 0 and "5" or "4"
+			print(step .. ". Remove " .. #orphaned .. " items from root after worktree is ready")
+		end
 		if #warnings > 0 then
 			print("")
 			print("Warnings:")
@@ -199,16 +299,22 @@ function M.cmd_init(args)
 		io.stderr:write("warning: " .. w .. "\n")
 	end
 
-	-- Prominent warning about file deletion
-	if #orphaned > 0 then
+	-- Show what will be preserved
+	if #to_preserve > 0 then
 		io.stderr:write("\n")
-		io.stderr:write("WARNING: The following " .. #orphaned .. " items will be DELETED from project root:\n")
-		for _, item in ipairs(orphaned) do
-			io.stderr:write("  - " .. item .. "\n")
+		io.stderr:write(
+			"The following " .. #to_preserve .. " untracked/ignored items will be copied to the new worktree:\n"
+		)
+		for _, item in ipairs(to_preserve) do
+			io.stderr:write("  - " .. item.path .. " (" .. item.kind .. ")\n")
 		end
 		io.stderr:write("\n")
-		io.stderr:write("These files are preserved in the new worktree at:\n")
-		io.stderr:write("  " .. worktree_path .. "\n")
+	end
+
+	-- Show what will be deleted from root
+	if #orphaned > 0 then
+		io.stderr:write("After copying, " .. #orphaned .. " items will be removed from project root.\n")
+		io.stderr:write("Worktree location: " .. worktree_path .. "\n")
 		io.stderr:write("\n")
 	end
 
@@ -222,7 +328,7 @@ function M.cmd_init(args)
 	end
 
 	-- Step 1: Move .git to .bare
-	local output, code = shell.run_cmd("mv " .. git_path .. " " .. bare_path)
+	local output, code = shell.run_cmd("mv " .. shell.quote(git_path) .. " " .. shell.quote(bare_path))
 	if code ~= 0 then
 		shell.die("failed to move .git to .bare: " .. output, exit.EXIT_SYSTEM_ERROR)
 	end
@@ -231,7 +337,7 @@ function M.cmd_init(args)
 	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.run_cmd("mv " .. shell.quote(bare_path) .. " " .. shell.quote(git_path))
 		shell.die("failed to create .git file", exit.EXIT_SYSTEM_ERROR)
 		return
 	end
@@ -240,19 +346,55 @@ function M.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
-	shell.run_cmd("GIT_DIR=" .. bare_path .. " git symbolic-ref HEAD refs/heads/__wt_detached_placeholder__")
+	shell.run_cmd(
+		"GIT_DIR=" .. shell.quote(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)
+	output, code = shell.run_cmd(
+		"GIT_DIR="
+			.. shell.quote(bare_path)
+			.. " git worktree add -- "
+			.. shell.quote(worktree_path)
+			.. " "
+			.. shell.quote(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
+	-- Step 5: Copy untracked/ignored files to worktree before deleting
+	local copy_failed = false
+	for _, item in ipairs(to_preserve) do
+		local src = cwd .. "/" .. item.path
+		local dest = worktree_path .. "/" .. item.path
+
+		-- Check for conflict: destination already exists
+		local dest_exists = shell.run_cmd_silent("test -e " .. shell.quote(dest))
+		if dest_exists then
+			io.stderr:write("error: destination already exists: " .. dest .. "\n")
+			io.stderr:write("hint: this may be a tracked file; cannot overwrite\n")
+			copy_failed = true
+		else
+			local ok, err = copy_preserve(src, dest)
+			if not ok then
+				io.stderr:write("error: failed to copy " .. item.path .. ": " .. (err or "unknown error") .. "\n")
+				copy_failed = true
+			end
+		end
+	end
+
+	if copy_failed then
+		io.stderr:write("\nConversion partially complete but some files could not be preserved.\n")
+		io.stderr:write("The worktree exists at: " .. worktree_path .. "\n")
+		io.stderr:write("Original files remain in project root; manually copy them before deleting.\n")
+		os.exit(exit.EXIT_SYSTEM_ERROR)
+	end
+
+	-- Step 6: 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)
+		output, code = shell.run_cmd("rm -rf " .. shell.quote(item_path))
 		if code ~= 0 then
 			io.stderr:write("warning: failed to remove " .. item .. ": " .. output .. "\n")
 		end
@@ -262,8 +404,11 @@ function M.cmd_init(args)
 	print("Converted to wt bare structure")
 	print("Bare repo:  " .. bare_path)
 	print("Worktree:   " .. worktree_path)
+	if #to_preserve > 0 then
+		print("Preserved:  " .. #to_preserve .. " untracked/ignored items")
+	end
 	if #orphaned > 0 then
-		print("Removed:    " .. #orphaned .. " items from root")
+		print("Cleaned:    " .. #orphaned .. " items from root")
 	end
 end