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
710if _VERSION < "Lua 5.2" then
711 io.stderr:write("error: wt requires Lua 5.2 or later\n")
712 os.exit(1)
713end
714
715local exit = require("wt.exit")
716local EXIT_SUCCESS = exit.EXIT_SUCCESS
717local EXIT_USER_ERROR = exit.EXIT_USER_ERROR
718local EXIT_SYSTEM_ERROR = exit.EXIT_SYSTEM_ERROR
719
720local shell = require("wt.shell")
721local run_cmd = shell.run_cmd
722local run_cmd_silent = shell.run_cmd_silent
723local get_cwd = shell.get_cwd
724local die = shell.die
725
726local path_mod = require("wt.path")
727local branch_to_path = path_mod.branch_to_path
728local split_path = path_mod.split_path
729local relative_path = path_mod.relative_path
730local path_inside = path_mod.path_inside
731local escape_pattern = path_mod.escape_pattern
732
733local git_mod = require("wt.git")
734local find_project_root = git_mod.find_project_root
735local detect_source_worktree = git_mod.detect_source_worktree
736local branch_exists_local = git_mod.branch_exists_local
737local find_branch_remotes = git_mod.find_branch_remotes
738local detect_cloned_default_branch = git_mod.detect_cloned_default_branch
739local get_default_branch = git_mod.get_default_branch
740local parse_branch_remotes = git_mod.parse_branch_remotes
741local parse_worktree_list = git_mod.parse_worktree_list
742local branch_checked_out_at = git_mod.branch_checked_out_at
743
744local config_mod = require("wt.config")
745local resolve_url_template = config_mod.resolve_url_template
746local extract_project_name = config_mod.extract_project_name
747local load_global_config = config_mod.load_global_config
748local load_project_config = config_mod.load_project_config
749
750local hooks_mod = require("wt.hooks")
751local load_hook_permissions = hooks_mod.load_hook_permissions
752local save_hook_permissions = hooks_mod.save_hook_permissions
753local summarize_hooks = hooks_mod.summarize_hooks
754local run_hooks = hooks_mod.run_hooks
755
756---Print usage information
757local function print_usage()
758 print("wt - git worktree manager")
759 print("")
760 print("Usage: wt <command> [options]")
761 print("")
762 print("Commands:")
763 print(" c <url> [--remote name]... [--own] Clone into bare worktree structure")
764 print(" n <project-name> [--remote name]... Initialize fresh project")
765 print(" a <branch> [-b [<start-point>]] Add worktree with optional hooks")
766 print(" r <branch> [-b] [-f] Remove worktree, optionally delete branch")
767 print(" l List worktrees with status")
768 print(" f Fetch all remotes")
769 print(" init [--dry-run] [-y] Convert existing repo to bare structure")
770 print(" help Show this help message")
771end
772
773-- Per-command help text (using table.concat for performance)
774local COMMAND_HELP = {
775 c = table.concat({
776 "wt c <url> [--remote name]... [--own]",
777 "",
778 "Clone a repository into bare worktree structure.",
779 "",
780 "Arguments:",
781 " <url> Git URL to clone",
782 "",
783 "Options:",
784 " --remote <name> Add configured remote from ~/.config/wt/config.lua",
785 " Can be specified multiple times",
786 " --own Treat as your own project: first remote becomes 'origin'",
787 " (default: 'origin' renamed to 'upstream', your remotes added)",
788 "",
789 "Examples:",
790 " wt c https://github.com/user/repo.git",
791 " wt c git@github.com:user/repo.git --remote github --own",
792 }, "\n"),
793
794 n = table.concat({
795 "wt n <project-name> [--remote name]...",
796 "",
797 "Initialize a fresh project with bare worktree structure.",
798 "",
799 "Arguments:",
800 " <project-name> Name of the new project directory",
801 "",
802 "Options:",
803 " --remote <name> Add configured remote from ~/.config/wt/config.lua",
804 " Can be specified multiple times",
805 "",
806 "Examples:",
807 " wt n my-project",
808 " wt n my-project --remote github --remote gitlab",
809 }, "\n"),
810
811 a = table.concat({
812 "wt a <branch> [-b [<start-point>]]",
813 "",
814 "Add a worktree for a branch.",
815 "",
816 "Arguments:",
817 " <branch> Branch name to checkout or create",
818 "",
819 "Options:",
820 " -b Create a new branch",
821 " <start-point> Base commit/branch for new branch (only with -b)",
822 "",
823 "If run from inside an existing worktree, hooks from .wt.lua will be applied.",
824 "",
825 "Examples:",
826 " wt a main # Checkout existing branch",
827 " wt a feature/new -b # Create new branch from HEAD",
828 " wt a feature/new -b main # Create new branch from main",
829 }, "\n"),
830
831 r = table.concat({
832 "wt r <branch> [-b] [-f]",
833 "",
834 "Remove a worktree.",
835 "",
836 "Arguments:",
837 " <branch> Branch name of worktree to remove",
838 "",
839 "Options:",
840 " -b Also delete the branch after removing worktree",
841 " -f Force removal even with uncommitted changes",
842 "",
843 "Examples:",
844 " wt r feature/old # Remove worktree, keep branch",
845 " wt r feature/old -b # Remove worktree and delete branch",
846 " wt r feature/old -f # Force remove with uncommitted changes",
847 }, "\n"),
848
849 l = table.concat({
850 "wt l",
851 "",
852 "List all worktrees with status information.",
853 "",
854 "Displays a table showing:",
855 " - Branch name",
856 " - Relative path from project root",
857 " - Commit status (ahead/behind remote)",
858 " - Working tree status (clean/dirty)",
859 }, "\n"),
860
861 f = table.concat({
862 "wt f",
863 "",
864 "Fetch from all configured remotes.",
865 "",
866 "Runs 'git fetch --all' in the bare repository.",
867 }, "\n"),
868
869 init = table.concat({
870 "wt init [--dry-run] [-y]",
871 "",
872 "Convert an existing git repository to bare worktree structure.",
873 "",
874 "Options:",
875 " --dry-run Show what would be done without making changes",
876 " -y Skip confirmation prompt",
877 "",
878 "This command:",
879 " 1. Moves .git/ to .bare/",
880 " 2. Creates .git file pointing to .bare/",
881 " 3. Creates a worktree for the current branch",
882 " 4. Removes orphaned files from project root",
883 }, "\n"),
884}
885
886---Show help for a specific command
887---@param cmd string
888local function show_command_help(cmd)
889 local help = COMMAND_HELP[cmd]
890 if help then
891 print(help)
892 else
893 print_usage()
894 end
895 os.exit(EXIT_SUCCESS)
896end
897
898---@param args string[]
899local function cmd_clone(args)
900 -- Parse arguments: <url> [--remote name]... [--own]
901 local url = nil
902 ---@type string[]
903 local remote_flags = {}
904 local own = false
905
906 local i = 1
907 while i <= #args do
908 local a = args[i]
909 if a == "--remote" then
910 if not args[i + 1] then
911 die("--remote requires a name")
912 end
913 table.insert(remote_flags, args[i + 1])
914 i = i + 1
915 elseif a == "--own" then
916 own = true
917 elseif not url then
918 url = a
919 else
920 die("unexpected argument: " .. a)
921 end
922 i = i + 1
923 end
924
925 if not url then
926 die("usage: wt c <url> [--remote name]... [--own]")
927 return
928 end
929
930 -- Extract project name from URL
931 local project_name = extract_project_name(url)
932 if not project_name then
933 die("could not extract project name from URL: " .. url)
934 return
935 end
936
937 -- Check if project directory already exists
938 local cwd = get_cwd()
939 if not cwd then
940 die("failed to get current directory", EXIT_SYSTEM_ERROR)
941 end
942 local project_path = cwd .. "/" .. project_name
943 local check = io.open(project_path, "r")
944 if check then
945 check:close()
946 die("directory already exists: " .. project_path)
947 end
948
949 -- Clone bare repo
950 local bare_path = project_path .. "/.bare"
951 local output, code = run_cmd("git clone --bare " .. url .. " " .. bare_path)
952 if code ~= 0 then
953 die("failed to clone: " .. output, EXIT_SYSTEM_ERROR)
954 end
955
956 -- Write .git file pointing to .bare
957 local git_file_handle = io.open(project_path .. "/.git", "w")
958 if not git_file_handle then
959 die("failed to create .git file", EXIT_SYSTEM_ERROR)
960 return
961 end
962 git_file_handle:write("gitdir: ./.bare\n")
963 git_file_handle:close()
964
965 -- Detect default branch
966 local git_dir = bare_path
967 local default_branch = detect_cloned_default_branch(git_dir)
968
969 -- Load global config
970 local global_config = load_global_config()
971
972 -- Determine which remotes to use
973 ---@type string[]
974 local selected_remotes = {}
975
976 if #remote_flags > 0 then
977 selected_remotes = remote_flags
978 elseif global_config.default_remotes then
979 if type(global_config.default_remotes) == "table" then
980 selected_remotes = global_config.default_remotes
981 elseif global_config.default_remotes == "prompt" then
982 if global_config.remotes then
983 local keys = {}
984 for k in pairs(global_config.remotes) do
985 table.insert(keys, k)
986 end
987 table.sort(keys)
988 if #keys > 0 then
989 local input = table.concat(keys, "\n")
990 local choose_type = own and "" or " --no-limit"
991 local cmd = "echo '" .. input .. "' | gum choose" .. choose_type
992 output, code = run_cmd(cmd)
993 if code == 0 and output ~= "" then
994 for line in output:gmatch("[^\n]+") do
995 table.insert(selected_remotes, line)
996 end
997 end
998 end
999 end
1000 end
1001 elseif global_config.remotes then
1002 local keys = {}
1003 for k in pairs(global_config.remotes) do
1004 table.insert(keys, k)
1005 end
1006 table.sort(keys)
1007 if #keys > 0 then
1008 local input = table.concat(keys, "\n")
1009 local choose_type = own and "" or " --no-limit"
1010 local cmd = "echo '" .. input .. "' | gum choose" .. choose_type
1011 output, code = run_cmd(cmd)
1012 if code == 0 and output ~= "" then
1013 for line in output:gmatch("[^\n]+") do
1014 table.insert(selected_remotes, line)
1015 end
1016 end
1017 end
1018 end
1019
1020 -- Track configured remotes for summary
1021 ---@type string[]
1022 local configured_remotes = {}
1023
1024 if own then
1025 -- User's own project: origin is their canonical remote
1026 if #selected_remotes > 0 then
1027 local first_remote = selected_remotes[1]
1028 -- Rename origin to first remote
1029 output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote rename origin " .. first_remote)
1030 if code ~= 0 then
1031 io.stderr:write("warning: failed to rename origin to " .. first_remote .. ": " .. output .. "\n")
1032 else
1033 -- Configure fetch refspec
1034 run_cmd(
1035 "GIT_DIR="
1036 .. git_dir
1037 .. " git config remote."
1038 .. first_remote
1039 .. ".fetch '+refs/heads/*:refs/remotes/"
1040 .. first_remote
1041 .. "/*'"
1042 )
1043 table.insert(configured_remotes, first_remote)
1044 end
1045
1046 -- Add additional remotes and push to them
1047 for j = 2, #selected_remotes do
1048 local remote_name = selected_remotes[j]
1049 local template = global_config.remotes and global_config.remotes[remote_name]
1050 if template then
1051 local remote_url = resolve_url_template(template, project_name)
1052 output, code =
1053 run_cmd("GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. remote_url)
1054 if code ~= 0 then
1055 io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
1056 else
1057 run_cmd(
1058 "GIT_DIR="
1059 .. git_dir
1060 .. " git config remote."
1061 .. remote_name
1062 .. ".fetch '+refs/heads/*:refs/remotes/"
1063 .. remote_name
1064 .. "/*'"
1065 )
1066 -- Push to additional remotes
1067 output, code =
1068 run_cmd("GIT_DIR=" .. git_dir .. " git push " .. remote_name .. " " .. default_branch)
1069 if code ~= 0 then
1070 io.stderr:write("warning: failed to push to " .. remote_name .. ": " .. output .. "\n")
1071 end
1072 table.insert(configured_remotes, remote_name)
1073 end
1074 else
1075 io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
1076 end
1077 end
1078 else
1079 -- No remotes selected, keep origin as-is
1080 run_cmd("GIT_DIR=" .. git_dir .. " git config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'")
1081 table.insert(configured_remotes, "origin")
1082 end
1083 else
1084 -- Contributing to someone else's project
1085 -- Rename origin to upstream
1086 output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote rename origin upstream")
1087 if code ~= 0 then
1088 io.stderr:write("warning: failed to rename origin to upstream: " .. output .. "\n")
1089 else
1090 run_cmd(
1091 "GIT_DIR=" .. git_dir .. " git config remote.upstream.fetch '+refs/heads/*:refs/remotes/upstream/*'"
1092 )
1093 table.insert(configured_remotes, "upstream")
1094 end
1095
1096 -- Add user's remotes and push to each
1097 for _, remote_name in ipairs(selected_remotes) do
1098 local template = global_config.remotes and global_config.remotes[remote_name]
1099 if template then
1100 local remote_url = resolve_url_template(template, project_name)
1101 output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. remote_url)
1102 if code ~= 0 then
1103 io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
1104 else
1105 run_cmd(
1106 "GIT_DIR="
1107 .. git_dir
1108 .. " git config remote."
1109 .. remote_name
1110 .. ".fetch '+refs/heads/*:refs/remotes/"
1111 .. remote_name
1112 .. "/*'"
1113 )
1114 -- Push to this remote
1115 output, code =
1116 run_cmd("GIT_DIR=" .. git_dir .. " git push " .. remote_name .. " " .. default_branch)
1117 if code ~= 0 then
1118 io.stderr:write("warning: failed to push to " .. remote_name .. ": " .. output .. "\n")
1119 end
1120 table.insert(configured_remotes, remote_name)
1121 end
1122 else
1123 io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
1124 end
1125 end
1126 end
1127
1128 -- Fetch all remotes
1129 run_cmd("GIT_DIR=" .. git_dir .. " git fetch --all")
1130
1131 -- Load config for path style
1132 local style = global_config.branch_path_style or "nested"
1133 local separator = global_config.flat_separator
1134 local worktree_path = branch_to_path(project_path, default_branch, style, separator)
1135
1136 -- Create initial worktree
1137 output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -- " .. worktree_path .. " " .. default_branch)
1138 if code ~= 0 then
1139 die("failed to create worktree: " .. output, EXIT_SYSTEM_ERROR)
1140 end
1141
1142 -- Print summary
1143 print("Created project: " .. project_path)
1144 print("Default branch: " .. default_branch)
1145 print("Worktree: " .. worktree_path)
1146 if #configured_remotes > 0 then
1147 print("Remotes: " .. table.concat(configured_remotes, ", "))
1148 end
1149end
1150
1151---@param args string[]
1152local function cmd_new(args)
1153 -- Parse arguments: <project-name> [--remote name]...
1154 local project_name = nil
1155 ---@type string[]
1156 local remote_flags = {}
1157
1158 local i = 1
1159 while i <= #args do
1160 local a = args[i]
1161 if a == "--remote" then
1162 if not args[i + 1] then
1163 die("--remote requires a name")
1164 end
1165 table.insert(remote_flags, args[i + 1])
1166 i = i + 1
1167 elseif not project_name then
1168 project_name = a
1169 else
1170 die("unexpected argument: " .. a)
1171 end
1172 i = i + 1
1173 end
1174
1175 if not project_name then
1176 die("usage: wt n <project-name> [--remote name]...")
1177 return
1178 end
1179
1180 -- Check if project directory already exists
1181 local cwd = get_cwd()
1182 if not cwd then
1183 die("failed to get current directory", EXIT_SYSTEM_ERROR)
1184 end
1185 local project_path = cwd .. "/" .. project_name
1186 local check = io.open(project_path, "r")
1187 if check then
1188 check:close()
1189 die("directory already exists: " .. project_path)
1190 end
1191
1192 -- Load global config
1193 local global_config = load_global_config()
1194
1195 -- Determine which remotes to use
1196 ---@type string[]
1197 local selected_remotes = {}
1198
1199 if #remote_flags > 0 then
1200 -- Use explicitly provided remotes
1201 selected_remotes = remote_flags
1202 elseif global_config.default_remotes then
1203 if type(global_config.default_remotes) == "table" then
1204 selected_remotes = global_config.default_remotes
1205 elseif global_config.default_remotes == "prompt" then
1206 -- Prompt with gum choose
1207 if global_config.remotes then
1208 local keys = {}
1209 for k in pairs(global_config.remotes) do
1210 table.insert(keys, k)
1211 end
1212 table.sort(keys)
1213 if #keys > 0 then
1214 local input = table.concat(keys, "\n")
1215 local cmd = "echo '" .. input .. "' | gum choose --no-limit"
1216 local output, code = run_cmd(cmd)
1217 if code == 0 and output ~= "" then
1218 for line in output:gmatch("[^\n]+") do
1219 table.insert(selected_remotes, line)
1220 end
1221 end
1222 end
1223 end
1224 end
1225 elseif global_config.remotes then
1226 -- No default_remotes configured, prompt if remotes exist
1227 local keys = {}
1228 for k in pairs(global_config.remotes) do
1229 table.insert(keys, k)
1230 end
1231 table.sort(keys)
1232 if #keys > 0 then
1233 local input = table.concat(keys, "\n")
1234 local cmd = "echo '" .. input .. "' | gum choose --no-limit"
1235 local output, code = run_cmd(cmd)
1236 if code == 0 and output ~= "" then
1237 for line in output:gmatch("[^\n]+") do
1238 table.insert(selected_remotes, line)
1239 end
1240 end
1241 end
1242 end
1243
1244 -- Create project structure
1245 local bare_path = project_path .. "/.bare"
1246 local output, code = run_cmd("mkdir -p " .. bare_path)
1247 if code ~= 0 then
1248 die("failed to create directory: " .. output, EXIT_SYSTEM_ERROR)
1249 end
1250
1251 output, code = run_cmd("git init --bare " .. bare_path)
1252 if code ~= 0 then
1253 die("failed to init bare repo: " .. output, EXIT_SYSTEM_ERROR)
1254 end
1255
1256 -- Write .git file pointing to .bare
1257 local git_file_handle = io.open(project_path .. "/.git", "w")
1258 if not git_file_handle then
1259 die("failed to create .git file", EXIT_SYSTEM_ERROR)
1260 return
1261 end
1262 git_file_handle:write("gitdir: ./.bare\n")
1263 git_file_handle:close()
1264
1265 -- Add remotes
1266 local git_dir = bare_path
1267 for _, remote_name in ipairs(selected_remotes) do
1268 local template = global_config.remotes and global_config.remotes[remote_name]
1269 if template then
1270 local url = resolve_url_template(template, project_name)
1271 output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. url)
1272 if code ~= 0 then
1273 io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
1274 else
1275 -- Configure fetch refspec for the remote
1276 run_cmd(
1277 "GIT_DIR="
1278 .. git_dir
1279 .. " git config remote."
1280 .. remote_name
1281 .. ".fetch '+refs/heads/*:refs/remotes/"
1282 .. remote_name
1283 .. "/*'"
1284 )
1285 end
1286 else
1287 io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
1288 end
1289 end
1290
1291 -- Detect default branch
1292 local default_branch = get_default_branch()
1293
1294 -- Load config for path style
1295 local style = global_config.branch_path_style or "nested"
1296 local separator = global_config.flat_separator
1297 local worktree_path = branch_to_path(project_path, default_branch, style, separator)
1298
1299 -- Create orphan worktree
1300 output, code =
1301 run_cmd("GIT_DIR=" .. git_dir .. " git worktree add --orphan -b " .. default_branch .. " -- " .. worktree_path)
1302 if code ~= 0 then
1303 die("failed to create worktree: " .. output, EXIT_SYSTEM_ERROR)
1304 end
1305
1306 -- Print summary
1307 print("Created project: " .. project_path)
1308 print("Default branch: " .. default_branch)
1309 print("Worktree: " .. worktree_path)
1310 if #selected_remotes > 0 then
1311 print("Remotes: " .. table.concat(selected_remotes, ", "))
1312 end
1313end
1314
1315---@param args string[]
1316local function cmd_add(args)
1317 -- Parse arguments: <branch> [-b [<start-point>]]
1318 ---@type string|nil
1319 local branch = nil
1320 local create_branch = false
1321 ---@type string|nil
1322 local start_point = nil
1323
1324 local i = 1
1325 while i <= #args do
1326 local a = args[i]
1327 if a == "-b" then
1328 create_branch = true
1329 -- Check if next arg is start-point (not another flag)
1330 if args[i + 1] and not args[i + 1]:match("^%-") then
1331 start_point = args[i + 1]
1332 i = i + 1
1333 end
1334 elseif not branch then
1335 branch = a
1336 else
1337 die("unexpected argument: " .. a)
1338 end
1339 i = i + 1
1340 end
1341
1342 if not branch then
1343 die("usage: wt a <branch> [-b [<start-point>]]")
1344 return
1345 end
1346
1347 local root, err = find_project_root()
1348 if not root then
1349 die(err --[[@as string]])
1350 return
1351 end
1352
1353 local git_dir = root .. "/.bare"
1354 local source_worktree = detect_source_worktree(root)
1355
1356 -- Load config for path style
1357 local global_config = load_global_config()
1358 local style = global_config.branch_path_style or "nested"
1359 local separator = global_config.flat_separator or "_"
1360
1361 local target_path = branch_to_path(root, branch, style, separator)
1362
1363 -- Check if target already exists
1364 local check = io.open(target_path .. "/.git", "r")
1365 if check then
1366 check:close()
1367 die("worktree already exists at " .. target_path)
1368 end
1369
1370 local output, code
1371 if create_branch then
1372 -- Create new branch with worktree
1373 if start_point then
1374 output, code = run_cmd(
1375 "GIT_DIR="
1376 .. git_dir
1377 .. " git worktree add -b "
1378 .. branch
1379 .. " -- "
1380 .. target_path
1381 .. " "
1382 .. start_point
1383 )
1384 else
1385 output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -b " .. branch .. " -- " .. target_path)
1386 end
1387 else
1388 -- Check if branch exists locally or on remotes
1389 local exists_local = branch_exists_local(git_dir, branch)
1390 local remotes = find_branch_remotes(git_dir, branch)
1391
1392 if not exists_local and #remotes == 0 then
1393 die("branch '" .. branch .. "' not found locally or on any remote (use -b to create)")
1394 end
1395
1396 if #remotes > 1 then
1397 die("branch '" .. branch .. "' exists on multiple remotes: " .. table.concat(remotes, ", "))
1398 end
1399
1400 output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -- " .. target_path .. " " .. branch)
1401 end
1402
1403 if code ~= 0 then
1404 die("failed to add worktree: " .. output, EXIT_SYSTEM_ERROR)
1405 end
1406
1407 -- Run hooks if we have a source worktree
1408 local project_config = load_project_config(root)
1409 if source_worktree then
1410 if project_config.hooks then
1411 run_hooks(source_worktree, target_path, project_config.hooks, root)
1412 end
1413 elseif project_config.hooks then
1414 io.stderr:write("warning: hooks skipped (run from inside a worktree to apply hooks)\n")
1415 end
1416
1417 print(target_path)
1418end
1419
1420---Check if cwd is inside (or equal to) a given path
1421---@param target string
1422---@return boolean
1423local function cwd_inside_path(target)
1424 local cwd = get_cwd()
1425 if not cwd then
1426 return false
1427 end
1428 return path_inside(cwd, target)
1429end
1430
1431---Get the bare repo's HEAD branch
1432---@param git_dir string
1433---@return string|nil branch name, nil on error
1434local function get_bare_head(git_dir)
1435 local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git symbolic-ref --short HEAD")
1436 if code ~= 0 then
1437 return nil
1438 end
1439 return (output:gsub("%s+$", ""))
1440end
1441
1442---@param args string[]
1443local function cmd_remove(args)
1444 -- Parse arguments: <branch> [-b] [-f]
1445 local branch = nil
1446 local delete_branch = false
1447 local force = false
1448
1449 for _, a in ipairs(args) do
1450 if a == "-b" then
1451 delete_branch = true
1452 elseif a == "-f" then
1453 force = true
1454 elseif not branch then
1455 branch = a
1456 else
1457 die("unexpected argument: " .. a)
1458 end
1459 end
1460
1461 if not branch then
1462 die("usage: wt r <branch> [-b] [-f]")
1463 return
1464 end
1465
1466 local root, err = find_project_root()
1467 if not root then
1468 die(err --[[@as string]])
1469 return
1470 end
1471
1472 local git_dir = root .. "/.bare"
1473
1474 -- Find worktree by querying git for actual location (not computed from config)
1475 local wt_output, wt_code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain")
1476 if wt_code ~= 0 then
1477 die("failed to list worktrees", EXIT_SYSTEM_ERROR)
1478 return
1479 end
1480
1481 local worktrees = parse_worktree_list(wt_output)
1482 local target_path = nil
1483 for _, wt in ipairs(worktrees) do
1484 if wt.branch == branch then
1485 target_path = wt.path
1486 break
1487 end
1488 end
1489
1490 if not target_path then
1491 die("no worktree found for branch '" .. branch .. "'")
1492 return
1493 end
1494
1495 -- Error if cwd is inside the worktree
1496 if cwd_inside_path(target_path) then
1497 die("cannot remove worktree while inside it")
1498 end
1499
1500 -- Check for uncommitted changes
1501 if not force then
1502 local status_out = run_cmd("git -C " .. target_path .. " status --porcelain")
1503 if status_out ~= "" then
1504 die("worktree has uncommitted changes (use -f to force)")
1505 end
1506 end
1507
1508 -- Remove worktree
1509 local remove_cmd = "GIT_DIR=" .. git_dir .. " git worktree remove"
1510 if force then
1511 remove_cmd = remove_cmd .. " --force"
1512 end
1513 remove_cmd = remove_cmd .. " -- " .. target_path
1514
1515 local output, code = run_cmd(remove_cmd)
1516 if code ~= 0 then
1517 die("failed to remove worktree: " .. output, EXIT_SYSTEM_ERROR)
1518 end
1519
1520 -- Delete branch if requested
1521 if delete_branch then
1522 -- Check if branch is bare repo's HEAD
1523 local bare_head = get_bare_head(git_dir)
1524 if bare_head and bare_head == branch then
1525 io.stderr:write("warning: cannot delete branch '" .. branch .. "' (it's the bare repo's HEAD)\n")
1526 print("Worktree removed; branch retained")
1527 return
1528 end
1529
1530 -- Check if branch is checked out elsewhere
1531 local checked_out = branch_checked_out_at(git_dir, branch)
1532 if checked_out then
1533 die("cannot delete branch '" .. branch .. "': checked out at " .. checked_out)
1534 end
1535
1536 -- Delete branch
1537 local delete_flag = force and "-D" or "-d"
1538 local del_output, del_code = run_cmd("GIT_DIR=" .. git_dir .. " git branch " .. delete_flag .. " " .. branch)
1539 if del_code ~= 0 then
1540 io.stderr:write("warning: failed to delete branch: " .. del_output .. "\n")
1541 print("Worktree removed; branch retained")
1542 return
1543 end
1544
1545 print("Worktree and branch '" .. branch .. "' removed")
1546 else
1547 print("Worktree removed")
1548 end
1549end
1550
1551local function cmd_list()
1552 local root, err = find_project_root()
1553 if not root then
1554 die(err --[[@as string]])
1555 return
1556 end
1557
1558 local git_dir = root .. "/.bare"
1559 local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain")
1560 if code ~= 0 then
1561 die("failed to list worktrees: " .. output, EXIT_SYSTEM_ERROR)
1562 end
1563
1564 -- Parse porcelain output into worktree entries
1565 ---@type {path: string, head: string, branch: string}[]
1566 local worktrees = {}
1567 local current = {}
1568
1569 for line in output:gmatch("[^\n]+") do
1570 local key, value = line:match("^(%S+)%s*(.*)$")
1571 if key == "worktree" and value then
1572 if current.path then
1573 table.insert(worktrees, current)
1574 end
1575 -- Skip .bare directory
1576 if value:match("/%.bare$") then
1577 current = {}
1578 else
1579 current = { path = value, head = "", branch = "(detached)" }
1580 end
1581 elseif key == "HEAD" and value then
1582 current.head = value:sub(1, 7)
1583 elseif key == "branch" and value then
1584 current.branch = value:gsub("^refs/heads/", "")
1585 elseif key == "bare" then
1586 -- Skip bare repo entry
1587 current = {}
1588 end
1589 end
1590 if current.path then
1591 table.insert(worktrees, current)
1592 end
1593
1594 if #worktrees == 0 then
1595 print("No worktrees found")
1596 return
1597 end
1598
1599 -- Get current working directory
1600 local cwd = get_cwd() or ""
1601
1602 -- Build table rows with status
1603 local rows = {}
1604 for _, wt in ipairs(worktrees) do
1605 local rel_path = relative_path(cwd, wt.path)
1606
1607 -- Check dirty status
1608 local status_out = run_cmd("git -C " .. wt.path .. " status --porcelain")
1609 local status = status_out == "" and "clean" or "dirty"
1610
1611 table.insert(rows, rel_path .. "," .. wt.branch .. "," .. wt.head .. "," .. status)
1612 end
1613
1614 -- Output via gum table
1615 local table_input = "Path,Branch,HEAD,Status\n" .. table.concat(rows, "\n")
1616 table_input = table_input:gsub("EOF", "eof")
1617 local table_cmd = "gum table --print <<'EOF'\n" .. table_input .. "\nEOF"
1618 local table_handle = io.popen(table_cmd, "r")
1619 if not table_handle then
1620 return
1621 end
1622 io.write(table_handle:read("*a") or "")
1623 table_handle:close()
1624end
1625
1626local function cmd_fetch()
1627 local root, err = find_project_root()
1628 if not root then
1629 die(err --[[@as string]])
1630 return
1631 end
1632
1633 local git_dir = root .. "/.bare"
1634 local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git fetch --all --prune")
1635 io.write(output)
1636 if code ~= 0 then
1637 os.exit(EXIT_SYSTEM_ERROR)
1638 end
1639end
1640
1641---List directory entries (excluding . and ..)
1642---@param path string
1643---@return string[]
1644local function list_dir(path)
1645 local entries = {}
1646 local handle = io.popen("ls -A " .. path .. " 2>/dev/null")
1647 if not handle then
1648 return entries
1649 end
1650 for line in handle:lines() do
1651 if line ~= "" then
1652 table.insert(entries, line)
1653 end
1654 end
1655 handle:close()
1656 return entries
1657end
1658
1659---Check if path is a directory
1660---@param path string
1661---@return boolean
1662local function is_dir(path)
1663 local f = io.open(path, "r")
1664 if not f then
1665 return false
1666 end
1667 f:close()
1668 return run_cmd_silent("test -d " .. path)
1669end
1670
1671---Check if path is a file (not directory)
1672---@param path string
1673---@return boolean
1674local function is_file(path)
1675 local f = io.open(path, "r")
1676 if not f then
1677 return false
1678 end
1679 f:close()
1680 return run_cmd_silent("test -f " .. path)
1681end
1682
1683---@param args string[]
1684local function cmd_init(args)
1685 -- Parse arguments
1686 local dry_run = false
1687 local skip_confirm = false
1688 for _, a in ipairs(args) do
1689 if a == "--dry-run" then
1690 dry_run = true
1691 elseif a == "-y" or a == "--yes" then
1692 skip_confirm = true
1693 else
1694 die("unexpected argument: " .. a)
1695 end
1696 end
1697
1698 local cwd = get_cwd()
1699 if not cwd then
1700 die("failed to get current directory", EXIT_SYSTEM_ERROR)
1701 return
1702 end
1703
1704 -- Case 1: Already wt-managed (.git file pointing to .bare + .bare/ exists)
1705 local git_path = cwd .. "/.git"
1706 local bare_path = cwd .. "/.bare"
1707
1708 local bare_exists = is_dir(bare_path)
1709 local git_file = io.open(git_path, "r")
1710
1711 if git_file then
1712 local content = git_file:read("*a")
1713 git_file:close()
1714
1715 -- Check if it's a file (not directory) pointing to .bare
1716 if is_file(git_path) and content and content:match("gitdir:%s*%.?/?%.bare") then
1717 if bare_exists then
1718 print("Already using wt bare structure")
1719 os.exit(EXIT_SUCCESS)
1720 end
1721 end
1722
1723 -- Check if .git is a file pointing elsewhere (inside a worktree)
1724 if is_file(git_path) and content and content:match("^gitdir:") then
1725 -- It's a worktree, not project root
1726 die("inside a worktree; run from project root or use 'wt c' to clone fresh")
1727 end
1728 end
1729
1730 -- Check for .git directory
1731 local git_dir_exists = is_dir(git_path)
1732
1733 if not git_dir_exists then
1734 -- Case 5: No .git at all, or bare repo without .git dir
1735 if bare_exists then
1736 die("found .bare/ but no .git; manually create .git file with 'gitdir: ./.bare'")
1737 end
1738 die("not a git repository (no .git found)")
1739 end
1740
1741 -- Now we have a .git directory
1742 -- Case 3: Existing worktree setup (.git/worktrees/ exists)
1743 local worktrees_path = git_path .. "/worktrees"
1744 if is_dir(worktrees_path) then
1745 local worktrees = list_dir(worktrees_path)
1746 io.stderr:write("error: repository already uses git worktrees\n")
1747 io.stderr:write("\n")
1748 io.stderr:write("Converting an existing worktree setup is complex and error-prone.\n")
1749 io.stderr:write("Recommend: clone fresh with 'wt c <url>' and recreate worktrees.\n")
1750 if #worktrees > 0 then
1751 io.stderr:write("\nExisting worktrees:\n")
1752 for _, wt in ipairs(worktrees) do
1753 io.stderr:write(" " .. wt .. "\n")
1754 end
1755 end
1756 os.exit(EXIT_USER_ERROR)
1757 end
1758
1759 -- Case 4: Normal clone (.git/ directory, no worktrees)
1760 -- Check for uncommitted changes
1761 local status_out = run_cmd("git status --porcelain")
1762 if status_out ~= "" then
1763 die("uncommitted changes; commit or stash before converting")
1764 end
1765
1766 -- Detect default branch
1767 local default_branch = detect_cloned_default_branch(git_path)
1768
1769 -- Warnings
1770 local warnings = {}
1771
1772 -- Check for submodules
1773 if is_file(cwd .. "/.gitmodules") then
1774 table.insert(warnings, "submodules detected (.gitmodules exists); verify they work after conversion")
1775 end
1776
1777 -- Check for nested .git directories (excluding the main one)
1778 local nested_git_output, _ = run_cmd("find " .. cwd .. " -mindepth 2 -name .git -type d 2>/dev/null")
1779 if nested_git_output ~= "" then
1780 table.insert(warnings, "nested .git directories found; these may cause issues")
1781 end
1782
1783 -- Find orphaned files (files in root that will be deleted)
1784 local all_entries = list_dir(cwd)
1785 local orphaned = {}
1786 for _, entry in ipairs(all_entries) do
1787 if entry ~= ".git" and entry ~= ".bare" then
1788 table.insert(orphaned, entry)
1789 end
1790 end
1791
1792 -- Load global config for path style
1793 local global_config = load_global_config()
1794 local style = global_config.branch_path_style or "nested"
1795 local separator = global_config.flat_separator
1796 local worktree_path = branch_to_path(cwd, default_branch, style, separator)
1797
1798 if dry_run then
1799 print("Dry run - planned actions:")
1800 print("")
1801 print("1. Move .git/ to .bare/")
1802 print("2. Create .git file pointing to .bare/")
1803 print("3. Create worktree: " .. worktree_path .. " (" .. default_branch .. ")")
1804 if #orphaned > 0 then
1805 print("4. Remove " .. #orphaned .. " orphaned items from root:")
1806 for _, item in ipairs(orphaned) do
1807 print(" - " .. item)
1808 end
1809 end
1810 if #warnings > 0 then
1811 print("")
1812 print("Warnings:")
1813 for _, w in ipairs(warnings) do
1814 print(" ⚠ " .. w)
1815 end
1816 end
1817 os.exit(EXIT_SUCCESS)
1818 end
1819
1820 -- Show warnings
1821 for _, w in ipairs(warnings) do
1822 io.stderr:write("warning: " .. w .. "\n")
1823 end
1824
1825 -- Confirm with gum (unless -y/--yes)
1826 if not skip_confirm then
1827 local confirm_msg = "Convert to wt bare structure? This will move .git to .bare"
1828 if #orphaned > 0 then
1829 confirm_msg = confirm_msg .. " and remove " .. #orphaned .. " items from root"
1830 end
1831
1832 local confirm_code = os.execute("gum confirm '" .. confirm_msg .. "'")
1833 if confirm_code ~= true then
1834 print("Aborted")
1835 os.exit(EXIT_USER_ERROR)
1836 end
1837 end
1838
1839 -- Step 1: Move .git to .bare
1840 local output, code = run_cmd("mv " .. git_path .. " " .. bare_path)
1841 if code ~= 0 then
1842 die("failed to move .git to .bare: " .. output, EXIT_SYSTEM_ERROR)
1843 end
1844
1845 -- Step 2: Write .git file
1846 local git_file_handle = io.open(git_path, "w")
1847 if not git_file_handle then
1848 -- Try to recover
1849 run_cmd("mv " .. bare_path .. " " .. git_path)
1850 die("failed to create .git file", EXIT_SYSTEM_ERROR)
1851 return
1852 end
1853 git_file_handle:write("gitdir: ./.bare\n")
1854 git_file_handle:close()
1855
1856 -- Step 3: Detach HEAD so branch can be checked out in worktree
1857 -- Point bare repo's HEAD to a placeholder so main branch can be used by worktree
1858 run_cmd("GIT_DIR=" .. bare_path .. " git symbolic-ref HEAD refs/heads/__wt_detached_placeholder__")
1859
1860 -- Step 4: Create worktree for default branch
1861 output, code = run_cmd("GIT_DIR=" .. bare_path .. " git worktree add -- " .. worktree_path .. " " .. default_branch)
1862 if code ~= 0 then
1863 die("failed to create worktree: " .. output, EXIT_SYSTEM_ERROR)
1864 end
1865
1866 -- Step 5: Remove orphaned files from root
1867 for _, item in ipairs(orphaned) do
1868 local item_path = cwd .. "/" .. item
1869 output, code = run_cmd("rm -rf " .. item_path)
1870 if code ~= 0 then
1871 io.stderr:write("warning: failed to remove " .. item .. ": " .. output .. "\n")
1872 end
1873 end
1874
1875 -- Summary
1876 print("Converted to wt bare structure")
1877 print("Bare repo: " .. bare_path)
1878 print("Worktree: " .. worktree_path)
1879 if #orphaned > 0 then
1880 print("Removed: " .. #orphaned .. " items from root")
1881 end
1882end
1883
1884-- Main entry point
1885
1886local function main()
1887 local command = arg[1]
1888
1889 if not command or command == "help" or command == "--help" or command == "-h" then
1890 print_usage()
1891 os.exit(EXIT_SUCCESS)
1892 end
1893
1894 ---@cast command string
1895
1896 -- Collect remaining args
1897 local subargs = {}
1898 for i = 2, #arg do
1899 table.insert(subargs, arg[i])
1900 end
1901
1902 -- Check for --help on any command
1903 if subargs[1] == "--help" or subargs[1] == "-h" then
1904 show_command_help(command)
1905 end
1906
1907 if command == "c" then
1908 cmd_clone(subargs)
1909 elseif command == "n" then
1910 cmd_new(subargs)
1911 elseif command == "a" then
1912 cmd_add(subargs)
1913 elseif command == "r" then
1914 cmd_remove(subargs)
1915 elseif command == "l" then
1916 cmd_list()
1917 elseif command == "f" then
1918 cmd_fetch()
1919 elseif command == "init" then
1920 cmd_init(subargs)
1921 else
1922 die("unknown command: " .. command)
1923 end
1924end
1925
1926-- Export for testing when required as module
1927if pcall(debug.getlocal, 4, 1) then
1928 return {
1929 -- URL/project parsing
1930 extract_project_name = extract_project_name,
1931 resolve_url_template = resolve_url_template,
1932 -- Path manipulation
1933 branch_to_path = branch_to_path,
1934 split_path = split_path,
1935 relative_path = relative_path,
1936 path_inside = path_inside,
1937 -- Config loading
1938 load_global_config = load_global_config,
1939 load_project_config = load_project_config,
1940 -- Git output parsing (testable without git)
1941 parse_branch_remotes = parse_branch_remotes,
1942 parse_worktree_list = parse_worktree_list,
1943 escape_pattern = escape_pattern,
1944 -- Hook helpers (re-exported from wt.hooks)
1945 summarize_hooks = summarize_hooks,
1946 load_hook_permissions = load_hook_permissions,
1947 save_hook_permissions = save_hook_permissions,
1948 run_hooks = run_hooks,
1949 -- Project root detection (re-exported from wt.git)
1950 find_project_root = find_project_root,
1951 detect_source_worktree = detect_source_worktree,
1952 -- Command execution (for integration tests)
1953 run_cmd = run_cmd,
1954 run_cmd_silent = run_cmd_silent,
1955 -- Exit codes (re-exported from wt.exit)
1956 EXIT_SUCCESS = exit.EXIT_SUCCESS,
1957 EXIT_USER_ERROR = exit.EXIT_USER_ERROR,
1958 EXIT_SYSTEM_ERROR = exit.EXIT_SYSTEM_ERROR,
1959 }
1960end
1961
1962main()