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