From 66cd038bb26cea161b80a73331b3439096b42401 Mon Sep 17 00:00:00 2001 From: Amolith Date: Sun, 18 Jan 2026 17:17:24 -0700 Subject: [PATCH] fix(init): preserve untracked/ignored files 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 --- dist/wt | 197 +++++++++++++++++++++++++++++++++++------ spec/cmd_init_spec.lua | 137 ++++++++++++++++++++++++++++ src/wt/cmd/init.lua | 197 +++++++++++++++++++++++++++++++++++------ 3 files changed, 479 insertions(+), 52 deletions(-) diff --git a/dist/wt b/dist/wt index 18f5c8d89ac6c1abb7300d72d320c42d0144561e..0b8def69e4843f15eed33d30520e0a4622f5dfc6 100755 --- a/dist/wt +++ b/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 && 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 diff --git a/spec/cmd_init_spec.lua b/spec/cmd_init_spec.lua index 054d09bc42a62d9e214b521e02b96735c73744b3..828edea3943e05e86f3830467d4ec40c034f0eb9 100644 --- a/spec/cmd_init_spec.lua +++ b/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) diff --git a/src/wt/cmd/init.lua b/src/wt/cmd/init.lua index baa9bb86f0a5c415ed6c1641430acb04cdbe8042..4483baf80c9ae0940deff9b3d78c697218bcc139 100644 --- a/src/wt/cmd/init.lua +++ b/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 && 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