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()