main.lua

  1#!/usr/bin/env lua
  2
  3-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  4--
  5-- SPDX-License-Identifier: GPL-3.0-or-later
  6
  7if _VERSION < "Lua 5.2" then
  8	io.stderr:write("error: wt requires Lua 5.2 or later\n")
  9	os.exit(1)
 10end
 11
 12local exit = require("wt.exit")
 13local EXIT_SUCCESS = exit.EXIT_SUCCESS
 14local EXIT_USER_ERROR = exit.EXIT_USER_ERROR
 15local EXIT_SYSTEM_ERROR = exit.EXIT_SYSTEM_ERROR
 16
 17local shell = require("wt.shell")
 18local run_cmd = shell.run_cmd
 19local run_cmd_silent = shell.run_cmd_silent
 20local get_cwd = shell.get_cwd
 21local die = shell.die
 22
 23local path_mod = require("wt.path")
 24local branch_to_path = path_mod.branch_to_path
 25local split_path = path_mod.split_path
 26local relative_path = path_mod.relative_path
 27local path_inside = path_mod.path_inside
 28local escape_pattern = path_mod.escape_pattern
 29
 30local git_mod = require("wt.git")
 31local find_project_root = git_mod.find_project_root
 32local detect_source_worktree = git_mod.detect_source_worktree
 33local detect_cloned_default_branch = git_mod.detect_cloned_default_branch
 34local parse_branch_remotes = git_mod.parse_branch_remotes
 35local parse_worktree_list = git_mod.parse_worktree_list
 36
 37local config_mod = require("wt.config")
 38local resolve_url_template = config_mod.resolve_url_template
 39local extract_project_name = config_mod.extract_project_name
 40local load_global_config = config_mod.load_global_config
 41local load_project_config = config_mod.load_project_config
 42
 43local hooks_mod = require("wt.hooks")
 44local load_hook_permissions = hooks_mod.load_hook_permissions
 45local save_hook_permissions = hooks_mod.save_hook_permissions
 46local summarize_hooks = hooks_mod.summarize_hooks
 47local run_hooks = hooks_mod.run_hooks
 48
 49local help_mod = require("wt.help")
 50local print_usage = help_mod.print_usage
 51local show_command_help = help_mod.show_command_help
 52
 53local fetch_mod = require("wt.cmd.fetch")
 54local cmd_fetch = fetch_mod.cmd_fetch
 55
 56local list_mod = require("wt.cmd.list")
 57local cmd_list = list_mod.cmd_list
 58
 59local remove_mod = require("wt.cmd.remove")
 60local cmd_remove = remove_mod.cmd_remove
 61
 62local add_mod = require("wt.cmd.add")
 63local cmd_add = add_mod.cmd_add
 64
 65local new_mod = require("wt.cmd.new")
 66local cmd_new = new_mod.cmd_new
 67
 68local clone_mod = require("wt.cmd.clone")
 69local cmd_clone = clone_mod.cmd_clone
 70
 71---List directory entries (excluding . and ..)
 72---@param path string
 73---@return string[]
 74local function list_dir(path)
 75	local entries = {}
 76	local handle = io.popen("ls -A " .. path .. " 2>/dev/null")
 77	if not handle then
 78		return entries
 79	end
 80	for line in handle:lines() do
 81		if line ~= "" then
 82			table.insert(entries, line)
 83		end
 84	end
 85	handle:close()
 86	return entries
 87end
 88
 89---Check if path is a directory
 90---@param path string
 91---@return boolean
 92local function is_dir(path)
 93	local f = io.open(path, "r")
 94	if not f then
 95		return false
 96	end
 97	f:close()
 98	return run_cmd_silent("test -d " .. path)
 99end
