1#!/usr/bin/env lua
2
3-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
4--
5-- SPDX-License-Identifier: GPL-3.0-or-later
6
7-- AUTO-GENERATED FILE - Do not edit directly
8-- Edit src/*.lua and run 'make dist' to regenerate
9
10-- Embedded module loader
11local _EMBEDDED_MODULES = {}
12local _LOADED_MODULES = {}
13local _real_require = require
14
15local function _embedded_require(name)
16 if _LOADED_MODULES[name] then
17 return _LOADED_MODULES[name]
18 end
19 if _EMBEDDED_MODULES[name] then
20 local loader = load(_EMBEDDED_MODULES[name], name)
21 if loader then
22 local result = loader()
23 _LOADED_MODULES[name] = result or true
24 return _LOADED_MODULES[name]
25 end
26 end
27 return _real_require(name)
28end
29require = _embedded_require
30
31_EMBEDDED_MODULES["wt.exit"] = [[-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
32--
33-- SPDX-License-Identifier: GPL-3.0-or-later
34
35---@class wt.exit
36---@field EXIT_SUCCESS integer
37---@field EXIT_USER_ERROR integer
38---@field EXIT_SYSTEM_ERROR integer
39local M = {}
40
41M.EXIT_SUCCESS = 0
42M.EXIT_USER_ERROR = 1
43M.EXIT_SYSTEM_ERROR = 2
44
45return M
46]]
47
48_EMBEDDED_MODULES["wt.shell"] = [[-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
49--
50-- SPDX-License-Identifier: GPL-3.0-or-later
51
52local exit = require("wt.exit")
53
54---@class wt.shell
55local M = {}
56
57---Execute command, return output and exit code
58---@param cmd string
59---@return string output
60---@return integer code
61function M.run_cmd(cmd)
62 local handle = io.popen(cmd .. " 2>&1")
63 if not handle then
64 return "", exit.EXIT_SYSTEM_ERROR
65 end
66 local output = handle:read("*a") or ""
67 local success, _, code = handle:close()
68 if success then
69 return output, 0
70 end
71 return output, code or exit.EXIT_SYSTEM_ERROR
72end
73
74---Execute command silently, return success boolean
75---@param cmd string
76---@return boolean success
77function M.run_cmd_silent(cmd)
78 local success = os.execute(cmd .. " >/dev/null 2>&1")
79 return success == true
80end
81
82---Get current working directory
83---@return string|nil cwd
84function M.get_cwd()
85 local handle = io.popen("pwd")
86 if not handle then
87 return nil
88 end
89 local cwd = handle:read("*l")
90 handle:close()
91 return cwd
92end
93
94---Print error message and exit
95---@param msg string
96---@param code? integer
97function M.die(msg, code)
98 io.stderr:write("error: " .. msg .. "\n")
99 os.exit(code or exit.EXIT_USER_ERROR)
100end
101
102---Quote a string for safe shell use
103---@param str string
104---@return string
105function M.quote(str)
106 return "'" .. str:gsub("'", "'\\''") .. "'"
107end
108
109return M
110]]
111
112_EMBEDDED_MODULES["wt.path"] = [[-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
113--
114-- SPDX-License-Identifier: GPL-3.0-or-later
115
116---@class wt.path
117local M = {}
118
119---Split path into components
120---@param path string
121---@return string[]
122function M.split_path(path)
123 local parts = {}
124 for part in path:gmatch("[^/]+") do
125 table.insert(parts, part)
126 end
127 return parts
128end
129
130---Calculate relative path from one absolute path to another
131---@param from string absolute path of starting directory
132---@param to string absolute path of target
133---@return string relative path
134function M.relative_path(from, to)
135 if from == to then
136 return "./"
137 end
138
139 local from_parts = M.split_path(from)
140 local to_parts = M.split_path(to)
141
142 local common = 0
143 for i = 1, math.min(#from_parts, #to_parts) do
144 if from_parts[i] == to_parts[i] then
145 common = i
146 else
147 break
148 end
149 end
150
151 local up_count = #from_parts - common
152 local result = {}
153
154 for _ = 1, up_count do
155 table.insert(result, "..")
156 end
157
158 for i = common + 1, #to_parts do
159 table.insert(result, to_parts[i])
160 end
161
162 if #result == 0 then
163 return "./"
164 end
165
166 return table.concat(result, "/")
167end
168
169---Convert branch name to worktree path based on style
170---@param root string project root path
171---@param branch string branch name
172---@param style string "nested" or "flat"
173---@param separator? string separator for flat style (default "_")
174---@return string worktree path
175function M.branch_to_path(root, branch, style, separator)
176 if style == "flat" then
177 local sep = separator or "_"
178 local escaped_sep = sep:gsub("%%", "%%%%")
179 local flat_name = branch:gsub("/", escaped_sep)
180 return root .. "/" .. flat_name
181 end
182 -- nested style (default): preserve slashes
183 return root .. "/" .. branch
184end
185
186---Check if path_a is inside (or equal to) path_b
187---@param path_a string the path to check
188---@param path_b string the container path
189---@return boolean
190function M.path_inside(path_a, path_b)
191 -- Normalize: ensure no trailing slash for comparison
192 path_b = path_b:gsub("/$", "")
193 path_a = path_a:gsub("/$", "")
194 return path_a == path_b or path_a:sub(1, #path_b + 1) == path_b .. "/"
195end
196
197---Escape special Lua pattern characters in a string
198---@param str string
199---@return string
200function M.escape_pattern(str)
201 return (str:gsub("([%%%-%+%[%]%(%)%.%^%$%*%?])", "%%%1"))
202end
203
204---Get the parent directory of a path
205---@param path string
206---@return string|nil parent or nil if path has no parent
207function M.parent_dir(path)
208 path = path:gsub("/$", "")
209 local parent = path:match("(.+)/[^/]+$")
210 return parent
211end
212
213return M
214]]
215
216_EMBEDDED_MODULES["wt.git"] = [[-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
217--
218-- SPDX-License-Identifier: GPL-3.0-or-later
219
220local shell = require("wt.shell")
221local run_cmd = shell.run_cmd
222local run_cmd_silent = shell.run_cmd_silent
223local get_cwd = shell.get_cwd
224
225---@class wt.git
226local M = {}
227
228---Walk up from cwd looking for .git file pointing to .bare, or .bare/ directory
229---@param cwd_override? string optional starting directory (defaults to pwd)
230---@return string|nil root
231---@return string|nil error
232function M.find_project_root(cwd_override)
233 local cwd = cwd_override
234 if not cwd then
235 local handle = io.popen("pwd")
236 if not handle then
237 return nil, "failed to get current directory"
238 end
239 cwd = handle:read("*l")
240 handle:close()
241 end
242
243 if not cwd then
244 return nil, "failed to get current directory"
245 end
246
247 local path = cwd
248 while path and path ~= "" and path ~= "/" do
249 -- Check for .bare directory
250 local bare_check = io.open(path .. "/.bare/HEAD", "r")
251 if bare_check then
252 bare_check:close()
253 return path, nil
254 end
255
256 -- Check for .git file pointing to .bare
257 local git_file = io.open(path .. "/.git", "r")
258 if git_file then
259 local content = git_file:read("*a")
260 git_file:close()
261 if content and content:match("gitdir:%s*%.?/?%.bare") then
262 return path, nil
263 end
264 end
265
266 -- Move up one directory
267 path = path:match("(.+)/[^/]+$")
268 end
269
270 return nil, "not in a wt-managed repository"
271end
272
273---Check if cwd is inside a worktree (has .git file, not at project root)
274---@param root string
275---@param cwd_override? string optional current directory (defaults to get_cwd())
276---@return string|nil source_worktree path if inside worktree, nil if at project root
277function M.detect_source_worktree(root, cwd_override)
278 local cwd = cwd_override or get_cwd()
279 if not cwd then
280 return nil
281 end
282 -- If cwd is the project root, no source worktree
283 if cwd == root then
284 return nil
285 end
286 -- Check if cwd has a .git file (indicating it's a worktree)
287 local git_file = io.open(cwd .. "/.git", "r")
288 if git_file then
289 git_file:close()
290 return cwd
291 end
292 -- Walk up to find worktree root
293 ---@type string|nil
294 local path = cwd
295 while path and path ~= "" and path ~= "/" and path ~= root do
296 local gf = io.open(path .. "/.git", "r")
297 if gf then
298 gf:close()
299 return path
300 end
301 path = path:match("(.+)/[^/]+$")
302 end
303 return nil
304end
305
306---Check if branch exists locally
307---@param git_dir string
308---@param branch string
309---@return boolean
310function M.branch_exists_local(git_dir, branch)
311 return run_cmd_silent("GIT_DIR=" .. git_dir .. " git show-ref --verify --quiet refs/heads/" .. branch)
312end
313
314---Parse git branch -r output to extract remotes containing a branch
315---@param output string git branch -r output
316---@param branch string branch name to find
317---@return string[] remote names
318function M.parse_branch_remotes(output, branch)
319 local remotes = {}
320 for line in output:gmatch("[^\n]+") do
321 -- Match: " origin/branch-name" or " upstream/feature/foo"
322 -- For branch "feature/foo", we want remote "origin", not "origin/feature"
323 -- The remote name is everything before the LAST occurrence of /branch
324 local trimmed = line:match("^%s*(.-)%s*$")
325 if trimmed then
326 -- Check if line ends with /branch
327 local suffix = "/" .. branch
328 if trimmed:sub(-#suffix) == suffix then
329 local remote = trimmed:sub(1, #trimmed - #suffix)
330 -- Simple remote name (no slashes) - this is what we want
331 -- Remote names with slashes (e.g., "forks/alice") are ambiguous
332 -- and skipped for safety
333 if remote ~= "" and not remote:match("/") then
334 table.insert(remotes, remote)
335 end
336 end
337 end
338 end
339 return remotes
340end
341
342---Find which remotes have the branch
343---@param git_dir string
344---@param branch string
345---@return string[] remote names
346function M.find_branch_remotes(git_dir, branch)
347 local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git branch -r --list '*/" .. branch .. "'")
348 if code ~= 0 then
349 return {}
350 end
351 return M.parse_branch_remotes(output, branch)
352end
353
354---Detect default branch from cloned bare repo's HEAD
355---@param git_dir string
356---@return string branch name
357function M.detect_cloned_default_branch(git_dir)
358 -- First try the bare repo's own HEAD (set during clone)
359 local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git symbolic-ref HEAD")
360 if code == 0 and output ~= "" then
361 local branch = output:match("refs/heads/(.+)")
362 if branch then
363 return (branch:gsub("%s+$", ""))
364 end
365 end
366 return "main"
367end
368
369---Get default branch name from git config, fallback to "main"
370---@return string
371function M.get_default_branch()
372 local output, code = run_cmd("git config --get init.defaultBranch")
373 if code == 0 and output ~= "" then
374 return (output:gsub("%s+$", ""))
375 end
376 return "main"
377end
378
379---Parse git worktree list --porcelain output
380---@param output string
381---@return {path: string, branch?: string, bare?: boolean, detached?: boolean, head?: string}[]
382function M.parse_worktree_list(output)
383 local worktrees = {}
384 ---@type {path: string, branch?: string, bare?: boolean, detached?: boolean, head?: string}|nil
385 local current = nil
386 for line in output:gmatch("[^\n]+") do
387 local key, value = line:match("^(%S+)%s*(.*)$")
388 if key == "worktree" and value then
389 if current then
390 table.insert(worktrees, current)
391 end
392 current = { path = value }
393 elseif current then
394 if key == "branch" and value then
395 current.branch = value:gsub("^refs/heads/", "")
396 elseif key == "bare" then
397 current.bare = true
398 elseif key == "detached" then
399 current.detached = true
400 elseif key == "HEAD" then
401 current.head = value
402 end
403 end
404 end
405 if current then
406 table.insert(worktrees, current)
407 end
408 return worktrees
409end
410
411---Get the branch checked out in a specific worktree
412---@param git_dir string
413---@param worktree_path string
414---@return string|nil branch name, or nil if detached/not found
415function M.get_worktree_branch(git_dir, worktree_path)
416 local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain")
417 if code ~= 0 then
418 return nil
419 end
420 local worktrees = M.parse_worktree_list(output)
421 for _, wt in ipairs(worktrees) do
422 if wt.path == worktree_path then
423 return wt.branch
424 end
425 end
426 return nil
427end
428
429---Check if branch is checked out in any worktree
430---@param git_dir string
431---@param branch string
432---@return string|nil path if checked out, nil otherwise
433function M.branch_checked_out_at(git_dir, branch)
434 local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain")
435 if code ~= 0 then
436 return nil
437 end
438 local worktrees = M.parse_worktree_list(output)
439 for _, wt in ipairs(worktrees) do
440 if wt.branch == branch then
441 return wt.path
442 end
443 end
444 return nil
445end
446
447---Check if a ref (branch) has any commits
448---@param git_dir string
449---@param ref string
450---@return boolean has_commits
451function M.ref_has_commits(git_dir, ref)
452 return run_cmd_silent("GIT_DIR=" .. git_dir .. " git rev-parse --verify --quiet " .. ref)
453end
454
455---Find worktree by branch name
456---@param git_dir string
457---@param branch string
458---@return {path: string, branch: string}|nil
459function M.find_worktree_by_branch(git_dir, branch)
460 local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain")
461 if code ~= 0 then
462 return nil
463 end
464 local worktrees = M.parse_worktree_list(output)
465 for _, wt in ipairs(worktrees) do
466 if wt.branch == branch and not wt.bare then
467 return { path = wt.path, branch = wt.branch }
468 end
469 end
470 return nil
471end
472
473return M
474]]
475
476_EMBEDDED_MODULES["wt.config"] = [[-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
477--
478-- SPDX-License-Identifier: GPL-3.0-or-later
479
480---@class wt.GlobalConfig
481---@field remotes? table<string, string?>
482---@field default_remotes? string[]|"prompt"
483---@field branch_path_style? string
484---@field flat_separator? string
485
486---@class wt.config
487local M = {}
488
489---Substitute ${project} in template string
490---@param template string
491---@param project_name string
492---@return string
493function M.resolve_url_template(template, project_name)
494 local escaped = project_name:gsub("%%", "%%%%")
495 return (template:gsub("%${project}", escaped))
496end
497
498---Parse git URLs to extract project name
499---@param url string
500---@return string|nil
501function M.extract_project_name(url)
502 if not url or url == "" then
503 return nil
504 end
505
506 url = url:gsub("[?#].*$", "")
507 url = url:gsub("/+$", "")
508
509 if url == "" or url == "/" then
510 return nil
511 end
512
513 url = url:gsub("%.git$", "")
514
515 if not url:match("://") then
516 local scp_path = url:match("^[^@]+@[^:]+:(.+)$")
517 if scp_path and scp_path ~= "" then
518 url = scp_path
519 end
520 end
521
522 local name = url:match("([^/]+)$") or url:match("([^:]+)$")
523 if not name or name == "" then
524 return nil
525 end
526 return name
527end
528
529---Load global config from ~/.config/wt/config.lua
530---@return wt.GlobalConfig
531function M.load_global_config()
532 local home = os.getenv("HOME")
533 if not home then
534 return {}
535 end
536 local config_path = home .. "/.config/wt/config.lua"
537 local f = io.open(config_path, "r")
538 if not f then
539 return {}
540 end
541 local content = f:read("*a")
542 f:close()
543 local chunk, err = load(content, config_path, "t", {})
544 if not chunk then
545 chunk, err = load("return " .. content, config_path, "t", {})
546 end
547 if not chunk then
548 io.stderr:write("warning: config syntax error in " .. config_path .. ": " .. err .. "\n")
549 return {}
550 end
551 local ok, result = pcall(chunk)
552 if not ok then
553 io.stderr:write("warning: config execution error in " .. config_path .. ": " .. result .. "\n")
554 return {}
555 end
556 if type(result) ~= "table" then
557 io.stderr:write("warning: config must return a table in " .. config_path .. "\n")
558 return {}
559 end
560 return result
561end
562
563---Load project config from <root>/.wt.lua
564---@param root string
565---@return {hooks?: {copy?: string[], symlink?: string[], run?: string[]}}
566function M.load_project_config(root)
567 local config_path = root .. "/.wt.lua"
568 local f = io.open(config_path, "r")
569 if not f then
570 return {}
571 end
572 local content = f:read("*a")
573 f:close()
574
575 local chunk, err = load(content, config_path, "t", {})
576 if not chunk then
577 chunk, err = load("return " .. content, config_path, "t", {})
578 end
579 if not chunk then
580 io.stderr:write("warning: config syntax error in " .. config_path .. ": " .. err .. "\n")
581 return {}
582 end
583 local ok, result = pcall(chunk)
584 if not ok then
585 io.stderr:write("warning: config execution error in " .. config_path .. ": " .. result .. "\n")
586 return {}
587 end
588 if type(result) ~= "table" then
589 io.stderr:write("warning: config must return a table in " .. config_path .. "\n")
590 return {}
591 end
592 return result
593end
594
595return M
596]]
597
598_EMBEDDED_MODULES["wt.hooks"] = [[-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
599--
600-- SPDX-License-Identifier: GPL-3.0-or-later
601
602local shell = require("wt.shell")
603local run_cmd = shell.run_cmd
604local run_cmd_silent = shell.run_cmd_silent
605
606---@class wt.hooks
607local M = {}
608
609---Load hook permissions from ~/.local/share/wt/hook-dirs.lua
610---@param home_override? string override HOME for testing
611---@return table<string, boolean>
612function M.load_hook_permissions(home_override)
613 local home = home_override or os.getenv("HOME")
614 if not home then
615 return {}
616 end
617 local path = home .. "/.local/share/wt/hook-dirs.lua"
618 local f = io.open(path, "r")
619 if not f then
620 return {}
621 end
622 local content = f:read("*a")
623 f:close()
624 local chunk = load("return " .. content, path, "t", {})
625 if not chunk then
626 return {}
627 end
628 local ok, result = pcall(chunk)
629 if ok and type(result) == "table" then
630 return result
631 end
632 return {}
633end
634
635---Save hook permissions to ~/.local/share/wt/hook-dirs.lua
636---@param perms table<string, boolean>
637---@param home_override? string override HOME for testing
638function M.save_hook_permissions(perms, home_override)
639 local home = home_override or os.getenv("HOME")
640 if not home then
641 return
642 end
643 local dir = home .. "/.local/share/wt"
644 run_cmd_silent("mkdir -p " .. dir)
645 local path = dir .. "/hook-dirs.lua"
646 local f = io.open(path, "w")
647 if not f then
648 return
649 end
650 f:write("{\n")
651 for k, v in pairs(perms) do
652 f:write('\t["' .. k .. '"] = ' .. tostring(v) .. ",\n")
653 end
654 f:write("}\n")
655 f:close()
656end
657
658---Summarize hooks for confirmation prompt
659---@param hooks {copy?: string[], symlink?: string[], run?: string[]}
660---@return string
661function M.summarize_hooks(hooks)
662 local parts = {}
663 if hooks.copy and #hooks.copy > 0 then
664 local items = {}
665 for i = 1, math.min(3, #hooks.copy) do
666 table.insert(items, hooks.copy[i])
667 end
668 local suffix = #hooks.copy > 3 and " (+" .. (#hooks.copy - 3) .. " more)" or ""
669 table.insert(parts, "copy: " .. table.concat(items, ", ") .. suffix)
670 end
671 if hooks.symlink and #hooks.symlink > 0 then
672 local items = {}
673 for i = 1, math.min(3, #hooks.symlink) do
674 table.insert(items, hooks.symlink[i])
675 end
676 local suffix = #hooks.symlink > 3 and " (+" .. (#hooks.symlink - 3) .. " more)" or ""
677 table.insert(parts, "symlink: " .. table.concat(items, ", ") .. suffix)
678 end
679 if hooks.run and #hooks.run > 0 then
680 local items = {}
681 for i = 1, math.min(3, #hooks.run) do
682 table.insert(items, hooks.run[i])
683 end
684 local suffix = #hooks.run > 3 and " (+" .. (#hooks.run - 3) .. " more)" or ""
685 table.insert(parts, "run: " .. table.concat(items, ", ") .. suffix)
686 end
687 return table.concat(parts, "; ")
688end
689
690---Check if hooks are allowed for a project, prompting if unknown
691---@param root string project root path
692---@param hooks {copy?: string[], symlink?: string[], run?: string[]}
693---@return boolean allowed
694function M.check_hook_permission(root, hooks)
695 local perms = M.load_hook_permissions()
696 if perms[root] ~= nil then
697 return perms[root]
698 end
699
700 -- Prompt user
701 local summary = M.summarize_hooks(hooks)
702 local prompt = "Allow hooks for " .. root .. "?\\n" .. summary
703 local allowed = run_cmd_silent("gum confirm " .. "'" .. prompt:gsub("'", "'\\''") .. "'")
704
705 perms[root] = allowed
706 M.save_hook_permissions(perms)
707 return allowed
708end
709
710---Run hooks from .wt.lua config
711---@param source string source worktree path
712---@param target string target worktree path
713---@param hooks {copy?: string[], symlink?: string[], run?: string[]}
714---@param root string project root path
715---@param home_override? string override HOME for testing
716function M.run_hooks(source, target, hooks, root, home_override)
717 -- Check permission before running any hooks
718 -- For testing: check permissions file directly if home_override given
719 if home_override then
720 local perms = M.load_hook_permissions(home_override)
721 if perms[root] == false then
722 io.stderr:write("hooks skipped (not allowed for this project)\n")
723 return
724 end
725 else
726 if not M.check_hook_permission(root, hooks) then
727 io.stderr:write("hooks skipped (not allowed for this project)\n")
728 return
729 end
730 end
731
732 if hooks.copy then
733 for _, item in ipairs(hooks.copy) do
734 local src = source .. "/" .. item
735 local dst = target .. "/" .. item
736 -- Create parent directory if needed
737 local parent = dst:match("(.+)/[^/]+$")
738 if parent then
739 run_cmd_silent("mkdir -p " .. parent)
740 end
741 local _, code = run_cmd("cp -r " .. src .. " " .. dst)
742 if code ~= 0 then
743 io.stderr:write("warning: failed to copy " .. item .. "\n")
744 end
745 end
746 end
747 if hooks.symlink then
748 for _, item in ipairs(hooks.symlink) do
749 local src = source .. "/" .. item
750 local dst = target .. "/" .. item
751 -- Create parent directory if needed
752 local parent = dst:match("(.+)/[^/]+$")
753 if parent then
754 run_cmd_silent("mkdir -p " .. parent)
755 end
756 local _, code = run_cmd("ln -s " .. src .. " " .. dst)
757 if code ~= 0 then
758 io.stderr:write("warning: failed to symlink " .. item .. "\n")
759 end
760 end
761 end
762 if hooks.run then
763 for _, cmd in ipairs(hooks.run) do
764 local output, code = run_cmd("cd " .. target .. " && " .. cmd)
765 if output ~= "" then
766 io.write(output)
767 if not output:match("\n$") then
768 io.write("\n")
769 end
770 end
771 if code ~= 0 then
772 io.stderr:write("warning: hook command failed: " .. cmd .. "\n")
773 end
774 end
775 end
776end
777
778return M
779]]
780
781_EMBEDDED_MODULES["wt.help"] = [=[-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
782--
783-- SPDX-License-Identifier: GPL-3.0-or-later
784
785local exit = require("wt.exit")
786local EXIT_SUCCESS = exit.EXIT_SUCCESS
787
788---@class wt.help
789local M = {}
790
791---Print usage information
792function M.print_usage()
793 print("wt - git worktree manager")
794 print("")
795 print("Usage: wt <command> [options]")
796 print("")
797 print("Commands:")
798 print(" c <url> [--remote name]... [--own] Clone into bare worktree structure")
799 print(" n <project-name> [--remote name]... Initialize fresh project")
800 print(" a <branch> [-b [<start-point>]] Add worktree with optional hooks")
801 print(" r <branch> [-b] [-f] Remove worktree, optionally delete branch")
802 print(" l List worktrees with status")
803 print(" f Fetch all remotes")
804 print(" init [--dry-run] [-y] Convert existing repo to bare structure")
805 print(" help Show this help message")
806end
807
808-- Per-command help text (using table.concat for performance)
809M.COMMAND_HELP = {
810 c = table.concat({
811 "wt c <url> [--remote name]... [--own]",
812 "",
813 "Clone a repository into bare worktree structure.",
814 "",
815 "Arguments:",
816 " <url> Git URL to clone",
817 "",
818 "Options:",
819 " --remote <name> Add configured remote from ~/.config/wt/config.lua",
820 " Can be specified multiple times",
821 " --own Treat as your own project (see Push Behavior below)",
822 "",
823 "Push Behavior:",
824 " Contributor mode (default):",
825 " - 'origin' renamed to 'upstream'",
826 " - Selected remotes added from config",
827 " - Pushes default branch to ALL selected remotes (creates fork copies)",
828 "",
829 " Own mode (--own):",
830 " - 'origin' renamed to first selected remote",
831 " - Additional remotes added from config",
832 " - Pushes only to ADDITIONAL remotes (mirrors), not the first",
833 "",
834 "Examples:",
835 " wt c https://github.com/user/repo.git",
836 " wt c git@github.com:user/repo.git --remote github --own",
837 }, "\n"),
838
839 n = table.concat({
840 "wt n <project-name> [--remote name]...",
841 "",
842 "Initialize a fresh project with bare worktree structure.",
843 "",
844 "Arguments:",
845 " <project-name> Name of the new project directory",
846 "",
847 "Options:",
848 " --remote <name> Add configured remote from ~/.config/wt/config.lua",
849 " Can be specified multiple times",
850 "",
851 "Examples:",
852 " wt n my-project",
853 " wt n my-project --remote github --remote gitlab",
854 }, "\n"),
855
856 a = table.concat({
857 "wt a <branch> [-b [<start-point>]]",
858 "",
859 "Add a worktree for a branch.",
860 "",
861 "Arguments:",
862 " <branch> Branch name to checkout or create",
863 "",
864 "Options:",
865 " -b Create a new branch",
866 " <start-point> Base commit/branch for new branch (only with -b)",
867 "",
868 "If run from inside an existing worktree, hooks from .wt.lua will be applied.",
869 "",
870 "Examples:",
871 " wt a main # Checkout existing branch",
872 " wt a feature/new -b # Create new branch from HEAD",
873 " wt a feature/new -b main # Create new branch from main",
874 }, "\n"),
875
876 r = table.concat({
877 "wt r <branch> [-b] [-f]",
878 "",
879 "Remove a worktree.",
880 "",
881 "Arguments:",
882 " <branch> Branch name of worktree to remove",
883 "",
884 "Options:",
885 " -b Also delete the branch after removing worktree",
886 " -f Force removal even with uncommitted changes",
887 "",
888 "Examples:",
889 " wt r feature/old # Remove worktree, keep branch",
890 " wt r feature/old -b # Remove worktree and delete branch",
891 " wt r feature/old -f # Force remove with uncommitted changes",
892 }, "\n"),
893
894 l = table.concat({
895 "wt l",
896 "",
897 "List all worktrees with status information.",
898 "",
899 "Displays a table showing:",
900 " - Branch name",
901 " - Relative path from project root",
902 " - Commit status (ahead/behind remote)",
903 " - Working tree status (clean/dirty)",
904 }, "\n"),
905
906 f = table.concat({
907 "wt f",
908 "",
909 "Fetch from all configured remotes.",
910 "",
911 "Runs 'git fetch --all' in the bare repository.",
912 }, "\n"),
913
914 init = table.concat({
915 "wt init [--dry-run] [-y]",
916 "",
917 "Convert an existing git repository to bare worktree structure.",
918 "",
919 "Options:",
920 " --dry-run Show what would be done without making changes",
921 " -y Skip confirmation prompt",
922 "",
923 "This command:",
924 " 1. Moves .git/ to .bare/",
925 " 2. Creates .git file pointing to .bare/",
926 " 3. Creates a worktree for the current branch",
927 " 4. Removes orphaned files from project root",
928 }, "\n"),
929}
930
931---Show help for a specific command
932---@param cmd string
933function M.show_command_help(cmd)
934 local help = M.COMMAND_HELP[cmd]
935 if help then
936 print(help)
937 else
938 M.print_usage()
939 end
940 os.exit(EXIT_SUCCESS)
941end
942
943return M
944]=]
945
946_EMBEDDED_MODULES["wt.cmd.clone"] = [=[-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
947--
948-- SPDX-License-Identifier: GPL-3.0-or-later
949
950local exit = require("wt.exit")
951local shell = require("wt.shell")
952local git = require("wt.git")
953local path_mod = require("wt.path")
954local config = require("wt.config")
955
956---@class wt.cmd.clone
957local M = {}
958
959---Clone a repository with bare repo structure
960---@param args string[]
961function M.cmd_clone(args)
962 -- Parse arguments: <url> [--remote name]... [--own]
963 ---@type string|nil
964 local url
965 ---@type string[]
966 local remote_flags = {}
967 local own = false
968
969 local i = 1
970 -- Flags can appear anywhere in the argument list
971 local positional = {}
972 while i <= #args do
973 local a = args[i]
974 if a == "--remote" then
975 if not args[i + 1] then
976 shell.die("--remote requires a name")
977 end
978 table.insert(remote_flags, args[i + 1])
979 i = i + 1
980 elseif a == "--own" then
981 own = true
982 elseif a:match("^%-") then
983 shell.die("unknown flag: " .. a)
984 else
985 table.insert(positional, a)
986 end
987 i = i + 1
988 end
989
990 if #positional == 0 then
991 shell.die("usage: wt c <url> [--remote name]... [--own]")
992 return
993 elseif #positional > 1 then
994 shell.die("unexpected argument: " .. positional[2])
995 end
996 url = positional[1]
997
998 -- Extract project name from URL
999 local project_name = config.extract_project_name(url)
1000 if not project_name then
1001 shell.die("could not extract project name from URL: " .. url)
1002 return
1003 end
1004
1005 -- Check if project directory already exists
1006 local cwd = shell.get_cwd()
1007 if not cwd then
1008 shell.die("failed to get current directory", exit.EXIT_SYSTEM_ERROR)
1009 end
1010 ---@cast cwd string
1011 local project_path = cwd .. "/" .. project_name
1012 local check = io.open(project_path, "r")
1013 if check then
1014 check:close()
1015 shell.die("directory already exists: " .. project_path)
1016 end
1017
1018 -- Clone bare repo
1019 local bare_path = project_path .. "/.bare"
1020 local output, code = shell.run_cmd("git clone --bare " .. url .. " " .. bare_path)
1021 if code ~= 0 then
1022 shell.die("failed to clone: " .. output, exit.EXIT_SYSTEM_ERROR)
1023 end
1024
1025 -- Write .git file pointing to .bare
1026 local git_file_handle = io.open(project_path .. "/.git", "w")
1027 if not git_file_handle then
1028 shell.die("failed to create .git file", exit.EXIT_SYSTEM_ERROR)
1029 return
1030 end
1031 git_file_handle:write("gitdir: ./.bare\n")
1032 git_file_handle:close()
1033
1034 -- Detect default branch
1035 local git_dir = bare_path
1036 local default_branch = git.detect_cloned_default_branch(git_dir)
1037
1038 -- Load global config
1039 local global_config = config.load_global_config()
1040
1041 -- Determine which remotes to use
1042 ---@type string[]
1043 local selected_remotes = {}
1044
1045 if #remote_flags > 0 then
1046 selected_remotes = remote_flags
1047 elseif global_config.default_remotes then
1048 if type(global_config.default_remotes) == "table" then
1049 selected_remotes = global_config.default_remotes --[[@as string[] ]]
1050 ---@diagnostic disable-next-line: unnecessary-if
1051 elseif global_config.default_remotes == "prompt" then
1052 if global_config.remotes then
1053 local keys = {}
1054 for k in pairs(global_config.remotes) do
1055 table.insert(keys, k)
1056 end
1057 table.sort(keys)
1058 if #keys > 0 then
1059 local input = table.concat(keys, "\n")
1060 local choose_type = own and "" or " --no-limit"
1061 local cmd = "echo '" .. input .. "' | gum choose" .. choose_type
1062 output, code = shell.run_cmd(cmd)
1063 if code == 0 and output ~= "" then
1064 for line in output:gmatch("[^\n]+") do
1065 table.insert(selected_remotes, line)
1066 end
1067 end
1068 end
1069 end
1070 end
1071 elseif global_config.remotes then
1072 local keys = {}
1073 for k in pairs(global_config.remotes) do
1074 table.insert(keys, k)
1075 end
1076 table.sort(keys)
1077 if #keys > 0 then
1078 local input = table.concat(keys, "\n")
1079 local choose_type = own and "" or " --no-limit"
1080 local cmd = "echo '" .. input .. "' | gum choose" .. choose_type
1081 output, code = shell.run_cmd(cmd)
1082 if code == 0 and output ~= "" then
1083 for line in output:gmatch("[^\n]+") do
1084 table.insert(selected_remotes, line)
1085 end
1086 end
1087 end
1088 end
1089
1090 -- Track configured remotes for summary
1091 ---@type string[]
1092 local configured_remotes = {}
1093
1094 if own then
1095 -- User's own project: origin is their canonical remote
1096 if #selected_remotes > 0 then
1097 local first_remote = selected_remotes[1]
1098 -- Rename origin to first remote
1099 output, code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git remote rename origin " .. first_remote)
1100 if code ~= 0 then
1101 io.stderr:write("warning: failed to rename origin to " .. first_remote .. ": " .. output .. "\n")
1102 else
1103 -- Configure fetch refspec
1104 shell.run_cmd(
1105 "GIT_DIR="
1106 .. git_dir
1107 .. " git config remote."
1108 .. first_remote
1109 .. ".fetch '+refs/heads/*:refs/remotes/"
1110 .. first_remote
1111 .. "/*'"
1112 )
1113 table.insert(configured_remotes, first_remote)
1114 end
1115
1116 -- Add additional remotes and push to them
1117 local remotes = global_config.remotes
1118 for j = 2, #selected_remotes do
1119 local remote_name = selected_remotes[j]
1120 if remotes then
1121 local template = remotes[remote_name]
1122 if template then
1123 local remote_url = config.resolve_url_template(template, project_name)
1124 output, code = shell.run_cmd(
1125 "GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. remote_url
1126 )
1127 if code ~= 0 then
1128 io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
1129 else
1130 shell.run_cmd(
1131 "GIT_DIR="
1132 .. git_dir
1133 .. " git config remote."
1134 .. remote_name
1135 .. ".fetch '+refs/heads/*:refs/remotes/"
1136 .. remote_name
1137 .. "/*'"
1138 )
1139 -- Push to additional remotes
1140 output, code = shell.run_cmd(
1141 "GIT_DIR=" .. git_dir .. " git push " .. remote_name .. " " .. default_branch
1142 )
1143 if code ~= 0 then
1144 io.stderr:write("warning: failed to push to " .. remote_name .. ": " .. output .. "\n")
1145 end
1146 table.insert(configured_remotes, remote_name)
1147 end
1148 else
1149 io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
1150 end
1151 else
1152 io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
1153 end
1154 end
1155 else
1156 -- No remotes selected, keep origin as-is
1157 shell.run_cmd(
1158 "GIT_DIR=" .. git_dir .. " git config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'"
1159 )
1160 table.insert(configured_remotes, "origin")
1161 end
1162 else
1163 -- Contributing to someone else's project
1164 -- Rename origin to upstream
1165 output, code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git remote rename origin upstream")
1166 if code ~= 0 then
1167 io.stderr:write("warning: failed to rename origin to upstream: " .. output .. "\n")
1168 else
1169 shell.run_cmd(
1170 "GIT_DIR=" .. git_dir .. " git config remote.upstream.fetch '+refs/heads/*:refs/remotes/upstream/*'"
1171 )
1172 table.insert(configured_remotes, "upstream")
1173 end
1174
1175 -- Add user's remotes and push to each
1176 local remotes = global_config.remotes
1177 for _, remote_name in ipairs(selected_remotes) do
1178 if remotes then
1179 local template = remotes[remote_name]
1180 if template then
1181 local remote_url = config.resolve_url_template(template, project_name)
1182 output, code =
1183 shell.run_cmd("GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. remote_url)
1184 if code ~= 0 then
1185 io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
1186 else
1187 shell.run_cmd(
1188 "GIT_DIR="
1189 .. git_dir
1190 .. " git config remote."
1191 .. remote_name
1192 .. ".fetch '+refs/heads/*:refs/remotes/"
1193 .. remote_name
1194 .. "/*'"
1195 )
1196 -- Push to this remote
1197 output, code =
1198 shell.run_cmd("GIT_DIR=" .. git_dir .. " git push " .. remote_name .. " " .. default_branch)
1199 if code ~= 0 then
1200 io.stderr:write("warning: failed to push to " .. remote_name .. ": " .. output .. "\n")
1201 end
1202 table.insert(configured_remotes, remote_name)
1203 end
1204 else
1205 io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
1206 end
1207 else
1208 io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
1209 end
1210 end
1211 end
1212
1213 -- Fetch all remotes
1214 shell.run_cmd("GIT_DIR=" .. git_dir .. " git fetch --all")
1215
1216 -- Load config for path style
1217 local style = global_config.branch_path_style or "nested"
1218 local separator = global_config.flat_separator
1219 local worktree_path = path_mod.branch_to_path(project_path, default_branch, style, separator)
1220
1221 -- Create initial worktree
1222 output, code =
1223 shell.run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -- " .. worktree_path .. " " .. default_branch)
1224 if code ~= 0 then
1225 shell.die("failed to create worktree: " .. output, exit.EXIT_SYSTEM_ERROR)
1226 end
1227
1228 -- Print summary
1229 print("Created project: " .. project_path)
1230 print("Default branch: " .. default_branch)
1231 print("Worktree: " .. worktree_path)
1232 if #configured_remotes > 0 then
1233 print("Remotes: " .. table.concat(configured_remotes, ", "))
1234 end
1235end
1236
1237return M
1238]=]
1239
1240_EMBEDDED_MODULES["wt.cmd.new"] = [=[-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
1241--
1242-- SPDX-License-Identifier: GPL-3.0-or-later
1243
1244local exit = require("wt.exit")
1245local shell = require("wt.shell")
1246local git = require("wt.git")
1247local path_mod = require("wt.path")
1248local config = require("wt.config")
1249
1250---@class wt.cmd.new
1251local M = {}
1252
1253---Create a new project with bare repo structure
1254---@param args string[]
1255function M.cmd_new(args)
1256 -- Parse arguments: <project-name> [--remote name]...
1257 ---@type string|nil
1258 local project_name
1259 ---@type string[]
1260 local remote_flags = {}
1261
1262 local i = 1
1263 -- Flags can appear anywhere in the argument list
1264 local positional = {}
1265 while i <= #args do
1266 local a = args[i]
1267 if a == "--remote" then
1268 if not args[i + 1] then
1269 shell.die("--remote requires a name")
1270 end
1271 table.insert(remote_flags, args[i + 1])
1272 i = i + 1
1273 elseif a:match("^%-") then
1274 shell.die("unknown flag: " .. a)
1275 else
1276 table.insert(positional, a)
1277 end
1278 i = i + 1
1279 end
1280
1281 if #positional == 0 then
1282 shell.die("usage: wt n <project-name> [--remote name]...")
1283 return
1284 elseif #positional > 1 then
1285 shell.die("unexpected argument: " .. positional[2])
1286 end
1287 project_name = positional[1]
1288
1289 -- Check if project directory already exists
1290 local cwd = shell.get_cwd()
1291 if not cwd then
1292 shell.die("failed to get current directory", exit.EXIT_SYSTEM_ERROR)
1293 end
1294 ---@cast cwd string
1295 local project_path = cwd .. "/" .. project_name
1296 local check = io.open(project_path, "r")
1297 if check then
1298 check:close()
1299 shell.die("directory already exists: " .. project_path)
1300 end
1301
1302 -- Load global config
1303 local global_config = config.load_global_config()
1304
1305 -- Determine which remotes to use
1306 ---@type string[]
1307 local selected_remotes = {}
1308
1309 if #remote_flags > 0 then
1310 -- Use explicitly provided remotes
1311 selected_remotes = remote_flags
1312 elseif global_config.default_remotes then
1313 if type(global_config.default_remotes) == "table" then
1314 selected_remotes = global_config.default_remotes --[[@as string[] ]]
1315 ---@diagnostic disable-next-line: unnecessary-if
1316 elseif global_config.default_remotes == "prompt" then
1317 -- Prompt with gum choose
1318 if global_config.remotes then
1319 local keys = {}
1320 for k in pairs(global_config.remotes) do
1321 table.insert(keys, k)
1322 end
1323 table.sort(keys)
1324 if #keys > 0 then
1325 local input = table.concat(keys, "\n")
1326 local cmd = "echo '" .. input .. "' | gum choose --no-limit"
1327 local output, code = shell.run_cmd(cmd)
1328 if code == 0 and output ~= "" then
1329 for line in output:gmatch("[^\n]+") do
1330 table.insert(selected_remotes, line)
1331 end
1332 end
1333 end
1334 end
1335 end
1336 elseif global_config.remotes then
1337 -- No default_remotes configured, prompt if remotes exist
1338 local keys = {}
1339 for k in pairs(global_config.remotes) do
1340 table.insert(keys, k)
1341 end
1342 table.sort(keys)
1343 if #keys > 0 then
1344 local input = table.concat(keys, "\n")
1345 local cmd = "echo '" .. input .. "' | gum choose --no-limit"
1346 local output, code = shell.run_cmd(cmd)
1347 if code == 0 and output ~= "" then
1348 for line in output:gmatch("[^\n]+") do
1349 table.insert(selected_remotes, line)
1350 end
1351 end
1352 end
1353 end
1354
1355 -- Create project structure
1356 local bare_path = project_path .. "/.bare"
1357 local output, code = shell.run_cmd("mkdir -p " .. bare_path)
1358 if code ~= 0 then
1359 shell.die("failed to create directory: " .. output, exit.EXIT_SYSTEM_ERROR)
1360 end
1361
1362 output, code = shell.run_cmd("git init --bare " .. bare_path)
1363 if code ~= 0 then
1364 shell.die("failed to init bare repo: " .. output, exit.EXIT_SYSTEM_ERROR)
1365 end
1366
1367 -- Write .git file pointing to .bare
1368 local git_file_handle = io.open(project_path .. "/.git", "w")
1369 if not git_file_handle then
1370 shell.die("failed to create .git file", exit.EXIT_SYSTEM_ERROR)
1371 return
1372 end
1373 git_file_handle:write("gitdir: ./.bare\n")
1374 git_file_handle:close()
1375
1376 -- Add remotes
1377 local git_dir = bare_path
1378 local remotes = global_config.remotes
1379 for _, remote_name in ipairs(selected_remotes) do
1380 if remotes then
1381 local template = remotes[remote_name]
1382 if template then
1383 local url = config.resolve_url_template(template, project_name)
1384 output, code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. url)
1385 if code ~= 0 then
1386 io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
1387 else
1388 -- Configure fetch refspec for the remote
1389 shell.run_cmd(
1390 "GIT_DIR="
1391 .. git_dir
1392 .. " git config remote."
1393 .. remote_name
1394 .. ".fetch '+refs/heads/*:refs/remotes/"
1395 .. remote_name
1396 .. "/*'"
1397 )
1398 end
1399 else
1400 io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
1401 end
1402 else
1403 io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
1404 end
1405 end
1406
1407 -- Detect default branch
1408 local default_branch = git.get_default_branch()
1409
1410 -- Load config for path style
1411 local style = global_config.branch_path_style or "nested"
1412 local separator = global_config.flat_separator
1413 local worktree_path = path_mod.branch_to_path(project_path, default_branch, style, separator)
1414
1415 -- Create orphan worktree
1416 output, code = shell.run_cmd(
1417 "GIT_DIR=" .. git_dir .. " git worktree add --orphan -b " .. default_branch .. " -- " .. worktree_path
1418 )
1419 if code ~= 0 then
1420 shell.die("failed to create worktree: " .. output, exit.EXIT_SYSTEM_ERROR)
1421 end
1422
1423 -- Print summary
1424 print("Created project: " .. project_path)
1425 print("Default branch: " .. default_branch)
1426 print("Worktree: " .. worktree_path)
1427 if #selected_remotes > 0 then
1428 print("Remotes: " .. table.concat(selected_remotes, ", "))
1429 end
1430end
1431
1432return M
1433]=]
1434
1435_EMBEDDED_MODULES["wt.cmd.add"] = [=[-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
1436--
1437-- SPDX-License-Identifier: GPL-3.0-or-later
1438
1439local exit = require("wt.exit")
1440local shell = require("wt.shell")
1441local git = require("wt.git")
1442local path_mod = require("wt.path")
1443local config = require("wt.config")
1444local hooks = require("wt.hooks")
1445
1446---@class wt.cmd.add
1447local M = {}
1448
1449---Add a worktree for an existing or new branch
1450---@param args string[]
1451function M.cmd_add(args)
1452 -- Parse arguments: <branch> [-b [<start-point>]]
1453 ---@type string|nil
1454 local branch = nil
1455 local create_branch = false
1456 ---@type string|nil
1457 local start_point = nil
1458
1459 -- Collect flags and positional args (flags can appear anywhere)
1460 local positional = {}
1461 for _, a in ipairs(args) do
1462 if a == "-b" then
1463 create_branch = true
1464 elseif a:match("^%-") then
1465 shell.die("unknown flag: " .. a)
1466 else
1467 table.insert(positional, a)
1468 end
1469 end
1470
1471 -- Assign positional args: <branch> [<start-point>]
1472 if #positional == 0 then
1473 shell.die("usage: wt a <branch> [-b [<start-point>]]")
1474 return
1475 elseif #positional == 1 then
1476 branch = positional[1]
1477 elseif #positional == 2 then
1478 branch = positional[1]
1479 start_point = positional[2]
1480 else
1481 shell.die("unexpected argument: " .. positional[3])
1482 end
1483
1484 local root, err = git.find_project_root()
1485 if not root then
1486 shell.die(err --[[@as string]])
1487 return
1488 end
1489
1490 local git_dir = root .. "/.bare"
1491 local source_worktree = git.detect_source_worktree(root)
1492
1493 -- Load config for path style
1494 local global_config = config.load_global_config()
1495 local style = global_config.branch_path_style or "nested"
1496 local separator = global_config.flat_separator or "_"
1497
1498 local target_path = path_mod.branch_to_path(root, branch, style, separator)
1499
1500 -- Check if worktree already exists for this branch (idempotent behavior)
1501 local existing_wt = git.find_worktree_by_branch(git_dir, branch)
1502 if existing_wt then
1503 -- Validate the worktree path actually exists (not stale metadata)
1504 local wt_check = io.open(existing_wt.path .. "/.git", "r")
1505 if not wt_check then
1506 shell.die(
1507 "worktree for '"
1508 .. branch
1509 .. "' is registered but missing at "
1510 .. existing_wt.path
1511 .. "\nhint: run `git worktree prune` to clean up stale entries"
1512 )
1513 end
1514 wt_check:close()
1515
1516 local project_config = config.load_project_config(root)
1517 io.stderr:write("worktree already exists at " .. existing_wt.path .. "\n")
1518 -- Warn if path differs from current config
1519 if existing_wt.path ~= target_path then
1520 io.stderr:write("note: current config would place it at " .. target_path .. "\n")
1521 end
1522 -- If running from root and hooks exist, offer to run them now
1523 if not source_worktree and project_config.hooks then
1524 io.stderr:write("hint: run `wt a` from inside a worktree to apply hooks from .wt.lua\n")
1525 end
1526 print(existing_wt.path)
1527 return
1528 end
1529
1530 -- Check if target path has a .git but for a different branch (conflict)
1531 local check = io.open(target_path .. "/.git", "r")
1532 if check then
1533 check:close()
1534 shell.die("directory already exists at " .. target_path .. " but is not a worktree for '" .. branch .. "'")
1535 end
1536
1537 local output, code
1538 if create_branch then
1539 -- Default start-point to source worktree's branch if inside one
1540 if not start_point and source_worktree then
1541 start_point = git.get_worktree_branch(git_dir, source_worktree)
1542 end
1543
1544 -- Check if start_point resolves to a valid commit (catches orphan branch case)
1545 if start_point and not git.ref_has_commits(git_dir, start_point) then
1546 shell.die("'" .. start_point .. "' has no commits yet; make an initial commit first")
1547 end
1548
1549 -- Create new branch with worktree
1550 if start_point then
1551 output, code = shell.run_cmd(
1552 "GIT_DIR="
1553 .. git_dir
1554 .. " git worktree add -b "
1555 .. branch
1556 .. " -- "
1557 .. target_path
1558 .. " "
1559 .. start_point
1560 )
1561 else
1562 output, code =
1563 shell.run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -b " .. branch .. " -- " .. target_path)
1564 end
1565 else
1566 -- Check if branch exists locally or on remotes
1567 local exists_local = git.branch_exists_local(git_dir, branch)
1568 local remotes = git.find_branch_remotes(git_dir, branch)
1569
1570 if not exists_local and #remotes == 0 then
1571 shell.die("branch '" .. branch .. "' not found locally or on any remote (use -b to create)")
1572 end
1573
1574 if #remotes > 1 then
1575 local hint = "use `-b " .. remotes[1] .. "/" .. branch .. "` to specify which remote to track"
1576 shell.die(
1577 "branch '" .. branch .. "' exists on multiple remotes: " .. table.concat(remotes, ", ") .. "\n" .. hint
1578 )
1579 end
1580
1581 output, code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -- " .. target_path .. " " .. branch)
1582 end
1583
1584 if code ~= 0 then
1585 shell.die("failed to add worktree: " .. output, exit.EXIT_SYSTEM_ERROR)
1586 end
1587
1588 -- Run hooks if we have a source worktree
1589 local project_config = config.load_project_config(root)
1590 if source_worktree then
1591 if project_config.hooks then
1592 hooks.run_hooks(source_worktree, target_path, project_config.hooks, root)
1593 end
1594 elseif project_config.hooks then
1595 io.stderr:write("hint: hooks skipped; run `wt a` from inside a worktree to apply hooks from .wt.lua\n")
1596 end
1597
1598 print(target_path)
1599end
1600
1601return M
1602]=]
1603
1604_EMBEDDED_MODULES["wt.cmd.remove"] = [=[-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
1605--
1606-- SPDX-License-Identifier: GPL-3.0-or-later
1607
1608local exit = require("wt.exit")
1609local shell = require("wt.shell")
1610local git = require("wt.git")
1611local path_mod = require("wt.path")
1612
1613---@class wt.cmd.remove
1614local M = {}
1615
1616---Check if cwd is inside (or equal to) a given path
1617---@param target string
1618---@return boolean
1619local function cwd_inside_path(target)
1620 local cwd = shell.get_cwd()
1621 if not cwd then
1622 return false
1623 end
1624 return path_mod.path_inside(cwd, target)
1625end
1626
1627---Get the bare repo's HEAD branch
1628---@param git_dir string
1629---@return string|nil branch name, nil on error
1630local function get_bare_head(git_dir)
1631 local output, code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git symbolic-ref --short HEAD")
1632 if code ~= 0 then
1633 return nil
1634 end
1635 return (output:gsub("%s+$", ""))
1636end
1637
1638---Remove empty parent directories between target and project root
1639---@param target_path string the removed worktree path
1640---@param project_root string the project root (stop here)
1641local function cleanup_empty_parents(target_path, project_root)
1642 project_root = project_root:gsub("/$", "")
1643 local parent = path_mod.parent_dir(target_path)
1644 while parent and parent ~= project_root and path_mod.path_inside(parent, project_root) do
1645 shell.run_cmd_silent("rmdir " .. shell.quote(parent))
1646 parent = path_mod.parent_dir(parent)
1647 end
1648end
1649
1650---Remove a worktree and optionally its branch
1651---@param args string[]
1652function M.cmd_remove(args)
1653 local branch = nil
1654 local delete_branch = false
1655 local force = false
1656
1657 for _, a in ipairs(args) do
1658 if a == "-b" then
1659 delete_branch = true
1660 elseif a == "-f" then
1661 force = true
1662 elseif not branch then
1663 branch = a
1664 else
1665 shell.die("unexpected argument: " .. a)
1666 end
1667 end
1668
1669 if not branch then
1670 shell.die("usage: wt r <branch> [-b] [-f]")
1671 return
1672 end
1673
1674 local root, err = git.find_project_root()
1675 if not root then
1676 shell.die(err --[[@as string]])
1677 return
1678 end
1679
1680 local git_dir = root .. "/.bare"
1681
1682 local wt_output, wt_code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain")
1683 if wt_code ~= 0 then
1684 shell.die("failed to list worktrees", exit.EXIT_SYSTEM_ERROR)
1685 return
1686 end
1687
1688 local worktrees = git.parse_worktree_list(wt_output)
1689 local target_path = nil
1690 for _, wt in ipairs(worktrees) do
1691 if wt.branch == branch then
1692 target_path = wt.path
1693 break
1694 end
1695 end
1696
1697 if not target_path then
1698 shell.die("no worktree found for branch '" .. branch .. "'")
1699 return
1700 end
1701
1702 if cwd_inside_path(target_path) then
1703 shell.die("cannot remove worktree while inside it")
1704 end
1705
1706 if not force then
1707 local status_out = shell.run_cmd("git -C " .. target_path .. " status --porcelain")
1708 if status_out ~= "" then
1709 shell.die("worktree has uncommitted changes (use -f to force)")
1710 end
1711 end
1712
1713 local remove_cmd = "GIT_DIR=" .. git_dir .. " git worktree remove"
1714 if force then
1715 remove_cmd = remove_cmd .. " --force"
1716 end
1717 remove_cmd = remove_cmd .. " -- " .. target_path
1718
1719 local output, code = shell.run_cmd(remove_cmd)
1720 if code ~= 0 then
1721 shell.die("failed to remove worktree: " .. output, exit.EXIT_SYSTEM_ERROR)
1722 end
1723
1724 cleanup_empty_parents(target_path, root)
1725
1726 if delete_branch then
1727 local bare_head = get_bare_head(git_dir)
1728 if bare_head and bare_head == branch then
1729 io.stderr:write("warning: cannot delete branch '" .. branch .. "' (it's the bare repo's HEAD)\n")
1730 print("Worktree removed; branch retained")
1731 return
1732 end
1733
1734 local checked_out = git.branch_checked_out_at(git_dir, branch)
1735 if checked_out then
1736 shell.die("cannot delete branch '" .. branch .. "': checked out at " .. checked_out)
1737 end
1738
1739 local delete_flag = force and "-D" or "-d"
1740 local del_output, del_code =
1741 shell.run_cmd("GIT_DIR=" .. git_dir .. " git branch " .. delete_flag .. " " .. branch)
1742 if del_code ~= 0 then
1743 io.stderr:write("warning: failed to delete branch: " .. del_output .. "\n")
1744 print("Worktree removed; branch retained")
1745 return
1746 end
1747
1748 print("Worktree and branch '" .. branch .. "' removed")
1749 else
1750 print("Worktree removed")
1751 end
1752end
1753
1754return M
1755]=]
1756
1757_EMBEDDED_MODULES["wt.cmd.list"] = [=[-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
1758--
1759-- SPDX-License-Identifier: GPL-3.0-or-later
1760
1761local exit = require("wt.exit")
1762local shell = require("wt.shell")
1763local git = require("wt.git")
1764local path_mod = require("wt.path")
1765
1766---@class wt.cmd.list
1767local M = {}
1768
1769---List all worktrees with status
1770function M.cmd_list()
1771 local root, err = git.find_project_root()
1772 if not root then
1773 shell.die(err --[[@as string]])
1774 return
1775 end
1776
1777 local git_dir = root .. "/.bare"
1778 local output, code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain")
1779 if code ~= 0 then
1780 shell.die("failed to list worktrees: " .. output, exit.EXIT_SYSTEM_ERROR)
1781 end
1782
1783 ---@type {path: string, head: string, branch: string}[]
1784 local worktrees = {}
1785 local current = {}
1786
1787 for line in output:gmatch("[^\n]+") do
1788 local key, value = line:match("^(%S+)%s*(.*)$")
1789 if key == "worktree" and value then
1790 if current.path then
1791 table.insert(worktrees, current)
1792 end
1793 if value:match("/%.bare$") then
1794 current = {}
1795 else
1796 current = { path = value, head = "", branch = "(detached)" }
1797 end
1798 elseif key == "HEAD" and value then
1799 current.head = value:sub(1, 7)
1800 elseif key == "branch" and value then
1801 current.branch = value:gsub("^refs/heads/", "")
1802 elseif key == "bare" then
1803 current = {}
1804 end
1805 end
1806 if current.path then
1807 table.insert(worktrees, current)
1808 end
1809
1810 if #worktrees == 0 then
1811 print("No worktrees found")
1812 return
1813 end
1814
1815 local cwd = shell.get_cwd() or ""
1816
1817 local rows = {}
1818 for _, wt in ipairs(worktrees) do
1819 local rel_path = path_mod.relative_path(cwd, wt.path)
1820
1821 local status_out = shell.run_cmd("git -C " .. wt.path .. " status --porcelain")
1822 local status = status_out == "" and "clean" or "dirty"
1823
1824 table.insert(rows, rel_path .. "," .. wt.branch .. "," .. wt.head .. "," .. status)
1825 end
1826
1827 local table_input = "Path,Branch,HEAD,Status\n" .. table.concat(rows, "\n")
1828 table_input = table_input:gsub("EOF", "eof")
1829 local table_cmd = "gum table --print <<'EOF'\n" .. table_input .. "\nEOF"
1830 local table_handle = io.popen(table_cmd, "r")
1831 if not table_handle then
1832 return
1833 end
1834 io.write(table_handle:read("*a") or "")
1835 table_handle:close()
1836end
1837
1838return M
1839]=]
1840
1841_EMBEDDED_MODULES["wt.cmd.fetch"] = [=[-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
1842--
1843-- SPDX-License-Identifier: GPL-3.0-or-later
1844
1845local exit = require("wt.exit")
1846local shell = require("wt.shell")
1847local git = require("wt.git")
1848
1849---@class wt.cmd.fetch
1850local M = {}
1851
1852---Get list of configured remotes
1853---@param git_dir string
1854---@return string[] remotes
1855local function get_remotes(git_dir)
1856 local output, code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git remote")
1857 if code ~= 0 then
1858 return {}
1859 end
1860 local remotes = {}
1861 for line in output:gmatch("[^\n]+") do
1862 local trimmed = line:match("^%s*(.-)%s*$")
1863 if trimmed and trimmed ~= "" then
1864 table.insert(remotes, trimmed)
1865 end
1866 end
1867 return remotes
1868end
1869
1870---Fetch all remotes with pruning, tolerating partial failures
1871function M.cmd_fetch()
1872 local root, err = git.find_project_root()
1873 if not root then
1874 shell.die(err --[[@as string]])
1875 return
1876 end
1877
1878 local git_dir = root .. "/.bare"
1879 local remotes = get_remotes(git_dir)
1880
1881 if #remotes == 0 then
1882 shell.die("no remotes configured", exit.EXIT_USER_ERROR)
1883 return
1884 end
1885
1886 local succeeded = 0
1887 local failures = {}
1888
1889 for _, remote in ipairs(remotes) do
1890 local output, code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git fetch --prune " .. shell.quote(remote))
1891 if code == 0 then
1892 succeeded = succeeded + 1
1893 io.write(output)
1894 else
1895 table.insert(failures, { remote = remote, output = output })
1896 end
1897 end
1898
1899 for _, f in ipairs(failures) do
1900 io.stderr:write("warning: failed to fetch " .. f.remote .. "\n")
1901 if f.output and f.output ~= "" then
1902 io.stderr:write(f.output)
1903 end
1904 end
1905
1906 if succeeded == 0 then
1907 io.stderr:write("error: all remotes failed to fetch\n")
1908 os.exit(exit.EXIT_SYSTEM_ERROR)
1909 end
1910end
1911
1912return M
1913]=]
1914
1915_EMBEDDED_MODULES["wt.cmd.init"] = [[-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
1916--
1917-- SPDX-License-Identifier: GPL-3.0-or-later
1918
1919local exit = require("wt.exit")
1920local shell = require("wt.shell")
1921local git = require("wt.git")
1922local path_mod = require("wt.path")
1923local config = require("wt.config")
1924
1925---@class wt.cmd.init
1926local M = {}
1927
1928---List directory entries (excluding . and ..)
1929---@param path string
1930---@return string[]
1931local function list_dir(path)
1932 local entries = {}
1933 local handle = io.popen("ls -A " .. path .. " 2>/dev/null")
1934 if not handle then
1935 return entries
1936 end
1937 for line in handle:lines() do
1938 if line ~= "" then
1939 table.insert(entries, line)
1940 end
1941 end
1942 handle:close()
1943 return entries
1944end
1945
1946---Check if path is a directory
1947---@param path string
1948---@return boolean
1949local function is_dir(path)
1950 local f = io.open(path, "r")
1951 if not f then
1952 return false
1953 end
1954 f:close()
1955 return shell.run_cmd_silent("test -d " .. path)
1956end
1957
1958---Check if path is a file (not directory)
1959---@param path string
1960---@return boolean
1961local function is_file(path)
1962 local f = io.open(path, "r")
1963 if not f then
1964 return false
1965 end
1966 f:close()
1967 return shell.run_cmd_silent("test -f " .. path)
1968end
1969
1970---Convert existing git repository to wt bare structure
1971---@param args string[]
1972function M.cmd_init(args)
1973 -- Parse arguments
1974 local dry_run = false
1975 local skip_confirm = false
1976 for _, a in ipairs(args) do
1977 if a == "--dry-run" then
1978 dry_run = true
1979 elseif a == "-y" or a == "--yes" then
1980 skip_confirm = true
1981 else
1982 shell.die("unexpected argument: " .. a)
1983 end
1984 end
1985
1986 local cwd = shell.get_cwd()
1987 if not cwd then
1988 shell.die("failed to get current directory", exit.EXIT_SYSTEM_ERROR)
1989 return
1990 end
1991
1992 -- Case 1: Already wt-managed (.git file pointing to .bare + .bare/ exists)
1993 local git_path = cwd .. "/.git"
1994 local bare_path = cwd .. "/.bare"
1995
1996 local bare_exists = is_dir(bare_path)
1997 local git_file = io.open(git_path, "r")
1998
1999 if git_file then
2000 local content = git_file:read("*a")
2001 git_file:close()
2002
2003 -- Check if it's a file (not directory) pointing to .bare
2004 if is_file(git_path) and content and content:match("gitdir:%s*%.?/?%.bare") then
2005 if bare_exists then
2006 print("Already using wt bare structure")
2007 os.exit(exit.EXIT_SUCCESS)
2008 end
2009 end
2010
2011 -- Check if .git is a file pointing elsewhere (inside a worktree)
2012 if is_file(git_path) and content and content:match("^gitdir:") then
2013 -- It's a worktree, not project root
2014 shell.die("inside a worktree; run from project root or use 'wt c' to clone fresh")
2015 end
2016 end
2017
2018 -- Check for .git directory
2019 local git_dir_exists = is_dir(git_path)
2020
2021 if not git_dir_exists then
2022 -- Case 5: No .git at all, or bare repo without .git dir
2023 if bare_exists then
2024 shell.die("found .bare/ but no .git; manually create .git file with 'gitdir: ./.bare'")
2025 end
2026 shell.die("not a git repository (no .git found)")
2027 end
2028
2029 -- Now we have a .git directory
2030 -- Case 3: Existing worktree setup (.git/worktrees/ exists)
2031 local worktrees_path = git_path .. "/worktrees"
2032 if is_dir(worktrees_path) then
2033 local worktrees = list_dir(worktrees_path)
2034 io.stderr:write("error: repository already uses git worktrees\n")
2035 io.stderr:write("\n")
2036 io.stderr:write("Converting an existing worktree setup is complex and error-prone.\n")
2037 io.stderr:write("Recommend: clone fresh with 'wt c <url>' and recreate worktrees.\n")
2038 if #worktrees > 0 then
2039 io.stderr:write("\nExisting worktrees:\n")
2040 for _, wt in ipairs(worktrees) do
2041 io.stderr:write(" " .. wt .. "\n")
2042 end
2043 end
2044 os.exit(exit.EXIT_USER_ERROR)
2045 end
2046
2047 -- Case 4: Normal clone (.git/ directory, no worktrees)
2048 -- Check for uncommitted changes
2049 local status_out = shell.run_cmd("git status --porcelain")
2050 if status_out ~= "" then
2051 io.stderr:write("error: uncommitted changes\n")
2052 io.stderr:write("hint: commit, or stash and restore after:\n")
2053 io.stderr:write(" git stash -u && wt init && cd <worktree> && git stash pop\n")
2054 os.exit(exit.EXIT_USER_ERROR)
2055 end
2056
2057 -- Detect default branch
2058 local default_branch = git.detect_cloned_default_branch(git_path)
2059
2060 -- Warnings
2061 local warnings = {}
2062
2063 -- Check for submodules
2064 if is_file(cwd .. "/.gitmodules") then
2065 table.insert(warnings, "submodules detected (.gitmodules exists); verify they work after conversion")
2066 end
2067
2068 -- Check for nested .git directories (excluding the main one)
2069 local nested_git_output, _ = shell.run_cmd("find " .. cwd .. " -mindepth 2 -name .git -type d 2>/dev/null")
2070 if nested_git_output ~= "" then
2071 table.insert(warnings, "nested .git directories found; these may cause issues")
2072 end
2073
2074 -- Find orphaned files (files in root that will be deleted)
2075 local all_entries = list_dir(cwd)
2076 local orphaned = {}
2077 for _, entry in ipairs(all_entries) do
2078 if entry ~= ".git" and entry ~= ".bare" then
2079 table.insert(orphaned, entry)
2080 end
2081 end
2082
2083 -- Load global config for path style
2084 local global_config = config.load_global_config()
2085 local style = global_config.branch_path_style or "nested"
2086 local separator = global_config.flat_separator
2087 local worktree_path = path_mod.branch_to_path(cwd, default_branch, style, separator)
2088
2089 if dry_run then
2090 print("Dry run - planned actions:")
2091 print("")
2092 print("1. Move .git/ to .bare/")
2093 print("2. Create .git file pointing to .bare/")
2094 print("3. Create worktree: " .. worktree_path .. " (" .. default_branch .. ")")
2095 if #orphaned > 0 then
2096 print("4. Remove " .. #orphaned .. " orphaned items from root:")
2097 for _, item in ipairs(orphaned) do
2098 print(" - " .. item)
2099 end
2100 end
2101 if #warnings > 0 then
2102 print("")
2103 print("Warnings:")
2104 for _, w in ipairs(warnings) do
2105 print(" ⚠ " .. w)
2106 end
2107 end
2108 os.exit(exit.EXIT_SUCCESS)
2109 end
2110
2111 -- Show warnings
2112 for _, w in ipairs(warnings) do
2113 io.stderr:write("warning: " .. w .. "\n")
2114 end
2115
2116 -- Prominent warning about file deletion
2117 if #orphaned > 0 then
2118 io.stderr:write("\n")
2119 io.stderr:write("WARNING: The following " .. #orphaned .. " items will be DELETED from project root:\n")
2120 for _, item in ipairs(orphaned) do
2121 io.stderr:write(" - " .. item .. "\n")
2122 end
2123 io.stderr:write("\n")
2124 io.stderr:write("These files are preserved in the new worktree at:\n")
2125 io.stderr:write(" " .. worktree_path .. "\n")
2126 io.stderr:write("\n")
2127 end
2128
2129 -- Confirm with gum (unless -y/--yes)
2130 if not skip_confirm then
2131 local confirm_code = os.execute("gum confirm 'Convert to wt bare structure?'")
2132 if confirm_code ~= true then
2133 print("Aborted")
2134 os.exit(exit.EXIT_USER_ERROR)
2135 end
2136 end
2137
2138 -- Step 1: Move .git to .bare
2139 local output, code = shell.run_cmd("mv " .. git_path .. " " .. bare_path)
2140 if code ~= 0 then
2141 shell.die("failed to move .git to .bare: " .. output, exit.EXIT_SYSTEM_ERROR)
2142 end
2143
2144 -- Step 2: Write .git file
2145 local git_file_handle = io.open(git_path, "w")
2146 if not git_file_handle then
2147 -- Try to recover
2148 shell.run_cmd("mv " .. bare_path .. " " .. git_path)
2149 shell.die("failed to create .git file", exit.EXIT_SYSTEM_ERROR)
2150 return
2151 end
2152 git_file_handle:write("gitdir: ./.bare\n")
2153 git_file_handle:close()
2154
2155 -- Step 3: Detach HEAD so branch can be checked out in worktree
2156 -- Point bare repo's HEAD to a placeholder so main branch can be used by worktree
2157 shell.run_cmd("GIT_DIR=" .. bare_path .. " git symbolic-ref HEAD refs/heads/__wt_detached_placeholder__")
2158
2159 -- Step 4: Create worktree for default branch
2160 output, code =
2161 shell.run_cmd("GIT_DIR=" .. bare_path .. " git worktree add -- " .. worktree_path .. " " .. default_branch)
2162 if code ~= 0 then
2163 shell.die("failed to create worktree: " .. output, exit.EXIT_SYSTEM_ERROR)
2164 end
2165
2166 -- Step 5: Remove orphaned files from root
2167 for _, item in ipairs(orphaned) do
2168 local item_path = cwd .. "/" .. item
2169 output, code = shell.run_cmd("rm -rf " .. item_path)
2170 if code ~= 0 then
2171 io.stderr:write("warning: failed to remove " .. item .. ": " .. output .. "\n")
2172 end
2173 end
2174
2175 -- Summary
2176 print("Converted to wt bare structure")
2177 print("Bare repo: " .. bare_path)
2178 print("Worktree: " .. worktree_path)
2179 if #orphaned > 0 then
2180 print("Removed: " .. #orphaned .. " items from root")
2181 end
2182end
2183
2184return M
2185]]
2186
2187
2188if _VERSION < "Lua 5.2" then
2189 io.stderr:write("error: wt requires Lua 5.2 or later\n")
2190 os.exit(1)
2191end
2192
2193local exit = require("wt.exit")
2194local shell = require("wt.shell")
2195local path_mod = require("wt.path")
2196local git_mod = require("wt.git")
2197local config_mod = require("wt.config")
2198local hooks_mod = require("wt.hooks")
2199local help_mod = require("wt.help")
2200local fetch_mod = require("wt.cmd.fetch")
2201local list_mod = require("wt.cmd.list")
2202local remove_mod = require("wt.cmd.remove")
2203local add_mod = require("wt.cmd.add")
2204local new_mod = require("wt.cmd.new")
2205local clone_mod = require("wt.cmd.clone")
2206local init_mod = require("wt.cmd.init")
2207
2208-- Main entry point
2209
2210local function main()
2211 local command = arg[1]
2212
2213 if not command or command == "help" or command == "--help" or command == "-h" then
2214 help_mod.print_usage()
2215 os.exit(exit.EXIT_SUCCESS)
2216 end
2217
2218 ---@cast command string
2219
2220 -- Collect remaining args
2221 local subargs = {}
2222 for i = 2, #arg do
2223 table.insert(subargs, arg[i])
2224 end
2225
2226 -- Check for --help on any command
2227 if subargs[1] == "--help" or subargs[1] == "-h" then
2228 help_mod.show_command_help(command)
2229 end
2230
2231 if command == "c" then
2232 clone_mod.cmd_clone(subargs)
2233 elseif command == "n" then
2234 new_mod.cmd_new(subargs)
2235 elseif command == "a" then
2236 add_mod.cmd_add(subargs)
2237 elseif command == "r" then
2238 remove_mod.cmd_remove(subargs)
2239 elseif command == "l" then
2240 list_mod.cmd_list()
2241 elseif command == "f" then
2242 fetch_mod.cmd_fetch()
2243 elseif command == "init" then
2244 init_mod.cmd_init(subargs)
2245 else
2246 shell.die("unknown command: " .. command)
2247 end
2248end
2249
2250-- Export for testing when required as module
2251if pcall(debug.getlocal, 4, 1) then
2252 ---@diagnostic disable: duplicate-set-field
2253 return {
2254 -- URL/project parsing
2255 extract_project_name = config_mod.extract_project_name,
2256 resolve_url_template = config_mod.resolve_url_template,
2257 -- Path manipulation
2258 branch_to_path = path_mod.branch_to_path,
2259 split_path = path_mod.split_path,
2260 relative_path = path_mod.relative_path,
2261 path_inside = path_mod.path_inside,
2262 -- Config loading
2263 load_global_config = config_mod.load_global_config,
2264 load_project_config = config_mod.load_project_config,
2265 -- Git output parsing (testable without git)
2266 parse_branch_remotes = git_mod.parse_branch_remotes,
2267 parse_worktree_list = git_mod.parse_worktree_list,
2268 escape_pattern = path_mod.escape_pattern,
2269 -- Hook helpers (re-exported from wt.hooks)
2270 summarize_hooks = hooks_mod.summarize_hooks,
2271 load_hook_permissions = hooks_mod.load_hook_permissions,
2272 save_hook_permissions = hooks_mod.save_hook_permissions,
2273 run_hooks = hooks_mod.run_hooks,
2274 -- Project root detection (re-exported from wt.git)
2275 find_project_root = git_mod.find_project_root,
2276 detect_source_worktree = git_mod.detect_source_worktree,
2277 -- Command execution (for integration tests)
2278 run_cmd = shell.run_cmd,
2279 run_cmd_silent = shell.run_cmd_silent,
2280 -- Exit codes (re-exported from wt.exit)
2281 EXIT_SUCCESS = exit.EXIT_SUCCESS,
2282 EXIT_USER_ERROR = exit.EXIT_USER_ERROR,
2283 EXIT_SYSTEM_ERROR = exit.EXIT_SYSTEM_ERROR,
2284 }
2285end
2286
2287main()