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
12local exit = require("wt.exit")
13local EXIT_SUCCESS = exit.EXIT_SUCCESS
14local EXIT_USER_ERROR = exit.EXIT_USER_ERROR
15local EXIT_SYSTEM_ERROR = exit.EXIT_SYSTEM_ERROR
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 ---@type {path: string, branch?: string, bare?: boolean, detached?: boolean, head?: string}|nil
1273 local current = nil
1274 for line in output:gmatch("[^\n]+") do
1275 local key, value = line:match("^(%S+)%s*(.*)$")
1276 if key == "worktree" and value then
1277 if current then
1278 table.insert(worktrees, current)
1279 end
1280 current = { path = value }
1281 elseif current then
1282 if key == "branch" and value then
1283 current.branch = value:gsub("^refs/heads/", "")
1284 elseif key == "bare" then
1285 current.bare = true
1286 elseif key == "detached" then
1287 current.detached = true
1288 elseif key == "HEAD" then
1289 current.head = value
1290 end
1291 end
1292 end
1293 if current then
1294 table.insert(worktrees, current)
1295 end
1296 return worktrees
1297end
1298
1299---Check if branch is checked out in any worktree
1300---@param git_dir string
1301---@param branch string
1302---@return string|nil path if checked out, nil otherwise
1303local function branch_checked_out_at(git_dir, branch)
1304 local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain")
1305 if code ~= 0 then
1306 return nil
1307 end
1308 local worktrees = parse_worktree_list(output)
1309 for _, wt in ipairs(worktrees) do
1310 if wt.branch == branch then
1311 return wt.path
1312 end
1313 end
1314 return nil
1315end
1316
1317---@param args string[]
1318local function cmd_remove(args)
1319 -- Parse arguments: <branch> [-b] [-f]
1320 local branch = nil
1321 local delete_branch = false
1322 local force = false
1323
1324 for _, a in ipairs(args) do
1325 if a == "-b" then
1326 delete_branch = true
1327 elseif a == "-f" then
1328 force = true
1329 elseif not branch then
1330 branch = a
1331 else
1332 die("unexpected argument: " .. a)
1333 end
1334 end
1335
1336 if not branch then
1337 die("usage: wt r <branch> [-b] [-f]")
1338 return
1339 end
1340
1341 local root, err = find_project_root()
1342 if not root then
1343 die(err --[[@as string]])
1344 return
1345 end
1346
1347 local git_dir = root .. "/.bare"
1348
1349 -- Find worktree by querying git for actual location (not computed from config)
1350 local wt_output, wt_code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain")
1351 if wt_code ~= 0 then
1352 die("failed to list worktrees", EXIT_SYSTEM_ERROR)
1353 return
1354 end
1355
1356 local worktrees = parse_worktree_list(wt_output)
1357 local target_path = nil
1358 for _, wt in ipairs(worktrees) do
1359 if wt.branch == branch then
1360 target_path = wt.path
1361 break
1362 end
1363 end
1364
1365 if not target_path then
1366 die("no worktree found for branch '" .. branch .. "'")
1367 return
1368 end
1369
1370 -- Error if cwd is inside the worktree
1371 if cwd_inside_path(target_path) then
1372 die("cannot remove worktree while inside it")
1373 end
1374
1375 -- Check for uncommitted changes
1376 if not force then
1377 local status_out = run_cmd("git -C " .. target_path .. " status --porcelain")
1378 if status_out ~= "" then
1379 die("worktree has uncommitted changes (use -f to force)")
1380 end
1381 end
1382
1383 -- Remove worktree
1384 local remove_cmd = "GIT_DIR=" .. git_dir .. " git worktree remove"
1385 if force then
1386 remove_cmd = remove_cmd .. " --force"
1387 end
1388 remove_cmd = remove_cmd .. " -- " .. target_path
1389
1390 local output, code = run_cmd(remove_cmd)
1391 if code ~= 0 then
1392 die("failed to remove worktree: " .. output, EXIT_SYSTEM_ERROR)
1393 end
1394
1395 -- Delete branch if requested
1396 if delete_branch then
1397 -- Check if branch is bare repo's HEAD
1398 local bare_head = get_bare_head(git_dir)
1399 if bare_head and bare_head == branch then
1400 io.stderr:write("warning: cannot delete branch '" .. branch .. "' (it's the bare repo's HEAD)\n")
1401 print("Worktree removed; branch retained")
1402 return
1403 end
1404
1405 -- Check if branch is checked out elsewhere
1406 local checked_out = branch_checked_out_at(git_dir, branch)
1407 if checked_out then
1408 die("cannot delete branch '" .. branch .. "': checked out at " .. checked_out)
1409 end
1410
1411 -- Delete branch
1412 local delete_flag = force and "-D" or "-d"
1413 local del_output, del_code = run_cmd("GIT_DIR=" .. git_dir .. " git branch " .. delete_flag .. " " .. branch)
1414 if del_code ~= 0 then
1415 io.stderr:write("warning: failed to delete branch: " .. del_output .. "\n")
1416 print("Worktree removed; branch retained")
1417 return
1418 end
1419
1420 print("Worktree and branch '" .. branch .. "' removed")
1421 else
1422 print("Worktree removed")
1423 end
1424end
1425
1426local function cmd_list()
1427 local root, err = find_project_root()
1428 if not root then
1429 die(err --[[@as string]])
1430 return
1431 end
1432
1433 local git_dir = root .. "/.bare"
1434 local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain")
1435 if code ~= 0 then
1436 die("failed to list worktrees: " .. output, EXIT_SYSTEM_ERROR)
1437 end
1438
1439 -- Parse porcelain output into worktree entries
1440 ---@type {path: string, head: string, branch: string}[]
1441 local worktrees = {}
1442 local current = {}
1443
1444 for line in output:gmatch("[^\n]+") do
1445 local key, value = line:match("^(%S+)%s*(.*)$")
1446 if key == "worktree" and value then
1447 if current.path then
1448 table.insert(worktrees, current)
1449 end
1450 -- Skip .bare directory
1451 if value:match("/%.bare$") then
1452 current = {}
1453 else
1454 current = { path = value, head = "", branch = "(detached)" }
1455 end
1456 elseif key == "HEAD" and value then
1457 current.head = value:sub(1, 7)
1458 elseif key == "branch" and value then
1459 current.branch = value:gsub("^refs/heads/", "")
1460 elseif key == "bare" then
1461 -- Skip bare repo entry
1462 current = {}
1463 end
1464 end
1465 if current.path then
1466 table.insert(worktrees, current)
1467 end
1468
1469 if #worktrees == 0 then
1470 print("No worktrees found")
1471 return
1472 end
1473
1474 -- Get current working directory
1475 local cwd = get_cwd() or ""
1476
1477 -- Build table rows with status
1478 local rows = {}
1479 for _, wt in ipairs(worktrees) do
1480 local rel_path = relative_path(cwd, wt.path)
1481
1482 -- Check dirty status
1483 local status_out = run_cmd("git -C " .. wt.path .. " status --porcelain")
1484 local status = status_out == "" and "clean" or "dirty"
1485
1486 table.insert(rows, rel_path .. "," .. wt.branch .. "," .. wt.head .. "," .. status)
1487 end
1488
1489 -- Output via gum table
1490 local table_input = "Path,Branch,HEAD,Status\n" .. table.concat(rows, "\n")
1491 table_input = table_input:gsub("EOF", "eof")
1492 local table_cmd = "gum table --print <<'EOF'\n" .. table_input .. "\nEOF"
1493 local table_handle = io.popen(table_cmd, "r")
1494 if not table_handle then
1495 return
1496 end
1497 io.write(table_handle:read("*a") or "")
1498 table_handle:close()
1499end
1500
1501local function cmd_fetch()
1502 local root, err = find_project_root()
1503 if not root then
1504 die(err --[[@as string]])
1505 return
1506 end
1507
1508 local git_dir = root .. "/.bare"
1509 local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git fetch --all --prune")
1510 io.write(output)
1511 if code ~= 0 then
1512 os.exit(EXIT_SYSTEM_ERROR)
1513 end
1514end
1515
1516---List directory entries (excluding . and ..)
1517---@param path string
1518---@return string[]
1519local function list_dir(path)
1520 local entries = {}
1521 local handle = io.popen("ls -A " .. path .. " 2>/dev/null")
1522 if not handle then
1523 return entries
1524 end
1525 for line in handle:lines() do
1526 if line ~= "" then
1527 table.insert(entries, line)
1528 end
1529 end
1530 handle:close()
1531 return entries
1532end
1533
1534---Check if path is a directory
1535---@param path string
1536---@return boolean
1537local function is_dir(path)
1538 local f = io.open(path, "r")
1539 if not f then
1540 return false
1541 end
1542 f:close()
1543 return run_cmd_silent("test -d " .. path)
1544end
1545
1546---Check if path is a file (not directory)
1547---@param path string
1548---@return boolean
1549local function is_file(path)
1550 local f = io.open(path, "r")
1551 if not f then
1552 return false
1553 end
1554 f:close()
1555 return run_cmd_silent("test -f " .. path)
1556end
1557
1558---@param args string[]
1559local function cmd_init(args)
1560 -- Parse arguments
1561 local dry_run = false
1562 local skip_confirm = false
1563 for _, a in ipairs(args) do
1564 if a == "--dry-run" then
1565 dry_run = true
1566 elseif a == "-y" or a == "--yes" then
1567 skip_confirm = true
1568 else
1569 die("unexpected argument: " .. a)
1570 end
1571 end
1572
1573 local cwd = get_cwd()
1574 if not cwd then
1575 die("failed to get current directory", EXIT_SYSTEM_ERROR)
1576 return
1577 end
1578
1579 -- Case 1: Already wt-managed (.git file pointing to .bare + .bare/ exists)
1580 local git_path = cwd .. "/.git"
1581 local bare_path = cwd .. "/.bare"
1582
1583 local bare_exists = is_dir(bare_path)
1584 local git_file = io.open(git_path, "r")
1585
1586 if git_file then
1587 local content = git_file:read("*a")
1588 git_file:close()
1589
1590 -- Check if it's a file (not directory) pointing to .bare
1591 if is_file(git_path) and content and content:match("gitdir:%s*%.?/?%.bare") then
1592 if bare_exists then
1593 print("Already using wt bare structure")
1594 os.exit(EXIT_SUCCESS)
1595 end
1596 end
1597
1598 -- Check if .git is a file pointing elsewhere (inside a worktree)
1599 if is_file(git_path) and content and content:match("^gitdir:") then
1600 -- It's a worktree, not project root
1601 die("inside a worktree; run from project root or use 'wt c' to clone fresh")
1602 end
1603 end
1604
1605 -- Check for .git directory
1606 local git_dir_exists = is_dir(git_path)
1607
1608 if not git_dir_exists then
1609 -- Case 5: No .git at all, or bare repo without .git dir
1610 if bare_exists then
1611 die("found .bare/ but no .git; manually create .git file with 'gitdir: ./.bare'")
1612 end
1613 die("not a git repository (no .git found)")
1614 end
1615
1616 -- Now we have a .git directory
1617 -- Case 3: Existing worktree setup (.git/worktrees/ exists)
1618 local worktrees_path = git_path .. "/worktrees"
1619 if is_dir(worktrees_path) then
1620 local worktrees = list_dir(worktrees_path)
1621 io.stderr:write("error: repository already uses git worktrees\n")
1622 io.stderr:write("\n")
1623 io.stderr:write("Converting an existing worktree setup is complex and error-prone.\n")
1624 io.stderr:write("Recommend: clone fresh with 'wt c <url>' and recreate worktrees.\n")
1625 if #worktrees > 0 then
1626 io.stderr:write("\nExisting worktrees:\n")
1627 for _, wt in ipairs(worktrees) do
1628 io.stderr:write(" " .. wt .. "\n")
1629 end
1630 end
1631 os.exit(EXIT_USER_ERROR)
1632 end
1633
1634 -- Case 4: Normal clone (.git/ directory, no worktrees)
1635 -- Check for uncommitted changes
1636 local status_out = run_cmd("git status --porcelain")
1637 if status_out ~= "" then
1638 die("uncommitted changes; commit or stash before converting")
1639 end
1640
1641 -- Detect default branch
1642 local default_branch = detect_cloned_default_branch(git_path)
1643
1644 -- Warnings
1645 local warnings = {}
1646
1647 -- Check for submodules
1648 if is_file(cwd .. "/.gitmodules") then
1649 table.insert(warnings, "submodules detected (.gitmodules exists); verify they work after conversion")
1650 end
1651
1652 -- Check for nested .git directories (excluding the main one)
1653 local nested_git_output, _ = run_cmd("find " .. cwd .. " -mindepth 2 -name .git -type d 2>/dev/null")
1654 if nested_git_output ~= "" then
1655 table.insert(warnings, "nested .git directories found; these may cause issues")
1656 end
1657
1658 -- Find orphaned files (files in root that will be deleted)
1659 local all_entries = list_dir(cwd)
1660 local orphaned = {}
1661 for _, entry in ipairs(all_entries) do
1662 if entry ~= ".git" and entry ~= ".bare" then
1663 table.insert(orphaned, entry)
1664 end
1665 end
1666
1667 -- Load global config for path style
1668 local global_config = load_global_config()
1669 local style = global_config.branch_path_style or "nested"
1670 local separator = global_config.flat_separator
1671 local worktree_path = branch_to_path(cwd, default_branch, style, separator)
1672
1673 if dry_run then
1674 print("Dry run - planned actions:")
1675 print("")
1676 print("1. Move .git/ to .bare/")
1677 print("2. Create .git file pointing to .bare/")
1678 print("3. Create worktree: " .. worktree_path .. " (" .. default_branch .. ")")
1679 if #orphaned > 0 then
1680 print("4. Remove " .. #orphaned .. " orphaned items from root:")
1681 for _, item in ipairs(orphaned) do
1682 print(" - " .. item)
1683 end
1684 end
1685 if #warnings > 0 then
1686 print("")
1687 print("Warnings:")
1688 for _, w in ipairs(warnings) do
1689 print(" ⚠ " .. w)
1690 end
1691 end
1692 os.exit(EXIT_SUCCESS)
1693 end
1694
1695 -- Show warnings
1696 for _, w in ipairs(warnings) do
1697 io.stderr:write("warning: " .. w .. "\n")
1698 end
1699
1700 -- Confirm with gum (unless -y/--yes)
1701 if not skip_confirm then
1702 local confirm_msg = "Convert to wt bare structure? This will move .git to .bare"
1703 if #orphaned > 0 then
1704 confirm_msg = confirm_msg .. " and remove " .. #orphaned .. " items from root"
1705 end
1706
1707 local confirm_code = os.execute("gum confirm '" .. confirm_msg .. "'")
1708 if confirm_code ~= true then
1709 print("Aborted")
1710 os.exit(EXIT_USER_ERROR)
1711 end
1712 end
1713
1714 -- Step 1: Move .git to .bare
1715 local output, code = run_cmd("mv " .. git_path .. " " .. bare_path)
1716 if code ~= 0 then
1717 die("failed to move .git to .bare: " .. output, EXIT_SYSTEM_ERROR)
1718 end
1719
1720 -- Step 2: Write .git file
1721 local git_file_handle = io.open(git_path, "w")
1722 if not git_file_handle then
1723 -- Try to recover
1724 run_cmd("mv " .. bare_path .. " " .. git_path)
1725 die("failed to create .git file", EXIT_SYSTEM_ERROR)
1726 return
1727 end
1728 git_file_handle:write("gitdir: ./.bare\n")
1729 git_file_handle:close()
1730
1731 -- Step 3: Detach HEAD so branch can be checked out in worktree
1732 -- Point bare repo's HEAD to a placeholder so main branch can be used by worktree
1733 run_cmd("GIT_DIR=" .. bare_path .. " git symbolic-ref HEAD refs/heads/__wt_detached_placeholder__")
1734
1735 -- Step 4: Create worktree for default branch
1736 output, code = run_cmd("GIT_DIR=" .. bare_path .. " git worktree add -- " .. worktree_path .. " " .. default_branch)
1737 if code ~= 0 then
1738 die("failed to create worktree: " .. output, EXIT_SYSTEM_ERROR)
1739 end
1740
1741 -- Step 5: Remove orphaned files from root
1742 for _, item in ipairs(orphaned) do
1743 local item_path = cwd .. "/" .. item
1744 output, code = run_cmd("rm -rf " .. item_path)
1745 if code ~= 0 then
1746 io.stderr:write("warning: failed to remove " .. item .. ": " .. output .. "\n")
1747 end
1748 end
1749
1750 -- Summary
1751 print("Converted to wt bare structure")
1752 print("Bare repo: " .. bare_path)
1753 print("Worktree: " .. worktree_path)
1754 if #orphaned > 0 then
1755 print("Removed: " .. #orphaned .. " items from root")
1756 end
1757end
1758
1759-- Main entry point
1760
1761local function main()
1762 local command = arg[1]
1763
1764 if not command or command == "help" or command == "--help" or command == "-h" then
1765 print_usage()
1766 os.exit(EXIT_SUCCESS)
1767 end
1768
1769 ---@cast command string
1770
1771 -- Collect remaining args
1772 local subargs = {}
1773 for i = 2, #arg do
1774 table.insert(subargs, arg[i])
1775 end
1776
1777 -- Check for --help on any command
1778 if subargs[1] == "--help" or subargs[1] == "-h" then
1779 show_command_help(command)
1780 end
1781
1782 if command == "c" then
1783 cmd_clone(subargs)
1784 elseif command == "n" then
1785 cmd_new(subargs)
1786 elseif command == "a" then
1787 cmd_add(subargs)
1788 elseif command == "r" then
1789 cmd_remove(subargs)
1790 elseif command == "l" then
1791 cmd_list()
1792 elseif command == "f" then
1793 cmd_fetch()
1794 elseif command == "init" then
1795 cmd_init(subargs)
1796 else
1797 die("unknown command: " .. command)
1798 end
1799end
1800
1801-- Export for testing when required as module
1802if pcall(debug.getlocal, 4, 1) then
1803 return {
1804 -- URL/project parsing
1805 extract_project_name = extract_project_name,
1806 resolve_url_template = resolve_url_template,
1807 -- Path manipulation
1808 branch_to_path = branch_to_path,
1809 split_path = split_path,
1810 relative_path = relative_path,
1811 path_inside = path_inside,
1812 -- Config loading
1813 load_global_config = load_global_config,
1814 load_project_config = load_project_config,
1815 -- Git output parsing (testable without git)
1816 parse_branch_remotes = parse_branch_remotes,
1817 parse_worktree_list = parse_worktree_list,
1818 escape_pattern = escape_pattern,
1819 -- Hook helpers
1820 summarize_hooks = summarize_hooks,
1821 load_hook_permissions = function(home_override)
1822 local home = home_override or os.getenv("HOME")
1823 if not home then
1824 return {}
1825 end
1826 local path = home .. "/.local/share/wt/hook-dirs.lua"
1827 local f = io.open(path, "r")
1828 if not f then
1829 return {}
1830 end
1831 local content = f:read("*a")
1832 f:close()
1833 local chunk = load("return " .. content, path, "t", {})
1834 if not chunk then
1835 return {}
1836 end
1837 local ok, result = pcall(chunk)
1838 if ok and type(result) == "table" then
1839 return result
1840 end
1841 return {}
1842 end,
1843 save_hook_permissions = function(perms, home_override)
1844 local home = home_override or os.getenv("HOME")
1845 if not home then
1846 return
1847 end
1848 local dir = home .. "/.local/share/wt"
1849 run_cmd_silent("mkdir -p " .. dir)
1850 local path = dir .. "/hook-dirs.lua"
1851 local f = io.open(path, "w")
1852 if not f then
1853 return
1854 end
1855 f:write("{\n")
1856 for k, v in pairs(perms) do
1857 f:write('\t["' .. k .. '"] = ' .. tostring(v) .. ",\n")
1858 end
1859 f:write("}\n")
1860 f:close()
1861 end,
1862 run_hooks = function(source, target, hooks, root, home_override)
1863 local home = home_override or os.getenv("HOME")
1864 if not home then
1865 return
1866 end
1867 local perm_path = home .. "/.local/share/wt/hook-dirs.lua"
1868 local perms = {}
1869 local pf = io.open(perm_path, "r")
1870 if pf then
1871 local content = pf:read("*a")
1872 pf:close()
1873 local chunk = load("return " .. content, perm_path, "t", {})
1874 if chunk then
1875 local ok, result = pcall(chunk)
1876 if ok and type(result) == "table" then
1877 perms = result
1878 end
1879 end
1880 end
1881 if perms[root] == false then
1882 io.stderr:write("hooks skipped (not allowed for this project)\n")
1883 return
1884 end
1885 if hooks.copy then
1886 for _, item in ipairs(hooks.copy) do
1887 local src = source .. "/" .. item
1888 local dst = target .. "/" .. item
1889 local parent = dst:match("(.+)/[^/]+$")
1890 if parent then
1891 run_cmd_silent("mkdir -p " .. parent)
1892 end
1893 run_cmd("cp -r " .. src .. " " .. dst)
1894 end
1895 end
1896 if hooks.symlink then
1897 for _, item in ipairs(hooks.symlink) do
1898 local src = source .. "/" .. item
1899 local dst = target .. "/" .. item
1900 local parent = dst:match("(.+)/[^/]+$")
1901 if parent then
1902 run_cmd_silent("mkdir -p " .. parent)
1903 end
1904 run_cmd("ln -s " .. src .. " " .. dst)
1905 end
1906 end
1907 if hooks.run then
1908 for _, cmd in ipairs(hooks.run) do
1909 run_cmd("cd " .. target .. " && " .. cmd)
1910 end
1911 end
1912 end,
1913 -- Project root detection
1914 find_project_root = function(cwd_override)
1915 local cwd = cwd_override or get_cwd()
1916 if not cwd then
1917 return nil, "failed to get current directory"
1918 end
1919 local path = cwd
1920 while path and path ~= "" and path ~= "/" do
1921 local bare_check = io.open(path .. "/.bare/HEAD", "r")
1922 if bare_check then
1923 bare_check:close()
1924 return path, nil
1925 end
1926 local git_file = io.open(path .. "/.git", "r")
1927 if git_file then
1928 local content = git_file:read("*a")
1929 git_file:close()
1930 if content and content:match("gitdir:%s*%.?/?%.bare") then
1931 return path, nil
1932 end
1933 end
1934 path = path:match("(.+)/[^/]+$")
1935 end
1936 return nil, "not in a wt-managed repository"
1937 end,
1938 detect_source_worktree = function(root, cwd_override)
1939 local cwd = cwd_override or get_cwd()
1940 if not cwd then
1941 return nil
1942 end
1943 if cwd == root then
1944 return nil
1945 end
1946 local git_file = io.open(cwd .. "/.git", "r")
1947 if git_file then
1948 git_file:close()
1949 return cwd
1950 end
1951 local path = cwd
1952 while path and path ~= "" and path ~= "/" and path ~= root do
1953 local gf = io.open(path .. "/.git", "r")
1954 if gf then
1955 gf:close()
1956 return path
1957 end
1958 path = path:match("(.+)/[^/]+$")
1959 end
1960 return nil
1961 end,
1962 -- Command execution (for integration tests)
1963 run_cmd = run_cmd,
1964 run_cmd_silent = run_cmd_silent,
1965 -- Exit codes (re-exported from wt.exit)
1966 EXIT_SUCCESS = exit.EXIT_SUCCESS,
1967 EXIT_USER_ERROR = exit.EXIT_USER_ERROR,
1968 EXIT_SYSTEM_ERROR = exit.EXIT_SYSTEM_ERROR,
1969 }
1970end
1971
1972main()