init.lua

  1-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2--
  3-- SPDX-License-Identifier: GPL-3.0-or-later
  4
  5local exit = require("wt.exit")
  6local shell = require("wt.shell")
  7local git = require("wt.git")
  8local path_mod = require("wt.path")
  9local config = require("wt.config")
 10
 11---@class wt.cmd.init
 12local M = {}
 13
 14---List directory entries (excluding . and ..)
 15---@param path string
 16---@return string[]
 17local function list_dir(path)
 18	local entries = {}
 19	local handle = io.popen("ls -A " .. shell.quote(path) .. " 2>/dev/null")
 20	if not handle then
 21		return entries
 22	end
 23	for line in handle:lines() do
 24		if line ~= "" then
 25			table.insert(entries, line)
 26		end
 27	end
 28	handle:close()
 29	return entries
 30end
 31
 32---@class FileStatus
 33---@field path string
 34---@field status string
 35
 36---Parse git status output to classify files
 37---@return FileStatus[] tracked_changes Files with tracked modifications (abort condition)
 38---@return FileStatus[] untracked Untracked files (to preserve)
 39---@return FileStatus[] ignored Ignored files (to preserve)
 40local function parse_git_status()
 41	local tracked_changes = {}
 42	local untracked = {}
 43	local ignored = {}
 44
 45	local handle = io.popen("git status --porcelain=v1 -z --ignored=matching 2>&1")
 46	if not handle then
 47		return tracked_changes, untracked, ignored
 48	end
 49
 50	local output = handle:read("*a") or ""
 51	handle:close()
 52
 53	-- Parse NUL-delimited records: "XY path\0" or "XY old\0new\0" for renames
 54	local i = 1
 55	while i <= #output do
 56		-- Find next NUL
 57		local nul_pos = output:find("\0", i, true)
 58		if not nul_pos then
 59			break
 60		end
 61
 62		local record = output:sub(i, nul_pos - 1)
 63		if #record >= 3 then
 64			local xy = record:sub(1, 2)
 65			local file_path = record:sub(4)
 66
 67			if xy == "??" then
 68				table.insert(untracked, { path = file_path, status = xy })
 69			elseif xy == "!!" then
 70				table.insert(ignored, { path = file_path, status = xy })
 71			else
 72				table.insert(tracked_changes, { path = file_path, status = xy })
 73			end
 74
 75			-- Handle renames (R) and copies (C) which have two paths
 76			if xy:sub(1, 1) == "R" or xy:sub(1, 1) == "C" then
 77				-- Skip the next NUL-delimited field (the new path)
 78				local next_nul = output:find("\0", nul_pos + 1, true)
 79				if next_nul then
 80					nul_pos = next_nul
 81				end
 82			end
 83		end
 84
 85		i = nul_pos + 1
 86	end
 87
 88	return tracked_changes, untracked, ignored
 89end
 90
 91---Copy a file or directory to destination, preserving metadata
 92---@param src string Source path
 93---@param dest string Destination path
 94---@return boolean success
 95---@return string? error_msg
 96local function copy_preserve(src, dest)
 97	-- Create parent directory if needed
 98	local parent = dest:match("(.+)/[^/]+$")
 99	if parent then
