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