Detailed changes
@@ -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
@@ -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)
@@ -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