100		local _, code = shell.run_cmd("mkdir -p " .. shell.quote(parent))
101		if code ~= 0 then
102			return false, "failed to create parent directory"
103		end
104	end
105
106	local output, code = shell.run_cmd("cp -a " .. shell.quote(src) .. " " .. shell.quote(dest))
107	if code ~= 0 then
108		return false, output
109	end
110	return true, nil
111end
112
113---Check if path is a directory
114---@param path string
115---@return boolean
116local function is_dir(path)
117	local f = io.open(path, "r")
118	if not f then
119		return false
120	end
121	f:close()
122	return shell.run_cmd_silent("test -d " .. path)
123end
124
125---Check if path is a file (not directory)
126---@param path string
127---@return boolean
128local function is_file(path)
129	local f = io.open(path, "r")
130	if not f then
131		return false
132	end
133	f:close()
134	return shell.run_cmd_silent("test -f " .. path)
135end
136
137---Convert existing git repository to wt bare structure
138---@param args string[]
139function M.cmd_init(args)
140	-- Parse arguments
141	local dry_run = false
142	local skip_confirm = false
143	for _, a in ipairs(args) do
144		if a == "--dry-run" then
145			dry_run = true
146		elseif a == "-y" or a == "--yes" then
147			skip_confirm = true
148		else
149			shell.die("unexpected argument: " .. a)
150		end
151	end
152
153	local cwd = shell.get_cwd()
154	if not cwd then
155		shell.die("failed to get current directory", exit.EXIT_SYSTEM_ERROR)
156		return
157	end
158
159	-- Case 1: Already wt-managed (.git file pointing to .bare + .bare/ exists)
160	local git_path = cwd .. "/.git"
161	local bare_path = cwd .. "/.bare"
162
163	local bare_exists = is_dir(bare_path)
164	local git_file = io.open(git_path, "r")
165
166	if git_file then
167		local content = git_file:read("*a")
168		git_file:close()
169
170		-- Check if it's a file (not directory) pointing to .bare
171		if is_file(git_path) and content and content:match("gitdir:%s*%.?/?%.bare") then
172			if bare_exists then
173				print("Already using wt bare structure")
174				os.exit(exit.EXIT_SUCCESS)
175			end
176		end
177
178		-- Check if .git is a file pointing elsewhere (inside a worktree)
179		if is_file(git_path) and content and content:match("^gitdir:") then
180			-- It's a worktree, not project root
181			shell.die("inside a worktree; run from project root or use 'wt c' to clone fresh")
182		end
183	end
184
185	-- Check for .git directory
186	local git_dir_exists = is_dir(git_path)
187
188	if not git_dir_exists then
189		-- Case 5: No .git at all, or bare repo without .git dir
190		if bare_exists then
191			shell.die("found .bare/ but no .git; manually create .git file with 'gitdir: ./.bare'")
192		end
193		shell.die("not a git repository (no .git found)")
194	end
195
196	-- Now we have a .git directory
197	-- Case 3: Existing worktree setup (.git/worktrees/ exists)
198	local worktrees_path = git_path .. "/worktrees"
199	if is_dir(worktrees_path) then
200		local worktrees = list_dir(worktrees_path)
201		io.stderr:write("error: repository already uses git worktrees\n")
202		io.stderr:write("\n")
203		io.stderr:write("Converting an existing worktree setup is complex and error-prone.\n")
204		io.stderr:write("Recommend: clone fresh with 'wt c <url>' and recreate worktrees.\n")
205		if #worktrees > 0 then
206			io.stderr:write("\nExisting worktrees:\n")
207			for _, wt in ipairs(worktrees) do
208				io.stderr:write("  " .. wt .. "\n")
209			end
210		end
211		os.exit(exit.EXIT_USER_ERROR)
212	end
213
214	-- Case 4: Normal clone (.git/ directory, no worktrees)
215	-- Parse git status to classify files
216	local tracked_changes, untracked, ignored = parse_git_status()
217
218	-- Abort on tracked modifications (staged or unstaged)
219	if #tracked_changes > 0 then
220		io.stderr:write("error: uncommitted changes\n")
221		for _, f in ipairs(tracked_changes) do
222			io.stderr:write("  " .. f.status .. " " .. f.path .. "\n")
223		end
224		io.stderr:write("\nhint: commit, or stash and restore after:\n")
225		io.stderr:write("  git stash -u && wt init && cd <worktree> && git stash pop\n")
226		os.exit(exit.EXIT_USER_ERROR)
227	end
228
229	-- Collect files to preserve (untracked + ignored)
230	local to_preserve = {}
231	for _, f in ipairs(untracked) do
232		table.insert(to_preserve, { path = f.path, kind = "untracked" })
233	end
234	for _, f in ipairs(ignored) do
235		table.insert(to_preserve, { path = f.path, kind = "ignored" })
236	end
237
238	-- Detect default branch
239	local default_branch = git.detect_cloned_default_branch(git_path)
240
241	-- Warnings
242	local warnings = {}
243
244	-- Check for submodules
245	if is_file(cwd .. "/.gitmodules") then
246		table.insert(warnings, "submodules detected (.gitmodules exists); verify they work after conversion")
247	end
248
249	-- Check for nested .git directories (excluding the main one)
250	local nested_git_output, _ =
251		shell.run_cmd("find " .. shell.quote(cwd) .. " -mindepth 2 -name .git -type d 2>/dev/null")
252	if nested_git_output ~= "" then
253		table.insert(warnings, "nested .git directories found; these may cause issues")
254	end
255
256	-- Find orphaned files (files in root that will be deleted after preservation)
257	local all_entries = list_dir(cwd)
258	local orphaned = {}
259	for _, entry in ipairs(all_entries) do
260		if entry ~= ".git" and entry ~= ".bare" then
261			table.insert(orphaned, entry)
262		end
263	end
264
265	-- Load global config for path style
266	local global_config = config.load_global_config()
267	local style = global_config.branch_path_style or "nested"
268	local separator = global_config.flat_separator
269	local worktree_path = path_mod.branch_to_path(cwd, default_branch, style, separator)
270
271	if dry_run then
272		print("Dry run - planned actions:")
273		print("")
274		print("1. Move .git/ to .bare/")
275		print("2. Create .git file pointing to .bare/")
276		print("3. Create worktree: " .. worktree_path .. " (" .. default_branch .. ")")
277		if #to_preserve > 0 then
278			print("4. Copy " .. #to_preserve .. " untracked/ignored items to worktree:")
279			for _, item in ipairs(to_preserve) do
280				print("   - " .. item.path .. " (" .. item.kind .. ")")
281			end
282		end
283		if #orphaned > 0 then
284			local step = #to_preserve > 0 and "5" or "4"
285			print(step .. ". Remove " .. #orphaned .. " items from root after worktree is ready")
286		end
287		if #warnings > 0 then
288			print("")
289			print("Warnings:")
290			for _, w in ipairs(warnings) do
291				print("" .. w)
292			end
293		end
294		os.exit(exit.EXIT_SUCCESS)
295	end
296
297	-- Show warnings
298	for _, w in ipairs(warnings) do
299		io.stderr:write("warning: " .. w .. "\n")
300	end
301
302	-- Show what will be preserved
303	if #to_preserve > 0 then
304		io.stderr:write("\n")
305		io.stderr:write(
306			"The following " .. #to_preserve .. " untracked/ignored items will be copied to the new worktree:\n"
307		)
308		for _, item in ipairs(to_preserve) do
309			io.stderr:write("  - " .. item.path .. " (" .. item.kind .. ")\n")
310		end
311		io.stderr:write("\n")
312	end
313
314	-- Show what will be deleted from root
315	if #orphaned > 0 then
316		io.stderr:write("After copying, " .. #orphaned .. " items will be removed from project root.\n")
317		io.stderr:write("Worktree location: " .. worktree_path .. "\n")
318		io.stderr:write("\n")
319	end
320
321	-- Confirm with gum (unless -y/--yes)
322	if not skip_confirm then
323		local confirm_code = os.execute("gum confirm 'Convert to wt bare structure?'")
324		if confirm_code ~= true then
325			print("Aborted")
326			os.exit(exit.EXIT_USER_ERROR)
327		end
328	end
329
330	-- Step 1: Move .git to .bare
331	local output, code = shell.run_cmd("mv " .. shell.quote(git_path) .. " " .. shell.quote(bare_path))
332	if code ~= 0 then
333		shell.die("failed to move .git to .bare: " .. output, exit.EXIT_SYSTEM_ERROR)
334	end
335
336	-- Step 2: Write .git file
337	local git_file_handle = io.open(git_path, "w")
338	if not git_file_handle then
339		-- Try to recover
340		shell.run_cmd("mv " .. shell.quote(bare_path) .. " " .. shell.quote(git_path))
341		shell.die("failed to create .git file", exit.EXIT_SYSTEM_ERROR)
342		return
343	end
344	git_file_handle:write("gitdir: ./.bare\n")
345	git_file_handle:close()
346
347	-- Step 3: Detach HEAD so branch can be checked out in worktree
348	-- Point bare repo's HEAD to a placeholder so main branch can be used by worktree
349	shell.run_cmd(
350		"GIT_DIR=" .. shell.quote(bare_path) .. " git symbolic-ref HEAD refs/heads/__wt_detached_placeholder__"
351	)
352
353	-- Step 4: Create worktree for default branch
354	output, code = shell.run_cmd(
355		"GIT_DIR="
356			.. shell.quote(bare_path)
357			.. " git worktree add -- "
358			.. shell.quote(worktree_path)
359			.. " "
360			.. shell.quote(default_branch)
361	)
362	if code ~= 0 then
363		shell.die("failed to create worktree: " .. output, exit.EXIT_SYSTEM_ERROR)
364	end
365
366	-- Step 5: Copy untracked/ignored files to worktree before deleting
367	local copy_failed = false
368	for _, item in ipairs(to_preserve) do
369		local src = cwd .. "/" .. item.path
370		local dest = worktree_path .. "/" .. item.path
371
372		-- Check for conflict: destination already exists
373		local dest_exists = shell.run_cmd_silent("test -e " .. shell.quote(dest))
374		if dest_exists then
375			io.stderr:write("error: destination already exists: " .. dest .. "\n")
376			io.stderr:write("hint: this may be a tracked file; cannot overwrite\n")
377			copy_failed = true
378		else
379			local ok, err = copy_preserve(src, dest)
380			if not ok then
381				io.stderr:write("error: failed to copy " .. item.path .. ": " .. (err or "unknown error") .. "\n")
382				copy_failed = true
383			end
384		end
385	end
386
387	if copy_failed then
388		io.stderr:write("\nConversion partially complete but some files could not be preserved.\n")
389		io.stderr:write("The worktree exists at: " .. worktree_path .. "\n")
390		io.stderr:write("Original files remain in project root; manually copy them before deleting.\n")
391		os.exit(exit.EXIT_SYSTEM_ERROR)
392	end
393
394	-- Step 6: Remove orphaned files from root
395	for _, item in ipairs(orphaned) do
396		local item_path = cwd .. "/" .. item
397		output, code = shell.run_cmd("rm -rf " .. shell.quote(item_path))
398		if code ~= 0 then
399			io.stderr:write("warning: failed to remove " .. item .. ": " .. output .. "\n")
400		end
401	end
402
403	-- Summary
404	print("Converted to wt bare structure")
405	print("Bare repo:  " .. bare_path)
406	print("Worktree:   " .. worktree_path)
407	if #to_preserve > 0 then
408		print("Preserved:  " .. #to_preserve .. " untracked/ignored items")
409	end
410	if #orphaned > 0 then
411		print("Cleaned:    " .. #orphaned .. " items from root")
412	end
413end
414
415return M