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