clone.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.clone
 12local M = {}
 13
 14---Clone a repository with bare repo structure
 15---@param args string[]
 16function M.cmd_clone(args)
 17	-- Parse arguments: <url> [--remote name]... [--own]
 18	---@type string|nil
 19	local url
 20	---@type string[]
 21	local remote_flags = {}
 22	local own = false
 23
 24	local i = 1
 25	-- Flags can appear anywhere in the argument list
 26	local positional = {}
 27	while i <= #args do
 28		local a = args[i]
 29		if a == "--remote" then
 30			if not args[i + 1] then
 31				shell.die("--remote requires a name")
 32			end
 33			table.insert(remote_flags, args[i + 1])
 34			i = i + 1
 35		elseif a == "--own" then
 36			own = true
 37		elseif a:match("^%-") then
 38			shell.die("unknown flag: " .. a)
 39		else
 40			table.insert(positional, a)
 41		end
 42		i = i + 1
 43	end
 44
 45	if #positional == 0 then
 46		shell.die("usage: wt c <url> [--remote name]... [--own]")
 47		return
 48	elseif #positional > 1 then
 49		shell.die("unexpected argument: " .. positional[2])
 50	end
 51	url = positional[1]
 52	---@cast url string
 53
 54	-- Extract project name from URL
 55	local project_name = config.extract_project_name(url)
 56	if not project_name then
 57		shell.die("could not extract project name from URL: " .. url)
 58		return
 59	end
 60
 61	-- Check if project directory already exists
 62	local cwd = shell.get_cwd()
 63	if not cwd then
 64		shell.die("failed to get current directory", exit.EXIT_SYSTEM_ERROR)
 65	end
 66	---@cast cwd string
 67	local project_path = cwd .. "/" .. project_name
 68	local check = io.open(project_path, "r")
 69	if check then
 70		check:close()
 71		shell.die("directory already exists: " .. project_path)
 72	end
 73
 74	-- Clone bare repo
 75	local bare_path = project_path .. "/.bare"
 76	local output, code = shell.run_cmd("git clone --bare " .. url .. " " .. bare_path)
 77	if code ~= 0 then
 78		shell.die("failed to clone: " .. output, exit.EXIT_SYSTEM_ERROR)
 79	end
 80
 81	-- Write .git file pointing to .bare
 82	local git_file_handle = io.open(project_path .. "/.git", "w")
 83	if not git_file_handle then
 84		shell.die("failed to create .git file", exit.EXIT_SYSTEM_ERROR)
 85		return
 86	end
 87	git_file_handle:write("gitdir: ./.bare\n")
 88	git_file_handle:close()
 89
 90	-- Detect default branch
 91	local git_dir = bare_path
 92	local default_branch = git.detect_cloned_default_branch(git_dir)
 93
 94	-- Load global config
 95	local global_config = config.load_global_config()
 96
 97	-- Determine which remotes to use
 98	---@type string[]
 99	local selected_remotes = {}
