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