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