100
101	if #remote_flags > 0 then
102		selected_remotes = remote_flags
103	elseif global_config.default_remotes then
104		if type(global_config.default_remotes) == "table" then
105			selected_remotes = global_config.default_remotes --[[@as string[] ]]
106		---@diagnostic disable-next-line: unnecessary-if
107		elseif global_config.default_remotes == "prompt" then
108			if global_config.remotes then
109				local keys = {}
110				for k in pairs(global_config.remotes) do
111					table.insert(keys, k)
112				end
113				table.sort(keys)
114				if #keys > 0 then
115					local input = table.concat(keys, "\n")
116					local choose_type = own and "" or " --no-limit"
117					local cmd = "echo '" .. input .. "' | gum choose" .. choose_type
118					output, code = shell.run_cmd(cmd)
119					if code == 0 and output ~= "" then
120						for line in output:gmatch("[^\n]+") do
121							table.insert(selected_remotes, line)
122						end
123					end
124				end
125			end
126		end
127	elseif global_config.remotes then
128		local keys = {}
129		for k in pairs(global_config.remotes) do
130			table.insert(keys, k)
131		end
132		table.sort(keys)
133		if #keys > 0 then
134			local input = table.concat(keys, "\n")
135			local choose_type = own and "" or " --no-limit"
136			local cmd = "echo '" .. input .. "' | gum choose" .. choose_type
137			output, code = shell.run_cmd(cmd)
138			if code == 0 and output ~= "" then
139				for line in output:gmatch("[^\n]+") do
140					table.insert(selected_remotes, line)
141				end
142			end
143		end
144	end
145
146	-- Track configured remotes for summary
147	---@type string[]
148	local configured_remotes = {}
149
150	if own then
151		-- User's own project: origin is their canonical remote
152		if #selected_remotes > 0 then
153			local first_remote = selected_remotes[1]
154			-- Rename origin to first remote
155			output, code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git remote rename origin " .. first_remote)
156			if code ~= 0 then
157				io.stderr:write("warning: failed to rename origin to " .. first_remote .. ": " .. output .. "\n")
158			else
159				-- Configure fetch refspec
160				shell.run_cmd(
161					"GIT_DIR="
162						.. git_dir
163						.. " git config remote."
164						.. first_remote
165						.. ".fetch '+refs/heads/*:refs/remotes/"
166						.. first_remote
167						.. "/*'"
168				)
169				table.insert(configured_remotes, first_remote)
170			end
171
172			-- Add additional remotes and push to them
173			local remotes = global_config.remotes
174			for j = 2, #selected_remotes do
175				local remote_name = selected_remotes[j]
176				if remotes then
177					local template = remotes[remote_name]
178					if template then
179						local remote_url = config.resolve_url_template(template, project_name)
180						output, code = shell.run_cmd(
181							"GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. remote_url
182						)
183						if code ~= 0 then
184							io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
185						else
186							shell.run_cmd(
187								"GIT_DIR="
188									.. git_dir
189									.. " git config remote."
190									.. remote_name
191									.. ".fetch '+refs/heads/*:refs/remotes/"
192									.. remote_name
193									.. "/*'"
194							)
195							-- Push to additional remotes
196							output, code = shell.run_cmd(
197								"GIT_DIR=" .. git_dir .. " git push " .. remote_name .. " " .. default_branch
198							)
199							if code ~= 0 then
200								io.stderr:write("warning: failed to push to " .. remote_name .. ": " .. output .. "\n")
201							end
202							table.insert(configured_remotes, remote_name)
203						end
204					else
205						io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
206					end
207				else
208					io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
209				end
210			end
211		else
212			-- No remotes selected, keep origin as-is
213			shell.run_cmd(
214				"GIT_DIR=" .. git_dir .. " git config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'"
215			)
216			table.insert(configured_remotes, "origin")
217		end
218	else
219		-- Contributing to someone else's project
220		-- Rename origin to upstream
221		output, code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git remote rename origin upstream")
222		if code ~= 0 then
223			io.stderr:write("warning: failed to rename origin to upstream: " .. output .. "\n")
224		else
225			shell.run_cmd(
226				"GIT_DIR=" .. git_dir .. " git config remote.upstream.fetch '+refs/heads/*:refs/remotes/upstream/*'"
227			)
228			table.insert(configured_remotes, "upstream")
229		end
230
231		-- Add user's remotes and push to each
232		local remotes = global_config.remotes
233		for _, remote_name in ipairs(selected_remotes) do
234			if remotes then
235				local template = remotes[remote_name]
236				if template then
237					local remote_url = config.resolve_url_template(template, project_name)
238					output, code =
239						shell.run_cmd("GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. remote_url)
240					if code ~= 0 then
241						io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
242					else
243						shell.run_cmd(
244							"GIT_DIR="
245								.. git_dir
246								.. " git config remote."
247								.. remote_name
248								.. ".fetch '+refs/heads/*:refs/remotes/"
249								.. remote_name
250								.. "/*'"
251						)
252						-- Push to this remote
253						output, code =
254							shell.run_cmd("GIT_DIR=" .. git_dir .. " git push " .. remote_name .. " " .. default_branch)
255						if code ~= 0 then
256							io.stderr:write("warning: failed to push to " .. remote_name .. ": " .. output .. "\n")
257						end
258						table.insert(configured_remotes, remote_name)
259					end
260				else
261					io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
262				end
263			else
264				io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
265			end
266		end
267	end
268
269	-- Fetch all remotes
270	shell.run_cmd("GIT_DIR=" .. git_dir .. " git fetch --all")
271
272	-- Load config for path style
273	local style = global_config.branch_path_style or "nested"
274	local separator = global_config.flat_separator
275	local worktree_path = path_mod.branch_to_path(project_path, default_branch, style, separator)
276
277	-- Create initial worktree
278	output, code =
279		shell.run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -- " .. worktree_path .. " " .. default_branch)
280	if code ~= 0 then
281		shell.die("failed to create worktree: " .. output, exit.EXIT_SYSTEM_ERROR)
282	end
283
284	-- Print summary
285	print("Created project: " .. project_path)
286	print("Default branch:  " .. default_branch)
287	print("Worktree:        " .. worktree_path)
288	if #configured_remotes > 0 then
289		print("Remotes:         " .. table.concat(configured_remotes, ", "))
290	end
291end
292
293return M