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