100
101---Check if path is a file (not directory)
102---@param path string
103---@return boolean
104local function is_file(path)
105	local f = io.open(path, "r")
106	if not f then
107		return false
108	end
109	f:close()
110	return run_cmd_silent("test -f " .. path)
111end
112
113---@param args string[]
114local function cmd_init(args)
115	-- Parse arguments
116	local dry_run = false
117	local skip_confirm = false
118	for _, a in ipairs(args) do
119		if a == "--dry-run" then
120			dry_run = true
121		elseif a == "-y" or a == "--yes" then
122			skip_confirm = true
123		else
124			die("unexpected argument: " .. a)
125		end
126	end
127
128	local cwd = get_cwd()
129	if not cwd then
130		die("failed to get current directory", EXIT_SYSTEM_ERROR)
131		return
132	end
133
134	-- Case 1: Already wt-managed (.git file pointing to .bare + .bare/ exists)
135	local git_path = cwd .. "/.git"
136	local bare_path = cwd .. "/.bare"
137
138	local bare_exists = is_dir(bare_path)
139	local git_file = io.open(git_path, "r")
140
141	if git_file then
142		local content = git_file:read("*a")
143		git_file:close()
144
145		-- Check if it's a file (not directory) pointing to .bare
146		if is_file(git_path) and content and content:match("gitdir:%s*%.?/?%.bare") then
147			if bare_exists then
148				print("Already using wt bare structure")
149				os.exit(EXIT_SUCCESS)
150			end
151		end
152
153		-- Check if .git is a file pointing elsewhere (inside a worktree)
154		if is_file(git_path) and content and content:match("^gitdir:") then
155			-- It's a worktree, not project root
156			die("inside a worktree; run from project root or use 'wt c' to clone fresh")
157		end
158	end
159
160	-- Check for .git directory
161	local git_dir_exists = is_dir(git_path)
162
163	if not git_dir_exists then
164		-- Case 5: No .git at all, or bare repo without .git dir
165		if bare_exists then
166			die("found .bare/ but no .git; manually create .git file with 'gitdir: ./.bare'")
167		end
168		die("not a git repository (no .git found)")
169	end
170
171	-- Now we have a .git directory
172	-- Case 3: Existing worktree setup (.git/worktrees/ exists)
173	local worktrees_path = git_path .. "/worktrees"
174	if is_dir(worktrees_path) then
175		local worktrees = list_dir(worktrees_path)
176		io.stderr:write("error: repository already uses git worktrees\n")
177		io.stderr:write("\n")
178		io.stderr:write("Converting an existing worktree setup is complex and error-prone.\n")
179		io.stderr:write("Recommend: clone fresh with 'wt c <url>' and recreate worktrees.\n")
180		if #worktrees > 0 then
181			io.stderr:write("\nExisting worktrees:\n")
182			for _, wt in ipairs(worktrees) do
183				io.stderr:write("  " .. wt .. "\n")
184			end
185		end
186		os.exit(EXIT_USER_ERROR)
187	end
188
189	-- Case 4: Normal clone (.git/ directory, no worktrees)
190	-- Check for uncommitted changes
191	local status_out = run_cmd("git status --porcelain")
192	if status_out ~= "" then
193		die("uncommitted changes; commit or stash before converting")
194	end
195
196	-- Detect default branch
197	local default_branch = detect_cloned_default_branch(git_path)
198
199	-- Warnings
200	local warnings = {}
201
202	-- Check for submodules
203	if is_file(cwd .. "/.gitmodules") then
204		table.insert(warnings, "submodules detected (.gitmodules exists); verify they work after conversion")
205	end
206
207	-- Check for nested .git directories (excluding the main one)
208	local nested_git_output, _ = run_cmd("find " .. cwd .. " -mindepth 2 -name .git -type d 2>/dev/null")
209	if nested_git_output ~= "" then
210		table.insert(warnings, "nested .git directories found; these may cause issues")
211	end
212
213	-- Find orphaned files (files in root that will be deleted)
214	local all_entries = list_dir(cwd)
215	local orphaned = {}
216	for _, entry in ipairs(all_entries) do
217		if entry ~= ".git" and entry ~= ".bare" then
218			table.insert(orphaned, entry)
219		end
220	end
221
222	-- Load global config for path style
223	local global_config = load_global_config()
224	local style = global_config.branch_path_style or "nested"
225	local separator = global_config.flat_separator
226	local worktree_path = branch_to_path(cwd, default_branch, style, separator)
227
228	if dry_run then
229		print("Dry run - planned actions:")
230		print("")
231		print("1. Move .git/ to .bare/")
232		print("2. Create .git file pointing to .bare/")
233		print("3. Create worktree: " .. worktree_path .. " (" .. default_branch .. ")")
234		if #orphaned > 0 then
235			print("4. Remove " .. #orphaned .. " orphaned items from root:")
236			for _, item in ipairs(orphaned) do
237				print("   - " .. item)
238			end
239		end
240		if #warnings > 0 then
241			print("")
242			print("Warnings:")
243			for _, w in ipairs(warnings) do
244				print("" .. w)
245			end
246		end
247		os.exit(EXIT_SUCCESS)
248	end
249
250	-- Show warnings
251	for _, w in ipairs(warnings) do
252		io.stderr:write("warning: " .. w .. "\n")
253	end
254
255	-- Confirm with gum (unless -y/--yes)
256	if not skip_confirm then
257		local confirm_msg = "Convert to wt bare structure? This will move .git to .bare"
258		if #orphaned > 0 then
259			confirm_msg = confirm_msg .. " and remove " .. #orphaned .. " items from root"
260		end
261
262		local confirm_code = os.execute("gum confirm '" .. confirm_msg .. "'")
263		if confirm_code ~= true then
264			print("Aborted")
265			os.exit(EXIT_USER_ERROR)
266		end
267	end
268
269	-- Step 1: Move .git to .bare
270	local output, code = run_cmd("mv " .. git_path .. " " .. bare_path)
271	if code ~= 0 then
272		die("failed to move .git to .bare: " .. output, EXIT_SYSTEM_ERROR)
273	end
274
275	-- Step 2: Write .git file
276	local git_file_handle = io.open(git_path, "w")
277	if not git_file_handle then
278		-- Try to recover
279		run_cmd("mv " .. bare_path .. " " .. git_path)
280		die("failed to create .git file", EXIT_SYSTEM_ERROR)
281		return
282	end
283	git_file_handle:write("gitdir: ./.bare\n")
284	git_file_handle:close()
285
286	-- Step 3: Detach HEAD so branch can be checked out in worktree
287	-- Point bare repo's HEAD to a placeholder so main branch can be used by worktree
288	run_cmd("GIT_DIR=" .. bare_path .. " git symbolic-ref HEAD refs/heads/__wt_detached_placeholder__")
289
290	-- Step 4: Create worktree for default branch
291	output, code = run_cmd("GIT_DIR=" .. bare_path .. " git worktree add -- " .. worktree_path .. " " .. default_branch)
292	if code ~= 0 then
293		die("failed to create worktree: " .. output, EXIT_SYSTEM_ERROR)
294	end
295
296	-- Step 5: Remove orphaned files from root
297	for _, item in ipairs(orphaned) do
298		local item_path = cwd .. "/" .. item
299		output, code = run_cmd("rm -rf " .. item_path)
300		if code ~= 0 then
301			io.stderr:write("warning: failed to remove " .. item .. ": " .. output .. "\n")
302		end
303	end
304
305	-- Summary
306	print("Converted to wt bare structure")
307	print("Bare repo:  " .. bare_path)
308	print("Worktree:   " .. worktree_path)
309	if #orphaned > 0 then
310		print("Removed:    " .. #orphaned .. " items from root")
311	end
312end
313
314-- Main entry point
315
316local function main()
317	local command = arg[1]
318
319	if not command or command == "help" or command == "--help" or command == "-h" then
320		print_usage()
321		os.exit(EXIT_SUCCESS)
322	end
323
324	---@cast command string
325
326	-- Collect remaining args
327	local subargs = {}
328	for i = 2, #arg do
329		table.insert(subargs, arg[i])
330	end
331
332	-- Check for --help on any command
333	if subargs[1] == "--help" or subargs[1] == "-h" then
334		show_command_help(command)
335	end
336
337	if command == "c" then
338		cmd_clone(subargs)
339	elseif command == "n" then
340		cmd_new(subargs)
341	elseif command == "a" then
342		cmd_add(subargs)
343	elseif command == "r" then
344		cmd_remove(subargs)
345	elseif command == "l" then
346		cmd_list()
347	elseif command == "f" then
348		cmd_fetch()
349	elseif command == "init" then
350		cmd_init(subargs)
351	else
352		die("unknown command: " .. command)
353	end
354end
355
356-- Export for testing when required as module
357if pcall(debug.getlocal, 4, 1) then
358	return {
359		-- URL/project parsing
360		extract_project_name = extract_project_name,
361		resolve_url_template = resolve_url_template,
362		-- Path manipulation
363		branch_to_path = branch_to_path,
364		split_path = split_path,
365		relative_path = relative_path,
366		path_inside = path_inside,
367		-- Config loading
368		load_global_config = load_global_config,
369		load_project_config = load_project_config,
370		-- Git output parsing (testable without git)
371		parse_branch_remotes = parse_branch_remotes,
372		parse_worktree_list = parse_worktree_list,
373		escape_pattern = escape_pattern,
374		-- Hook helpers (re-exported from wt.hooks)
375		summarize_hooks = summarize_hooks,
376		load_hook_permissions = load_hook_permissions,
377		save_hook_permissions = save_hook_permissions,
378		run_hooks = run_hooks,
379		-- Project root detection (re-exported from wt.git)
380		find_project_root = find_project_root,
381		detect_source_worktree = detect_source_worktree,
382		-- Command execution (for integration tests)
383		run_cmd = run_cmd,
384		run_cmd_silent = run_cmd_silent,
385		-- Exit codes (re-exported from wt.exit)
386		EXIT_SUCCESS = exit.EXIT_SUCCESS,
387		EXIT_USER_ERROR = exit.EXIT_USER_ERROR,
388		EXIT_SYSTEM_ERROR = exit.EXIT_SYSTEM_ERROR,
389	}
390end
391
392main()