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
 68---@param args string[]
 69local function cmd_clone(args)
 70	-- Parse arguments: <url> [--remote name]... [--own]
 71	local url = nil
 72	---@type string[]
 73	local remote_flags = {}
 74	local own = false
 75
 76	local i = 1
 77	while i <= #args do
 78		local a = args[i]
 79		if a == "--remote" then
 80			if not args[i + 1] then
 81				die("--remote requires a name")
 82			end
 83			table.insert(remote_flags, args[i + 1])
 84			i = i + 1
 85		elseif a == "--own" then
 86			own = true
 87		elseif not url then
 88			url = a
 89		else
 90			die("unexpected argument: " .. a)
 91		end
 92		i = i + 1
 93	end
 94
 95	if not url then
 96		die("usage: wt c <url> [--remote name]... [--own]")
 97		return
 98	end
 99
100	-- Extract project name from URL
101	local project_name = extract_project_name(url)
102	if not project_name then
103		die("could not extract project name from URL: " .. url)
104		return
105	end
106
107	-- Check if project directory already exists
108	local cwd = get_cwd()
109	if not cwd then
110		die("failed to get current directory", EXIT_SYSTEM_ERROR)
111	end
112	local project_path = cwd .. "/" .. project_name
113	local check = io.open(project_path, "r")
114	if check then
115		check:close()
116		die("directory already exists: " .. project_path)
117	end
118
119	-- Clone bare repo
120	local bare_path = project_path .. "/.bare"
121	local output, code = run_cmd("git clone --bare " .. url .. " " .. bare_path)
122	if code ~= 0 then
123		die("failed to clone: " .. output, EXIT_SYSTEM_ERROR)
124	end
125
126	-- Write .git file pointing to .bare
127	local git_file_handle = io.open(project_path .. "/.git", "w")
128	if not git_file_handle then
129		die("failed to create .git file", EXIT_SYSTEM_ERROR)
130		return
131	end
132	git_file_handle:write("gitdir: ./.bare\n")
133	git_file_handle:close()
134
135	-- Detect default branch
136	local git_dir = bare_path
137	local default_branch = detect_cloned_default_branch(git_dir)
138
139	-- Load global config
140	local global_config = load_global_config()
141
142	-- Determine which remotes to use
143	---@type string[]
144	local selected_remotes = {}
145
146	if #remote_flags > 0 then
147		selected_remotes = remote_flags
148	elseif global_config.default_remotes then
149		if type(global_config.default_remotes) == "table" then
150			selected_remotes = global_config.default_remotes
151		elseif global_config.default_remotes == "prompt" then
152			if global_config.remotes then
153				local keys = {}
154				for k in pairs(global_config.remotes) do
155					table.insert(keys, k)
156				end
157				table.sort(keys)
158				if #keys > 0 then
159					local input = table.concat(keys, "\n")
160					local choose_type = own and "" or " --no-limit"
161					local cmd = "echo '" .. input .. "' | gum choose" .. choose_type
162					output, code = run_cmd(cmd)
163					if code == 0 and output ~= "" then
164						for line in output:gmatch("[^\n]+") do
165							table.insert(selected_remotes, line)
166						end
167					end
168				end
169			end
170		end
171	elseif global_config.remotes then
172		local keys = {}
173		for k in pairs(global_config.remotes) do
174			table.insert(keys, k)
175		end
176		table.sort(keys)
177		if #keys > 0 then
178			local input = table.concat(keys, "\n")
179			local choose_type = own and "" or " --no-limit"
180			local cmd = "echo '" .. input .. "' | gum choose" .. choose_type
181			output, code = run_cmd(cmd)
182			if code == 0 and output ~= "" then
183				for line in output:gmatch("[^\n]+") do
184					table.insert(selected_remotes, line)
185				end
186			end
187		end
188	end
189
190	-- Track configured remotes for summary
191	---@type string[]
192	local configured_remotes = {}
193
194	if own then
195		-- User's own project: origin is their canonical remote
196		if #selected_remotes > 0 then
197			local first_remote = selected_remotes[1]
198			-- Rename origin to first remote
199			output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote rename origin " .. first_remote)
200			if code ~= 0 then
201				io.stderr:write("warning: failed to rename origin to " .. first_remote .. ": " .. output .. "\n")
202			else
203				-- Configure fetch refspec
204				run_cmd(
205					"GIT_DIR="
206						.. git_dir
207						.. " git config remote."
208						.. first_remote
209						.. ".fetch '+refs/heads/*:refs/remotes/"
210						.. first_remote
211						.. "/*'"
212				)
213				table.insert(configured_remotes, first_remote)
214			end
215
216			-- Add additional remotes and push to them
217			for j = 2, #selected_remotes do
218				local remote_name = selected_remotes[j]
219				local template = global_config.remotes and global_config.remotes[remote_name]
220				if template then
221					local remote_url = resolve_url_template(template, project_name)
222					output, code =
223						run_cmd("GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. remote_url)
224					if code ~= 0 then
225						io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
226					else
227						run_cmd(
228							"GIT_DIR="
229								.. git_dir
230								.. " git config remote."
231								.. remote_name
232								.. ".fetch '+refs/heads/*:refs/remotes/"
233								.. remote_name
234								.. "/*'"
235						)
236						-- Push to additional remotes
237						output, code =
238							run_cmd("GIT_DIR=" .. git_dir .. " git push " .. remote_name .. " " .. default_branch)
239						if code ~= 0 then
240							io.stderr:write("warning: failed to push to " .. remote_name .. ": " .. output .. "\n")
241						end
242						table.insert(configured_remotes, remote_name)
243					end
244				else
245					io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
246				end
247			end
248		else
249			-- No remotes selected, keep origin as-is
250			run_cmd("GIT_DIR=" .. git_dir .. " git config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'")
251			table.insert(configured_remotes, "origin")
252		end
253	else
254		-- Contributing to someone else's project
255		-- Rename origin to upstream
256		output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote rename origin upstream")
257		if code ~= 0 then
258			io.stderr:write("warning: failed to rename origin to upstream: " .. output .. "\n")
259		else
260			run_cmd(
261				"GIT_DIR=" .. git_dir .. " git config remote.upstream.fetch '+refs/heads/*:refs/remotes/upstream/*'"
262			)
263			table.insert(configured_remotes, "upstream")
264		end
265
266		-- Add user's remotes and push to each
267		for _, remote_name in ipairs(selected_remotes) do
268			local template = global_config.remotes and global_config.remotes[remote_name]
269			if template then
270				local remote_url = resolve_url_template(template, project_name)
271				output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. remote_url)
272				if code ~= 0 then
273					io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
274				else
275					run_cmd(
276						"GIT_DIR="
277							.. git_dir
278							.. " git config remote."
279							.. remote_name
280							.. ".fetch '+refs/heads/*:refs/remotes/"
281							.. remote_name
282							.. "/*'"
283					)
284					-- Push to this remote
285					output, code =
286						run_cmd("GIT_DIR=" .. git_dir .. " git push " .. remote_name .. " " .. default_branch)
287					if code ~= 0 then
288						io.stderr:write("warning: failed to push to " .. remote_name .. ": " .. output .. "\n")
289					end
290					table.insert(configured_remotes, remote_name)
291				end
292			else
293				io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
294			end
295		end
296	end
297
298	-- Fetch all remotes
299	run_cmd("GIT_DIR=" .. git_dir .. " git fetch --all")
300
301	-- Load config for path style
302	local style = global_config.branch_path_style or "nested"
303	local separator = global_config.flat_separator
304	local worktree_path = branch_to_path(project_path, default_branch, style, separator)
305
306	-- Create initial worktree
307	output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -- " .. worktree_path .. " " .. default_branch)
308	if code ~= 0 then
309		die("failed to create worktree: " .. output, EXIT_SYSTEM_ERROR)
310	end
311
312	-- Print summary
313	print("Created project: " .. project_path)
314	print("Default branch:  " .. default_branch)
315	print("Worktree:        " .. worktree_path)
316	if #configured_remotes > 0 then
317		print("Remotes:         " .. table.concat(configured_remotes, ", "))
318	end
319end
320
321---List directory entries (excluding . and ..)
322---@param path string
323---@return string[]
324local function list_dir(path)
325	local entries = {}
326	local handle = io.popen("ls -A " .. path .. " 2>/dev/null")
327	if not handle then
328		return entries
329	end
330	for line in handle:lines() do
331		if line ~= "" then
332			table.insert(entries, line)
333		end
334	end
335	handle:close()
336	return entries
337end
338
339---Check if path is a directory
340---@param path string
341---@return boolean
342local function is_dir(path)
343	local f = io.open(path, "r")
344	if not f then
345		return false
346	end
347	f:close()
348	return run_cmd_silent("test -d " .. path)
349end
350
351---Check if path is a file (not directory)
352---@param path string
353---@return boolean
354local function is_file(path)
355	local f = io.open(path, "r")
356	if not f then
357		return false
358	end
359	f:close()
360	return run_cmd_silent("test -f " .. path)
361end
362
363---@param args string[]
364local function cmd_init(args)
365	-- Parse arguments
366	local dry_run = false
367	local skip_confirm = false
368	for _, a in ipairs(args) do
369		if a == "--dry-run" then
370			dry_run = true
371		elseif a == "-y" or a == "--yes" then
372			skip_confirm = true
373		else
374			die("unexpected argument: " .. a)
375		end
376	end
377
378	local cwd = get_cwd()
379	if not cwd then
380		die("failed to get current directory", EXIT_SYSTEM_ERROR)
381		return
382	end
383
384	-- Case 1: Already wt-managed (.git file pointing to .bare + .bare/ exists)
385	local git_path = cwd .. "/.git"
386	local bare_path = cwd .. "/.bare"
387
388	local bare_exists = is_dir(bare_path)
389	local git_file = io.open(git_path, "r")
390
391	if git_file then
392		local content = git_file:read("*a")
393		git_file:close()
394
395		-- Check if it's a file (not directory) pointing to .bare
396		if is_file(git_path) and content and content:match("gitdir:%s*%.?/?%.bare") then
397			if bare_exists then
398				print("Already using wt bare structure")
399				os.exit(EXIT_SUCCESS)
400			end
401		end
402
403		-- Check if .git is a file pointing elsewhere (inside a worktree)
404		if is_file(git_path) and content and content:match("^gitdir:") then
405			-- It's a worktree, not project root
406			die("inside a worktree; run from project root or use 'wt c' to clone fresh")
407		end
408	end
409
410	-- Check for .git directory
411	local git_dir_exists = is_dir(git_path)
412
413	if not git_dir_exists then
414		-- Case 5: No .git at all, or bare repo without .git dir
415		if bare_exists then
416			die("found .bare/ but no .git; manually create .git file with 'gitdir: ./.bare'")
417		end
418		die("not a git repository (no .git found)")
419	end
420
421	-- Now we have a .git directory
422	-- Case 3: Existing worktree setup (.git/worktrees/ exists)
423	local worktrees_path = git_path .. "/worktrees"
424	if is_dir(worktrees_path) then
425		local worktrees = list_dir(worktrees_path)
426		io.stderr:write("error: repository already uses git worktrees\n")
427		io.stderr:write("\n")
428		io.stderr:write("Converting an existing worktree setup is complex and error-prone.\n")
429		io.stderr:write("Recommend: clone fresh with 'wt c <url>' and recreate worktrees.\n")
430		if #worktrees > 0 then
431			io.stderr:write("\nExisting worktrees:\n")
432			for _, wt in ipairs(worktrees) do
433				io.stderr:write("  " .. wt .. "\n")
434			end
435		end
436		os.exit(EXIT_USER_ERROR)
437	end
438
439	-- Case 4: Normal clone (.git/ directory, no worktrees)
440	-- Check for uncommitted changes
441	local status_out = run_cmd("git status --porcelain")
442	if status_out ~= "" then
443		die("uncommitted changes; commit or stash before converting")
444	end
445
446	-- Detect default branch
447	local default_branch = detect_cloned_default_branch(git_path)
448
449	-- Warnings
450	local warnings = {}
451
452	-- Check for submodules
453	if is_file(cwd .. "/.gitmodules") then
454		table.insert(warnings, "submodules detected (.gitmodules exists); verify they work after conversion")
455	end
456
457	-- Check for nested .git directories (excluding the main one)
458	local nested_git_output, _ = run_cmd("find " .. cwd .. " -mindepth 2 -name .git -type d 2>/dev/null")
459	if nested_git_output ~= "" then
460		table.insert(warnings, "nested .git directories found; these may cause issues")
461	end
462
463	-- Find orphaned files (files in root that will be deleted)
464	local all_entries = list_dir(cwd)
465	local orphaned = {}
466	for _, entry in ipairs(all_entries) do
467		if entry ~= ".git" and entry ~= ".bare" then
468			table.insert(orphaned, entry)
469		end
470	end
471
472	-- Load global config for path style
473	local global_config = load_global_config()
474	local style = global_config.branch_path_style or "nested"
475	local separator = global_config.flat_separator
476	local worktree_path = branch_to_path(cwd, default_branch, style, separator)
477
478	if dry_run then
479		print("Dry run - planned actions:")
480		print("")
481		print("1. Move .git/ to .bare/")
482		print("2. Create .git file pointing to .bare/")
483		print("3. Create worktree: " .. worktree_path .. " (" .. default_branch .. ")")
484		if #orphaned > 0 then
485			print("4. Remove " .. #orphaned .. " orphaned items from root:")
486			for _, item in ipairs(orphaned) do
487				print("   - " .. item)
488			end
489		end
490		if #warnings > 0 then
491			print("")
492			print("Warnings:")
493			for _, w in ipairs(warnings) do
494				print("" .. w)
495			end
496		end
497		os.exit(EXIT_SUCCESS)
498	end
499
500	-- Show warnings
501	for _, w in ipairs(warnings) do
502		io.stderr:write("warning: " .. w .. "\n")
503	end
504
505	-- Confirm with gum (unless -y/--yes)
506	if not skip_confirm then
507		local confirm_msg = "Convert to wt bare structure? This will move .git to .bare"
508		if #orphaned > 0 then
509			confirm_msg = confirm_msg .. " and remove " .. #orphaned .. " items from root"
510		end
511
512		local confirm_code = os.execute("gum confirm '" .. confirm_msg .. "'")
513		if confirm_code ~= true then
514			print("Aborted")
515			os.exit(EXIT_USER_ERROR)
516		end
517	end
518
519	-- Step 1: Move .git to .bare
520	local output, code = run_cmd("mv " .. git_path .. " " .. bare_path)
521	if code ~= 0 then
522		die("failed to move .git to .bare: " .. output, EXIT_SYSTEM_ERROR)
523	end
524
525	-- Step 2: Write .git file
526	local git_file_handle = io.open(git_path, "w")
527	if not git_file_handle then
528		-- Try to recover
529		run_cmd("mv " .. bare_path .. " " .. git_path)
530		die("failed to create .git file", EXIT_SYSTEM_ERROR)
531		return
532	end
533	git_file_handle:write("gitdir: ./.bare\n")
534	git_file_handle:close()
535
536	-- Step 3: Detach HEAD so branch can be checked out in worktree
537	-- Point bare repo's HEAD to a placeholder so main branch can be used by worktree
538	run_cmd("GIT_DIR=" .. bare_path .. " git symbolic-ref HEAD refs/heads/__wt_detached_placeholder__")
539
540	-- Step 4: Create worktree for default branch
541	output, code = run_cmd("GIT_DIR=" .. bare_path .. " git worktree add -- " .. worktree_path .. " " .. default_branch)
542	if code ~= 0 then
543		die("failed to create worktree: " .. output, EXIT_SYSTEM_ERROR)
544	end
545
546	-- Step 5: Remove orphaned files from root
547	for _, item in ipairs(orphaned) do
548		local item_path = cwd .. "/" .. item
549		output, code = run_cmd("rm -rf " .. item_path)
550		if code ~= 0 then
551			io.stderr:write("warning: failed to remove " .. item .. ": " .. output .. "\n")
552		end
553	end
554
555	-- Summary
556	print("Converted to wt bare structure")
557	print("Bare repo:  " .. bare_path)
558	print("Worktree:   " .. worktree_path)
559	if #orphaned > 0 then
560		print("Removed:    " .. #orphaned .. " items from root")
561	end
562end
563
564-- Main entry point
565
566local function main()
567	local command = arg[1]
568
569	if not command or command == "help" or command == "--help" or command == "-h" then
570		print_usage()
571		os.exit(EXIT_SUCCESS)
572	end
573
574	---@cast command string
575
576	-- Collect remaining args
577	local subargs = {}
578	for i = 2, #arg do
579		table.insert(subargs, arg[i])
580	end
581
582	-- Check for --help on any command
583	if subargs[1] == "--help" or subargs[1] == "-h" then
584		show_command_help(command)
585	end
586
587	if command == "c" then
588		cmd_clone(subargs)
589	elseif command == "n" then
590		cmd_new(subargs)
591	elseif command == "a" then
592		cmd_add(subargs)
593	elseif command == "r" then
594		cmd_remove(subargs)
595	elseif command == "l" then
596		cmd_list()
597	elseif command == "f" then
598		cmd_fetch()
599	elseif command == "init" then
600		cmd_init(subargs)
601	else
602		die("unknown command: " .. command)
603	end
604end
605
606-- Export for testing when required as module
607if pcall(debug.getlocal, 4, 1) then
608	return {
609		-- URL/project parsing
610		extract_project_name = extract_project_name,
611		resolve_url_template = resolve_url_template,
612		-- Path manipulation
613		branch_to_path = branch_to_path,
614		split_path = split_path,
615		relative_path = relative_path,
616		path_inside = path_inside,
617		-- Config loading
618		load_global_config = load_global_config,
619		load_project_config = load_project_config,
620		-- Git output parsing (testable without git)
621		parse_branch_remotes = parse_branch_remotes,
622		parse_worktree_list = parse_worktree_list,
623		escape_pattern = escape_pattern,
624		-- Hook helpers (re-exported from wt.hooks)
625		summarize_hooks = summarize_hooks,
626		load_hook_permissions = load_hook_permissions,
627		save_hook_permissions = save_hook_permissions,
628		run_hooks = run_hooks,
629		-- Project root detection (re-exported from wt.git)
630		find_project_root = find_project_root,
631		detect_source_worktree = detect_source_worktree,
632		-- Command execution (for integration tests)
633		run_cmd = run_cmd,
634		run_cmd_silent = run_cmd_silent,
635		-- Exit codes (re-exported from wt.exit)
636		EXIT_SUCCESS = exit.EXIT_SUCCESS,
637		EXIT_USER_ERROR = exit.EXIT_USER_ERROR,
638		EXIT_SYSTEM_ERROR = exit.EXIT_SYSTEM_ERROR,
639	}
640end
641
642main()