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