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 local url = nil
964 ---@type string[]
965 local remote_flags = {}
966 local own = false
967
968 local i = 1
969 while i <= #args do
970 local a = args[i]
971 if a == "--remote" then
972 if not args[i + 1] then
973 shell.die("--remote requires a name")
974 end
975 table.insert(remote_flags, args[i + 1])
976 i = i + 1
977 elseif a == "--own" then
978 own = true
979 elseif not url then
980 url = a
981 else
982 shell.die("unexpected argument: " .. a)
983 end
984 i = i + 1
985 end
986
987 if not url then
988 shell.die("usage: wt c <url> [--remote name]... [--own]")
989 return
990 end
991
992 -- Extract project name from URL
993 local project_name = config.extract_project_name(url)
994 if not project_name then
995 shell.die("could not extract project name from URL: " .. url)
996 return
997 end
998
999 -- Check if project directory already exists
1000 local cwd = shell.get_cwd()
1001 if not cwd then
1002 shell.die("failed to get current directory", exit.EXIT_SYSTEM_ERROR)
1003 end
1004 ---@cast cwd string
1005 local project_path = cwd .. "/" .. project_name
1006 local check = io.open(project_path, "r")
1007 if check then
1008 check:close()
1009 shell.die("directory already exists: " .. project_path)
1010 end
1011
1012 -- Clone bare repo
1013 local bare_path = project_path .. "/.bare"
1014 local output, code = shell.run_cmd("git clone --bare " .. url .. " " .. bare_path)
1015 if code ~= 0 then
1016 shell.die("failed to clone: " .. output, exit.EXIT_SYSTEM_ERROR)
1017 end
1018
1019 -- Write .git file pointing to .bare
1020 local git_file_handle = io.open(project_path .. "/.git", "w")
1021 if not git_file_handle then
1022 shell.die("failed to create .git file", exit.EXIT_SYSTEM_ERROR)
1023 return
1024 end
1025 git_file_handle:write("gitdir: ./.bare\n")
1026 git_file_handle:close()
1027
1028 -- Detect default branch
1029 local git_dir = bare_path
1030 local default_branch = git.detect_cloned_default_branch(git_dir)
1031
1032 -- Load global config
1033 local global_config = config.load_global_config()
1034
1035 -- Determine which remotes to use
1036 ---@type string[]
1037 local selected_remotes = {}
1038
1039 if #remote_flags > 0 then
1040 selected_remotes = remote_flags
1041 elseif global_config.default_remotes then
1042 if type(global_config.default_remotes) == "table" then
1043 selected_remotes = global_config.default_remotes --[[@as string[] ]]
1044 ---@diagnostic disable-next-line: unnecessary-if
1045 elseif global_config.default_remotes == "prompt" then
1046 if global_config.remotes then
1047 local keys = {}
1048 for k in pairs(global_config.remotes) do
1049 table.insert(keys, k)
1050 end
1051 table.sort(keys)
1052 if #keys > 0 then
1053 local input = table.concat(keys, "\n")
1054 local choose_type = own and "" or " --no-limit"
1055 local cmd = "echo '" .. input .. "' | gum choose" .. choose_type
1056 output, code = shell.run_cmd(cmd)
1057 if code == 0 and output ~= "" then
1058 for line in output:gmatch("[^\n]+") do
1059 table.insert(selected_remotes, line)
1060 end
1061 end
1062 end
1063 end
1064 end
1065 elseif global_config.remotes then
1066 local keys = {}
1067 for k in pairs(global_config.remotes) do
1068 table.insert(keys, k)
1069 end
1070 table.sort(keys)
1071 if #keys > 0 then
1072 local input = table.concat(keys, "\n")
1073 local choose_type = own and "" or " --no-limit"
1074 local cmd = "echo '" .. input .. "' | gum choose" .. choose_type
1075 output, code = shell.run_cmd(cmd)
1076 if code == 0 and output ~= "" then
1077 for line in output:gmatch("[^\n]+") do
1078 table.insert(selected_remotes, line)
1079 end
1080 end
1081 end
1082 end
1083
1084 -- Track configured remotes for summary
1085 ---@type string[]
1086 local configured_remotes = {}
1087
1088 if own then
1089 -- User's own project: origin is their canonical remote
1090 if #selected_remotes > 0 then
1091 local first_remote = selected_remotes[1]
1092 -- Rename origin to first remote
1093 output, code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git remote rename origin " .. first_remote)
1094 if code ~= 0 then
1095 io.stderr:write("warning: failed to rename origin to " .. first_remote .. ": " .. output .. "\n")
1096 else
1097 -- Configure fetch refspec
1098 shell.run_cmd(
1099 "GIT_DIR="
1100 .. git_dir
1101 .. " git config remote."
1102 .. first_remote
1103 .. ".fetch '+refs/heads/*:refs/remotes/"
1104 .. first_remote
1105 .. "/*'"
1106 )
1107 table.insert(configured_remotes, first_remote)
1108 end
1109
1110 -- Add additional remotes and push to them
1111 local remotes = global_config.remotes
1112 for j = 2, #selected_remotes do
1113 local remote_name = selected_remotes[j]
1114 if remotes then
1115 local template = remotes[remote_name]
1116 if template then
1117 local remote_url = config.resolve_url_template(template, project_name)
1118 output, code = shell.run_cmd(
1119 "GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. remote_url
1120 )
1121 if code ~= 0 then
1122 io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
1123 else
1124 shell.run_cmd(
1125 "GIT_DIR="
1126 .. git_dir
1127 .. " git config remote."
1128 .. remote_name
1129 .. ".fetch '+refs/heads/*:refs/remotes/"
1130 .. remote_name
1131 .. "/*'"
1132 )
1133 -- Push to additional remotes
1134 output, code = shell.run_cmd(
1135 "GIT_DIR=" .. git_dir .. " git push " .. remote_name .. " " .. default_branch
1136 )
1137 if code ~= 0 then
1138 io.stderr:write("warning: failed to push to " .. remote_name .. ": " .. output .. "\n")
1139 end
1140 table.insert(configured_remotes, remote_name)
1141 end
1142 else
1143 io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
1144 end
1145 else
1146 io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
1147 end
1148 end
1149 else
1150 -- No remotes selected, keep origin as-is
1151 shell.run_cmd(
1152 "GIT_DIR=" .. git_dir .. " git config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'"
1153 )
1154 table.insert(configured_remotes, "origin")
1155 end
1156 else
1157 -- Contributing to someone else's project
1158 -- Rename origin to upstream
1159 output, code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git remote rename origin upstream")
1160 if code ~= 0 then
1161 io.stderr:write("warning: failed to rename origin to upstream: " .. output .. "\n")
1162 else
1163 shell.run_cmd(
1164 "GIT_DIR=" .. git_dir .. " git config remote.upstream.fetch '+refs/heads/*:refs/remotes/upstream/*'"
1165 )
1166 table.insert(configured_remotes, "upstream")
1167 end
1168
1169 -- Add user's remotes and push to each
1170 local remotes = global_config.remotes
1171 for _, remote_name in ipairs(selected_remotes) do
1172 if remotes then
1173 local template = remotes[remote_name]
1174 if template then
1175 local remote_url = config.resolve_url_template(template, project_name)
1176 output, code =
1177 shell.run_cmd("GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. remote_url)
1178 if code ~= 0 then
1179 io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
1180 else
1181 shell.run_cmd(
1182 "GIT_DIR="
1183 .. git_dir
1184 .. " git config remote."
1185 .. remote_name
1186 .. ".fetch '+refs/heads/*:refs/remotes/"
1187 .. remote_name
1188 .. "/*'"
1189 )
1190 -- Push to this remote
1191 output, code =
1192 shell.run_cmd("GIT_DIR=" .. git_dir .. " git push " .. remote_name .. " " .. default_branch)
1193 if code ~= 0 then
1194 io.stderr:write("warning: failed to push to " .. remote_name .. ": " .. output .. "\n")
1195 end
1196 table.insert(configured_remotes, remote_name)
1197 end
1198 else
1199 io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
1200 end
1201 else
1202 io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
1203 end
1204 end
1205 end
1206
1207 -- Fetch all remotes
1208 shell.run_cmd("GIT_DIR=" .. git_dir .. " git fetch --all")
1209
1210 -- Load config for path style
1211 local style = global_config.branch_path_style or "nested"
1212 local separator = global_config.flat_separator
1213 local worktree_path = path_mod.branch_to_path(project_path, default_branch, style, separator)
1214
1215 -- Create initial worktree
1216 output, code =
1217 shell.run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -- " .. worktree_path .. " " .. default_branch)
1218 if code ~= 0 then
1219 shell.die("failed to create worktree: " .. output, exit.EXIT_SYSTEM_ERROR)
1220 end
1221
1222 -- Print summary
1223 print("Created project: " .. project_path)
1224 print("Default branch: " .. default_branch)
1225 print("Worktree: " .. worktree_path)
1226 if #configured_remotes > 0 then
1227 print("Remotes: " .. table.concat(configured_remotes, ", "))
1228 end
1229end
1230
1231return M
1232]=]
1233
1234_EMBEDDED_MODULES["wt.cmd.new"] = [=[-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
1235--
1236-- SPDX-License-Identifier: GPL-3.0-or-later
1237
1238local exit = require("wt.exit")
1239local shell = require("wt.shell")
1240local git = require("wt.git")
1241local path_mod = require("wt.path")
1242local config = require("wt.config")
1243
1244---@class wt.cmd.new
1245local M = {}
1246
1247---Create a new project with bare repo structure
1248---@param args string[]
1249function M.cmd_new(args)
1250 -- Parse arguments: <project-name> [--remote name]...
1251 local project_name = nil
1252 ---@type string[]
1253 local remote_flags = {}
1254
1255 local i = 1
1256 while i <= #args do
1257 local a = args[i]
1258 if a == "--remote" then
1259 if not args[i + 1] then
1260 shell.die("--remote requires a name")
1261 end
1262 table.insert(remote_flags, args[i + 1])
1263 i = i + 1
1264 elseif not project_name then
1265 project_name = a
1266 else
1267 shell.die("unexpected argument: " .. a)
1268 end
1269 i = i + 1
1270 end
1271
1272 if not project_name then
1273 shell.die("usage: wt n <project-name> [--remote name]...")
1274 return
1275 end
1276
1277 -- Check if project directory already exists
1278 local cwd = shell.get_cwd()
1279 if not cwd then
1280 shell.die("failed to get current directory", exit.EXIT_SYSTEM_ERROR)
1281 end
1282 ---@cast cwd string
1283 local project_path = cwd .. "/" .. project_name
1284 local check = io.open(project_path, "r")
1285 if check then
1286 check:close()
1287 shell.die("directory already exists: " .. project_path)
1288 end
1289
1290 -- Load global config
1291 local global_config = config.load_global_config()
1292
1293 -- Determine which remotes to use
1294 ---@type string[]
1295 local selected_remotes = {}
1296
1297 if #remote_flags > 0 then
1298 -- Use explicitly provided remotes
1299 selected_remotes = remote_flags
1300 elseif global_config.default_remotes then
1301 if type(global_config.default_remotes) == "table" then
1302 selected_remotes = global_config.default_remotes --[[@as string[] ]]
1303 ---@diagnostic disable-next-line: unnecessary-if
1304 elseif global_config.default_remotes == "prompt" then
1305 -- Prompt with gum choose
1306 if global_config.remotes then
1307 local keys = {}
1308 for k in pairs(global_config.remotes) do
1309 table.insert(keys, k)
1310 end
1311 table.sort(keys)
1312 if #keys > 0 then
1313 local input = table.concat(keys, "\n")
1314 local cmd = "echo '" .. input .. "' | gum choose --no-limit"
1315 local output, code = shell.run_cmd(cmd)
1316 if code == 0 and output ~= "" then
1317 for line in output:gmatch("[^\n]+") do
1318 table.insert(selected_remotes, line)
1319 end
1320 end
1321 end
1322 end
1323 end
1324 elseif global_config.remotes then
1325 -- No default_remotes configured, prompt if remotes exist
1326 local keys = {}
1327 for k in pairs(global_config.remotes) do
1328 table.insert(keys, k)
1329 end
1330 table.sort(keys)
1331 if #keys > 0 then
1332 local input = table.concat(keys, "\n")
1333 local cmd = "echo '" .. input .. "' | gum choose --no-limit"
1334 local output, code = shell.run_cmd(cmd)
1335 if code == 0 and output ~= "" then
1336 for line in output:gmatch("[^\n]+") do
1337 table.insert(selected_remotes, line)
1338 end
1339 end
1340 end
1341 end
1342
1343 -- Create project structure
1344 local bare_path = project_path .. "/.bare"
1345 local output, code = shell.run_cmd("mkdir -p " .. bare_path)
1346 if code ~= 0 then
1347 shell.die("failed to create directory: " .. output, exit.EXIT_SYSTEM_ERROR)
1348 end
1349
1350 output, code = shell.run_cmd("git init --bare " .. bare_path)
1351 if code ~= 0 then
1352 shell.die("failed to init bare repo: " .. output, exit.EXIT_SYSTEM_ERROR)
1353 end
1354
1355 -- Write .git file pointing to .bare
1356 local git_file_handle = io.open(project_path .. "/.git", "w")
1357 if not git_file_handle then
1358 shell.die("failed to create .git file", exit.EXIT_SYSTEM_ERROR)
1359 return
1360 end
1361 git_file_handle:write("gitdir: ./.bare\n")
1362 git_file_handle:close()
1363
1364 -- Add remotes
1365 local git_dir = bare_path
1366 local remotes = global_config.remotes
1367 for _, remote_name in ipairs(selected_remotes) do
1368 if remotes then
1369 local template = remotes[remote_name]
1370 if template then
1371 local url = config.resolve_url_template(template, project_name)
1372 output, code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. url)
1373 if code ~= 0 then
1374 io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
1375 else
1376 -- Configure fetch refspec for the remote
1377 shell.run_cmd(
1378 "GIT_DIR="
1379 .. git_dir
1380 .. " git config remote."
1381 .. remote_name
1382 .. ".fetch '+refs/heads/*:refs/remotes/"
1383 .. remote_name
1384 .. "/*'"
1385 )
1386 end
1387 else
1388 io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
1389 end
1390 else
1391 io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
1392 end
1393 end
1394
1395 -- Detect default branch
1396 local default_branch = git.get_default_branch()
1397
1398 -- Load config for path style
1399 local style = global_config.branch_path_style or "nested"
1400 local separator = global_config.flat_separator
1401 local worktree_path = path_mod.branch_to_path(project_path, default_branch, style, separator)
1402
1403 -- Create orphan worktree
1404 output, code = shell.run_cmd(
1405 "GIT_DIR=" .. git_dir .. " git worktree add --orphan -b " .. default_branch .. " -- " .. worktree_path
1406 )
1407 if code ~= 0 then
1408 shell.die("failed to create worktree: " .. output, exit.EXIT_SYSTEM_ERROR)
1409 end
1410
1411 -- Print summary
1412 print("Created project: " .. project_path)
1413 print("Default branch: " .. default_branch)
1414 print("Worktree: " .. worktree_path)
1415 if #selected_remotes > 0 then
1416 print("Remotes: " .. table.concat(selected_remotes, ", "))
1417 end
1418end
1419
1420return M
1421]=]
1422
1423_EMBEDDED_MODULES["wt.cmd.add"] = [=[-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
1424--
1425-- SPDX-License-Identifier: GPL-3.0-or-later
1426
1427local exit = require("wt.exit")
1428local shell = require("wt.shell")
1429local git = require("wt.git")
1430local path_mod = require("wt.path")
1431local config = require("wt.config")
1432local hooks = require("wt.hooks")
1433
1434---@class wt.cmd.add
1435local M = {}
1436
1437---Add a worktree for an existing or new branch
1438---@param args string[]
1439function M.cmd_add(args)
1440 -- Parse arguments: <branch> [-b [<start-point>]]
1441 ---@type string|nil
1442 local branch = nil
1443 local create_branch = false
1444 ---@type string|nil
1445 local start_point = nil
1446
1447 local i = 1
1448 while i <= #args do
1449 local a = args[i]
1450 if a == "-b" then
1451 create_branch = true
1452 -- Check if next arg is start-point (not another flag)
1453 if args[i + 1] and not args[i + 1]:match("^%-") then
1454 start_point = args[i + 1]
1455 i = i + 1
1456 end
1457 elseif not branch then
1458 branch = a
1459 else
1460 shell.die("unexpected argument: " .. a)
1461 end
1462 i = i + 1
1463 end
1464
1465 if not branch then
1466 shell.die("usage: wt a <branch> [-b [<start-point>]]")
1467 return
1468 end
1469
1470 local root, err = git.find_project_root()
1471 if not root then
1472 shell.die(err --[[@as string]])
1473 return
1474 end
1475
1476 local git_dir = root .. "/.bare"
1477 local source_worktree = git.detect_source_worktree(root)
1478
1479 -- Load config for path style
1480 local global_config = config.load_global_config()
1481 local style = global_config.branch_path_style or "nested"
1482 local separator = global_config.flat_separator or "_"
1483
1484 local target_path = path_mod.branch_to_path(root, branch, style, separator)
1485
1486 -- Check if worktree already exists for this branch (idempotent behavior)
1487 local existing_wt = git.find_worktree_by_branch(git_dir, branch)
1488 if existing_wt then
1489 -- Validate the worktree path actually exists (not stale metadata)
1490 local wt_check = io.open(existing_wt.path .. "/.git", "r")
1491 if not wt_check then
1492 shell.die(
1493 "worktree for '"
1494 .. branch
1495 .. "' is registered but missing at "
1496 .. existing_wt.path
1497 .. "\nhint: run `git worktree prune` to clean up stale entries"
1498 )
1499 end
1500 wt_check:close()
1501
1502 local project_config = config.load_project_config(root)
1503 io.stderr:write("worktree already exists at " .. existing_wt.path .. "\n")
1504 -- Warn if path differs from current config
1505 if existing_wt.path ~= target_path then
1506 io.stderr:write("note: current config would place it at " .. target_path .. "\n")
1507 end
1508 -- If running from root and hooks exist, offer to run them now
1509 if not source_worktree and project_config.hooks then
1510 io.stderr:write("hint: run `wt a` from inside a worktree to apply hooks from .wt.lua\n")
1511 end
1512 print(existing_wt.path)
1513 return
1514 end
1515
1516 -- Check if target path has a .git but for a different branch (conflict)
1517 local check = io.open(target_path .. "/.git", "r")
1518 if check then
1519 check:close()
1520 shell.die("directory already exists at " .. target_path .. " but is not a worktree for '" .. branch .. "'")
1521 end
1522
1523 local output, code
1524 if create_branch then
1525 -- Default start-point to source worktree's branch if inside one
1526 if not start_point and source_worktree then
1527 start_point = git.get_worktree_branch(git_dir, source_worktree)
1528 end
1529
1530 -- Check if start_point resolves to a valid commit (catches orphan branch case)
1531 if start_point and not git.ref_has_commits(git_dir, start_point) then
1532 shell.die("'" .. start_point .. "' has no commits yet; make an initial commit first")
1533 end
1534
1535 -- Create new branch with worktree
1536 if start_point then
1537 output, code = shell.run_cmd(
1538 "GIT_DIR="
1539 .. git_dir
1540 .. " git worktree add -b "
1541 .. branch
1542 .. " -- "
1543 .. target_path
1544 .. " "
1545 .. start_point
1546 )
1547 else
1548 output, code =
1549 shell.run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -b " .. branch .. " -- " .. target_path)
1550 end
1551 else
1552 -- Check if branch exists locally or on remotes
1553 local exists_local = git.branch_exists_local(git_dir, branch)
1554 local remotes = git.find_branch_remotes(git_dir, branch)
1555
1556 if not exists_local and #remotes == 0 then
1557 shell.die("branch '" .. branch .. "' not found locally or on any remote (use -b to create)")
1558 end
1559
1560 if #remotes > 1 then
1561 local hint = "use `-b " .. remotes[1] .. "/" .. branch .. "` to specify which remote to track"
1562 shell.die(
1563 "branch '" .. branch .. "' exists on multiple remotes: " .. table.concat(remotes, ", ") .. "\n" .. hint
1564 )
1565 end
1566
1567 output, code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -- " .. target_path .. " " .. branch)
1568 end
1569
1570 if code ~= 0 then
1571 shell.die("failed to add worktree: " .. output, exit.EXIT_SYSTEM_ERROR)
1572 end
1573
1574 -- Run hooks if we have a source worktree
1575 local project_config = config.load_project_config(root)
1576 if source_worktree then
1577 if project_config.hooks then
1578 hooks.run_hooks(source_worktree, target_path, project_config.hooks, root)
1579 end
1580 elseif project_config.hooks then
1581 io.stderr:write("hint: hooks skipped; run `wt a` from inside a worktree to apply hooks from .wt.lua\n")
1582 end
1583
1584 print(target_path)
1585end
1586
1587return M
1588]=]
1589
1590_EMBEDDED_MODULES["wt.cmd.remove"] = [=[-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
1591--
1592-- SPDX-License-Identifier: GPL-3.0-or-later
1593
1594local exit = require("wt.exit")
1595local shell = require("wt.shell")
1596local git = require("wt.git")
1597local path_mod = require("wt.path")
1598
1599---@class wt.cmd.remove
1600local M = {}
1601
1602---Check if cwd is inside (or equal to) a given path
1603---@param target string
1604---@return boolean
1605local function cwd_inside_path(target)
1606 local cwd = shell.get_cwd()
1607 if not cwd then
1608 return false
1609 end
1610 return path_mod.path_inside(cwd, target)
1611end
1612
1613---Get the bare repo's HEAD branch
1614---@param git_dir string
1615---@return string|nil branch name, nil on error
1616local function get_bare_head(git_dir)
1617 local output, code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git symbolic-ref --short HEAD")
1618 if code ~= 0 then
1619 return nil
1620 end
1621 return (output:gsub("%s+$", ""))
1622end
1623
1624---Remove empty parent directories between target and project root
1625---@param target_path string the removed worktree path
1626---@param project_root string the project root (stop here)
1627local function cleanup_empty_parents(target_path, project_root)
1628 project_root = project_root:gsub("/$", "")
1629 local parent = path_mod.parent_dir(target_path)
1630 while parent and parent ~= project_root and path_mod.path_inside(parent, project_root) do
1631 shell.run_cmd_silent("rmdir " .. shell.quote(parent))
1632 parent = path_mod.parent_dir(parent)
1633 end
1634end
1635
1636---Remove a worktree and optionally its branch
1637---@param args string[]
1638function M.cmd_remove(args)
1639 local branch = nil
1640 local delete_branch = false
1641 local force = false
1642
1643 for _, a in ipairs(args) do
1644 if a == "-b" then
1645 delete_branch = true
1646 elseif a == "-f" then
1647 force = true
1648 elseif not branch then
1649 branch = a
1650 else
1651 shell.die("unexpected argument: " .. a)
1652 end
1653 end
1654
1655 if not branch then
1656 shell.die("usage: wt r <branch> [-b] [-f]")
1657 return
1658 end
1659
1660 local root, err = git.find_project_root()
1661 if not root then
1662 shell.die(err --[[@as string]])
1663 return
1664 end
1665
1666 local git_dir = root .. "/.bare"
1667
1668 local wt_output, wt_code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain")
1669 if wt_code ~= 0 then
1670 shell.die("failed to list worktrees", exit.EXIT_SYSTEM_ERROR)
1671 return
1672 end
1673
1674 local worktrees = git.parse_worktree_list(wt_output)
1675 local target_path = nil
1676 for _, wt in ipairs(worktrees) do
1677 if wt.branch == branch then
1678 target_path = wt.path
1679 break
1680 end
1681 end
1682
1683 if not target_path then
1684 shell.die("no worktree found for branch '" .. branch .. "'")
1685 return
1686 end
1687
1688 if cwd_inside_path(target_path) then
1689 shell.die("cannot remove worktree while inside it")
1690 end
1691
1692 if not force then
1693 local status_out = shell.run_cmd("git -C " .. target_path .. " status --porcelain")
1694 if status_out ~= "" then
1695 shell.die("worktree has uncommitted changes (use -f to force)")
1696 end
1697 end
1698
1699 local remove_cmd = "GIT_DIR=" .. git_dir .. " git worktree remove"
1700 if force then
1701 remove_cmd = remove_cmd .. " --force"
1702 end
1703 remove_cmd = remove_cmd .. " -- " .. target_path
1704
1705 local output, code = shell.run_cmd(remove_cmd)
1706 if code ~= 0 then
1707 shell.die("failed to remove worktree: " .. output, exit.EXIT_SYSTEM_ERROR)
1708 end
1709
1710 cleanup_empty_parents(target_path, root)
1711
1712 if delete_branch then
1713 local bare_head = get_bare_head(git_dir)
1714 if bare_head and bare_head == branch then
1715 io.stderr:write("warning: cannot delete branch '" .. branch .. "' (it's the bare repo's HEAD)\n")
1716 print("Worktree removed; branch retained")
1717 return
1718 end
1719
1720 local checked_out = git.branch_checked_out_at(git_dir, branch)
1721 if checked_out then
1722 shell.die("cannot delete branch '" .. branch .. "': checked out at " .. checked_out)
1723 end
1724
1725 local delete_flag = force and "-D" or "-d"
1726 local del_output, del_code =
1727 shell.run_cmd("GIT_DIR=" .. git_dir .. " git branch " .. delete_flag .. " " .. branch)
1728 if del_code ~= 0 then
1729 io.stderr:write("warning: failed to delete branch: " .. del_output .. "\n")
1730 print("Worktree removed; branch retained")
1731 return
1732 end
1733
1734 print("Worktree and branch '" .. branch .. "' removed")
1735 else
1736 print("Worktree removed")
1737 end
1738end
1739
1740return M
1741]=]
1742
1743_EMBEDDED_MODULES["wt.cmd.list"] = [=[-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
1744--
1745-- SPDX-License-Identifier: GPL-3.0-or-later
1746
1747local exit = require("wt.exit")
1748local shell = require("wt.shell")
1749local git = require("wt.git")
1750local path_mod = require("wt.path")
1751
1752---@class wt.cmd.list
1753local M = {}
1754
1755---List all worktrees with status
1756function M.cmd_list()
1757 local root, err = git.find_project_root()
1758 if not root then
1759 shell.die(err --[[@as string]])
1760 return
1761 end
1762
1763 local git_dir = root .. "/.bare"
1764 local output, code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain")
1765 if code ~= 0 then
1766 shell.die("failed to list worktrees: " .. output, exit.EXIT_SYSTEM_ERROR)
1767 end
1768
1769 ---@type {path: string, head: string, branch: string}[]
1770 local worktrees = {}
1771 local current = {}
1772
1773 for line in output:gmatch("[^\n]+") do
1774 local key, value = line:match("^(%S+)%s*(.*)$")
1775 if key == "worktree" and value then
1776 if current.path then
1777 table.insert(worktrees, current)
1778 end
1779 if value:match("/%.bare$") then
1780 current = {}
1781 else
1782 current = { path = value, head = "", branch = "(detached)" }
1783 end
1784 elseif key == "HEAD" and value then
1785 current.head = value:sub(1, 7)
1786 elseif key == "branch" and value then
1787 current.branch = value:gsub("^refs/heads/", "")
1788 elseif key == "bare" then
1789 current = {}
1790 end
1791 end
1792 if current.path then
1793 table.insert(worktrees, current)
1794 end
1795
1796 if #worktrees == 0 then
1797 print("No worktrees found")
1798 return
1799 end
1800
1801 local cwd = shell.get_cwd() or ""
1802
1803 local rows = {}
1804 for _, wt in ipairs(worktrees) do
1805 local rel_path = path_mod.relative_path(cwd, wt.path)
1806
1807 local status_out = shell.run_cmd("git -C " .. wt.path .. " status --porcelain")
1808 local status = status_out == "" and "clean" or "dirty"
1809
1810 table.insert(rows, rel_path .. "," .. wt.branch .. "," .. wt.head .. "," .. status)
1811 end
1812
1813 local table_input = "Path,Branch,HEAD,Status\n" .. table.concat(rows, "\n")
1814 table_input = table_input:gsub("EOF", "eof")
1815 local table_cmd = "gum table --print <<'EOF'\n" .. table_input .. "\nEOF"
1816 local table_handle = io.popen(table_cmd, "r")
1817 if not table_handle then
1818 return
1819 end
1820 io.write(table_handle:read("*a") or "")
1821 table_handle:close()
1822end
1823
1824return M
1825]=]
1826
1827_EMBEDDED_MODULES["wt.cmd.fetch"] = [=[-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
1828--
1829-- SPDX-License-Identifier: GPL-3.0-or-later
1830
1831local exit = require("wt.exit")
1832local shell = require("wt.shell")
1833local git = require("wt.git")
1834
1835---@class wt.cmd.fetch
1836local M = {}
1837
1838---Get list of configured remotes
1839---@param git_dir string
1840---@return string[] remotes
1841local function get_remotes(git_dir)
1842 local output, code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git remote")
1843 if code ~= 0 then
1844 return {}
1845 end
1846 local remotes = {}
1847 for line in output:gmatch("[^\n]+") do
1848 local trimmed = line:match("^%s*(.-)%s*$")
1849 if trimmed and trimmed ~= "" then
1850 table.insert(remotes, trimmed)
1851 end
1852 end
1853 return remotes
1854end
1855
1856---Fetch all remotes with pruning, tolerating partial failures
1857function M.cmd_fetch()
1858 local root, err = git.find_project_root()
1859 if not root then
1860 shell.die(err --[[@as string]])
1861 return
1862 end
1863
1864 local git_dir = root .. "/.bare"
1865 local remotes = get_remotes(git_dir)
1866
1867 if #remotes == 0 then
1868 shell.die("no remotes configured", exit.EXIT_USER_ERROR)
1869 return
1870 end
1871
1872 local succeeded = 0
1873 local failures = {}
1874
1875 for _, remote in ipairs(remotes) do
1876 local output, code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git fetch --prune " .. shell.quote(remote))
1877 if code == 0 then
1878 succeeded = succeeded + 1
1879 io.write(output)
1880 else
1881 table.insert(failures, { remote = remote, output = output })
1882 end
1883 end
1884
1885 for _, f in ipairs(failures) do
1886 io.stderr:write("warning: failed to fetch " .. f.remote .. "\n")
1887 if f.output and f.output ~= "" then
1888 io.stderr:write(f.output)
1889 end
1890 end
1891
1892 if succeeded == 0 then
1893 io.stderr:write("error: all remotes failed to fetch\n")
1894 os.exit(exit.EXIT_SYSTEM_ERROR)
1895 end
1896end
1897
1898return M
1899]=]
1900
1901_EMBEDDED_MODULES["wt.cmd.init"] = [[-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
1902--
1903-- SPDX-License-Identifier: GPL-3.0-or-later
1904
1905local exit = require("wt.exit")
1906local shell = require("wt.shell")
1907local git = require("wt.git")
1908local path_mod = require("wt.path")
1909local config = require("wt.config")
1910
1911---@class wt.cmd.init
1912local M = {}
1913
1914---List directory entries (excluding . and ..)
1915---@param path string
1916---@return string[]
1917local function list_dir(path)
1918 local entries = {}
1919 local handle = io.popen("ls -A " .. path .. " 2>/dev/null")
1920 if not handle then
1921 return entries
1922 end
1923 for line in handle:lines() do
1924 if line ~= "" then
1925 table.insert(entries, line)
1926 end
1927 end
1928 handle:close()
1929 return entries
1930end
1931
1932---Check if path is a directory
1933---@param path string
1934---@return boolean
1935local function is_dir(path)
1936 local f = io.open(path, "r")
1937 if not f then
1938 return false
1939 end
1940 f:close()
1941 return shell.run_cmd_silent("test -d " .. path)
1942end
1943
1944---Check if path is a file (not directory)
1945---@param path string
1946---@return boolean
1947local function is_file(path)
1948 local f = io.open(path, "r")
1949 if not f then
1950 return false
1951 end
1952 f:close()
1953 return shell.run_cmd_silent("test -f " .. path)
1954end
1955
1956---Convert existing git repository to wt bare structure
1957---@param args string[]
1958function M.cmd_init(args)
1959 -- Parse arguments
1960 local dry_run = false
1961 local skip_confirm = false
1962 for _, a in ipairs(args) do
1963 if a == "--dry-run" then
1964 dry_run = true
1965 elseif a == "-y" or a == "--yes" then
1966 skip_confirm = true
1967 else
1968 shell.die("unexpected argument: " .. a)
1969 end
1970 end
1971
1972 local cwd = shell.get_cwd()
1973 if not cwd then
1974 shell.die("failed to get current directory", exit.EXIT_SYSTEM_ERROR)
1975 return
1976 end
1977
1978 -- Case 1: Already wt-managed (.git file pointing to .bare + .bare/ exists)
1979 local git_path = cwd .. "/.git"
1980 local bare_path = cwd .. "/.bare"
1981
1982 local bare_exists = is_dir(bare_path)
1983 local git_file = io.open(git_path, "r")
1984
1985 if git_file then
1986 local content = git_file:read("*a")
1987 git_file:close()
1988
1989 -- Check if it's a file (not directory) pointing to .bare
1990 if is_file(git_path) and content and content:match("gitdir:%s*%.?/?%.bare") then
1991 if bare_exists then
1992 print("Already using wt bare structure")
1993 os.exit(exit.EXIT_SUCCESS)
1994 end
1995 end
1996
1997 -- Check if .git is a file pointing elsewhere (inside a worktree)
1998 if is_file(git_path) and content and content:match("^gitdir:") then
1999 -- It's a worktree, not project root
2000 shell.die("inside a worktree; run from project root or use 'wt c' to clone fresh")
2001 end
2002 end
2003
2004 -- Check for .git directory
2005 local git_dir_exists = is_dir(git_path)
2006
2007 if not git_dir_exists then
2008 -- Case 5: No .git at all, or bare repo without .git dir
2009 if bare_exists then
2010 shell.die("found .bare/ but no .git; manually create .git file with 'gitdir: ./.bare'")
2011 end
2012 shell.die("not a git repository (no .git found)")
2013 end
2014
2015 -- Now we have a .git directory
2016 -- Case 3: Existing worktree setup (.git/worktrees/ exists)
2017 local worktrees_path = git_path .. "/worktrees"
2018 if is_dir(worktrees_path) then
2019 local worktrees = list_dir(worktrees_path)
2020 io.stderr:write("error: repository already uses git worktrees\n")
2021 io.stderr:write("\n")
2022 io.stderr:write("Converting an existing worktree setup is complex and error-prone.\n")
2023 io.stderr:write("Recommend: clone fresh with 'wt c <url>' and recreate worktrees.\n")
2024 if #worktrees > 0 then
2025 io.stderr:write("\nExisting worktrees:\n")
2026 for _, wt in ipairs(worktrees) do
2027 io.stderr:write(" " .. wt .. "\n")
2028 end
2029 end
2030 os.exit(exit.EXIT_USER_ERROR)
2031 end
2032
2033 -- Case 4: Normal clone (.git/ directory, no worktrees)
2034 -- Check for uncommitted changes
2035 local status_out = shell.run_cmd("git status --porcelain")
2036 if status_out ~= "" then
2037 io.stderr:write("error: uncommitted changes\n")
2038 io.stderr:write("hint: commit, or stash and restore after:\n")
2039 io.stderr:write(" git stash -u && wt init && cd <worktree> && git stash pop\n")
2040 os.exit(exit.EXIT_USER_ERROR)
2041 end
2042
2043 -- Detect default branch
2044 local default_branch = git.detect_cloned_default_branch(git_path)
2045
2046 -- Warnings
2047 local warnings = {}
2048
2049 -- Check for submodules
2050 if is_file(cwd .. "/.gitmodules") then
2051 table.insert(warnings, "submodules detected (.gitmodules exists); verify they work after conversion")
2052 end
2053
2054 -- Check for nested .git directories (excluding the main one)
2055 local nested_git_output, _ = shell.run_cmd("find " .. cwd .. " -mindepth 2 -name .git -type d 2>/dev/null")
2056 if nested_git_output ~= "" then
2057 table.insert(warnings, "nested .git directories found; these may cause issues")
2058 end
2059
2060 -- Find orphaned files (files in root that will be deleted)
2061 local all_entries = list_dir(cwd)
2062 local orphaned = {}
2063 for _, entry in ipairs(all_entries) do
2064 if entry ~= ".git" and entry ~= ".bare" then
2065 table.insert(orphaned, entry)
2066 end
2067 end
2068
2069 -- Load global config for path style
2070 local global_config = config.load_global_config()
2071 local style = global_config.branch_path_style or "nested"
2072 local separator = global_config.flat_separator
2073 local worktree_path = path_mod.branch_to_path(cwd, default_branch, style, separator)
2074
2075 if dry_run then
2076 print("Dry run - planned actions:")
2077 print("")
2078 print("1. Move .git/ to .bare/")
2079 print("2. Create .git file pointing to .bare/")
2080 print("3. Create worktree: " .. worktree_path .. " (" .. default_branch .. ")")
2081 if #orphaned > 0 then
2082 print("4. Remove " .. #orphaned .. " orphaned items from root:")
2083 for _, item in ipairs(orphaned) do
2084 print(" - " .. item)
2085 end
2086 end
2087 if #warnings > 0 then
2088 print("")
2089 print("Warnings:")
2090 for _, w in ipairs(warnings) do
2091 print(" ⚠ " .. w)
2092 end
2093 end
2094 os.exit(exit.EXIT_SUCCESS)
2095 end
2096
2097 -- Show warnings
2098 for _, w in ipairs(warnings) do
2099 io.stderr:write("warning: " .. w .. "\n")
2100 end
2101
2102 -- Prominent warning about file deletion
2103 if #orphaned > 0 then
2104 io.stderr:write("\n")
2105 io.stderr:write("WARNING: The following " .. #orphaned .. " items will be DELETED from project root:\n")
2106 for _, item in ipairs(orphaned) do
2107 io.stderr:write(" - " .. item .. "\n")
2108 end
2109 io.stderr:write("\n")
2110 io.stderr:write("These files are preserved in the new worktree at:\n")
2111 io.stderr:write(" " .. worktree_path .. "\n")
2112 io.stderr:write("\n")
2113 end
2114
2115 -- Confirm with gum (unless -y/--yes)
2116 if not skip_confirm then
2117 local confirm_code = os.execute("gum confirm 'Convert to wt bare structure?'")
2118 if confirm_code ~= true then
2119 print("Aborted")
2120 os.exit(exit.EXIT_USER_ERROR)
2121 end
2122 end
2123
2124 -- Step 1: Move .git to .bare
2125 local output, code = shell.run_cmd("mv " .. git_path .. " " .. bare_path)
2126 if code ~= 0 then
2127 shell.die("failed to move .git to .bare: " .. output, exit.EXIT_SYSTEM_ERROR)
2128 end
2129
2130 -- Step 2: Write .git file
2131 local git_file_handle = io.open(git_path, "w")
2132 if not git_file_handle then
2133 -- Try to recover
2134 shell.run_cmd("mv " .. bare_path .. " " .. git_path)
2135 shell.die("failed to create .git file", exit.EXIT_SYSTEM_ERROR)
2136 return
2137 end
2138 git_file_handle:write("gitdir: ./.bare\n")
2139 git_file_handle:close()
2140
2141 -- Step 3: Detach HEAD so branch can be checked out in worktree
2142 -- Point bare repo's HEAD to a placeholder so main branch can be used by worktree
2143 shell.run_cmd("GIT_DIR=" .. bare_path .. " git symbolic-ref HEAD refs/heads/__wt_detached_placeholder__")
2144
2145 -- Step 4: Create worktree for default branch
2146 output, code =
2147 shell.run_cmd("GIT_DIR=" .. bare_path .. " git worktree add -- " .. worktree_path .. " " .. default_branch)
2148 if code ~= 0 then
2149 shell.die("failed to create worktree: " .. output, exit.EXIT_SYSTEM_ERROR)
2150 end
2151
2152 -- Step 5: Remove orphaned files from root
2153 for _, item in ipairs(orphaned) do
2154 local item_path = cwd .. "/" .. item
2155 output, code = shell.run_cmd("rm -rf " .. item_path)
2156 if code ~= 0 then
2157 io.stderr:write("warning: failed to remove " .. item .. ": " .. output .. "\n")
2158 end
2159 end
2160
2161 -- Summary
2162 print("Converted to wt bare structure")
2163 print("Bare repo: " .. bare_path)
2164 print("Worktree: " .. worktree_path)
2165 if #orphaned > 0 then
2166 print("Removed: " .. #orphaned .. " items from root")
2167 end
2168end
2169
2170return M
2171]]
2172
2173
2174if _VERSION < "Lua 5.2" then
2175 io.stderr:write("error: wt requires Lua 5.2 or later\n")
2176 os.exit(1)
2177end
2178
2179local exit = require("wt.exit")
2180local shell = require("wt.shell")
2181local path_mod = require("wt.path")
2182local git_mod = require("wt.git")
2183local config_mod = require("wt.config")
2184local hooks_mod = require("wt.hooks")
2185local help_mod = require("wt.help")
2186local fetch_mod = require("wt.cmd.fetch")
2187local list_mod = require("wt.cmd.list")
2188local remove_mod = require("wt.cmd.remove")
2189local add_mod = require("wt.cmd.add")
2190local new_mod = require("wt.cmd.new")
2191local clone_mod = require("wt.cmd.clone")
2192local init_mod = require("wt.cmd.init")
2193
2194-- Main entry point
2195
2196local function main()
2197 local command = arg[1]
2198
2199 if not command or command == "help" or command == "--help" or command == "-h" then
2200 help_mod.print_usage()
2201 os.exit(exit.EXIT_SUCCESS)
2202 end
2203
2204 ---@cast command string
2205
2206 -- Collect remaining args
2207 local subargs = {}
2208 for i = 2, #arg do
2209 table.insert(subargs, arg[i])
2210 end
2211
2212 -- Check for --help on any command
2213 if subargs[1] == "--help" or subargs[1] == "-h" then
2214 help_mod.show_command_help(command)
2215 end
2216
2217 if command == "c" then
2218 clone_mod.cmd_clone(subargs)
2219 elseif command == "n" then
2220 new_mod.cmd_new(subargs)
2221 elseif command == "a" then
2222 add_mod.cmd_add(subargs)
2223 elseif command == "r" then
2224 remove_mod.cmd_remove(subargs)
2225 elseif command == "l" then
2226 list_mod.cmd_list()
2227 elseif command == "f" then
2228 fetch_mod.cmd_fetch()
2229 elseif command == "init" then
2230 init_mod.cmd_init(subargs)
2231 else
2232 shell.die("unknown command: " .. command)
2233 end
2234end
2235
2236-- Export for testing when required as module
2237if pcall(debug.getlocal, 4, 1) then
2238 ---@diagnostic disable: duplicate-set-field
2239 return {
2240 -- URL/project parsing
2241 extract_project_name = config_mod.extract_project_name,
2242 resolve_url_template = config_mod.resolve_url_template,
2243 -- Path manipulation
2244 branch_to_path = path_mod.branch_to_path,
2245 split_path = path_mod.split_path,
2246 relative_path = path_mod.relative_path,
2247 path_inside = path_mod.path_inside,
2248 -- Config loading
2249 load_global_config = config_mod.load_global_config,
2250 load_project_config = config_mod.load_project_config,
2251 -- Git output parsing (testable without git)
2252 parse_branch_remotes = git_mod.parse_branch_remotes,
2253 parse_worktree_list = git_mod.parse_worktree_list,
2254 escape_pattern = path_mod.escape_pattern,
2255 -- Hook helpers (re-exported from wt.hooks)
2256 summarize_hooks = hooks_mod.summarize_hooks,
2257 load_hook_permissions = hooks_mod.load_hook_permissions,
2258 save_hook_permissions = hooks_mod.save_hook_permissions,
2259 run_hooks = hooks_mod.run_hooks,
2260 -- Project root detection (re-exported from wt.git)
2261 find_project_root = git_mod.find_project_root,
2262 detect_source_worktree = git_mod.detect_source_worktree,
2263 -- Command execution (for integration tests)
2264 run_cmd = shell.run_cmd,
2265 run_cmd_silent = shell.run_cmd_silent,
2266 -- Exit codes (re-exported from wt.exit)
2267 EXIT_SUCCESS = exit.EXIT_SUCCESS,
2268 EXIT_USER_ERROR = exit.EXIT_USER_ERROR,
2269 EXIT_SYSTEM_ERROR = exit.EXIT_SYSTEM_ERROR,
2270 }
2271end
2272
2273main()