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