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