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
30local git_mod = require("wt.git")
31local find_project_root = git_mod.find_project_root
32local detect_source_worktree = git_mod.detect_source_worktree
33local branch_exists_local = git_mod.branch_exists_local
34local find_branch_remotes = git_mod.find_branch_remotes
35local detect_cloned_default_branch = git_mod.detect_cloned_default_branch
36local get_default_branch = git_mod.get_default_branch
37local parse_branch_remotes = git_mod.parse_branch_remotes
38local parse_worktree_list = git_mod.parse_worktree_list
39local branch_checked_out_at = git_mod.branch_checked_out_at
40
41local config_mod = require("wt.config")
42local resolve_url_template = config_mod.resolve_url_template
43local extract_project_name = config_mod.extract_project_name
44local load_global_config = config_mod.load_global_config
45local load_project_config = config_mod.load_project_config
46
47local hooks_mod = require("wt.hooks")
48local load_hook_permissions = hooks_mod.load_hook_permissions
49local save_hook_permissions = hooks_mod.save_hook_permissions
50local summarize_hooks = hooks_mod.summarize_hooks
51local run_hooks = hooks_mod.run_hooks
52
53local help_mod = require("wt.help")
54local print_usage = help_mod.print_usage
55local show_command_help = help_mod.show_command_help
56
57---@param args string[]
58local function cmd_clone(args)
59 -- Parse arguments: <url> [--remote name]... [--own]
60 local url = nil
61 ---@type string[]
62 local remote_flags = {}
63 local own = false
64
65 local i = 1
66 while i <= #args do
67 local a = args[i]
68 if a == "--remote" then
69 if not args[i + 1] then
70 die("--remote requires a name")
71 end
72 table.insert(remote_flags, args[i + 1])
73 i = i + 1
74 elseif a == "--own" then
75 own = true
76 elseif not url then
77 url = a
78 else
79 die("unexpected argument: " .. a)
80 end
81 i = i + 1
82 end
83
84 if not url then
85 die("usage: wt c <url> [--remote name]... [--own]")
86 return
87 end
88
89 -- Extract project name from URL
90 local project_name = extract_project_name(url)
91 if not project_name then
92 die("could not extract project name from URL: " .. url)
93 return
94 end
95
96 -- Check if project directory already exists
97 local cwd = get_cwd()
98 if not cwd then
99 die("failed to get current directory", EXIT_SYSTEM_ERROR)
100 end
101 local project_path = cwd .. "/" .. project_name
102 local check = io.open(project_path, "r")
103 if check then
104 check:close()
105 die("directory already exists: " .. project_path)
106 end
107
108 -- Clone bare repo
109 local bare_path = project_path .. "/.bare"
110 local output, code = run_cmd("git clone --bare " .. url .. " " .. bare_path)
111 if code ~= 0 then
112 die("failed to clone: " .. output, EXIT_SYSTEM_ERROR)
113 end
114
115 -- Write .git file pointing to .bare
116 local git_file_handle = io.open(project_path .. "/.git", "w")
117 if not git_file_handle then
118 die("failed to create .git file", EXIT_SYSTEM_ERROR)
119 return
120 end
121 git_file_handle:write("gitdir: ./.bare\n")
122 git_file_handle:close()
123
124 -- Detect default branch
125 local git_dir = bare_path
126 local default_branch = detect_cloned_default_branch(git_dir)
127
128 -- Load global config
129 local global_config = load_global_config()
130
131 -- Determine which remotes to use
132 ---@type string[]
133 local selected_remotes = {}
134
135 if #remote_flags > 0 then
136 selected_remotes = remote_flags
137 elseif global_config.default_remotes then
138 if type(global_config.default_remotes) == "table" then
139 selected_remotes = global_config.default_remotes
140 elseif global_config.default_remotes == "prompt" then
141 if global_config.remotes then
142 local keys = {}
143 for k in pairs(global_config.remotes) do
144 table.insert(keys, k)
145 end
146 table.sort(keys)
147 if #keys > 0 then
148 local input = table.concat(keys, "\n")
149 local choose_type = own and "" or " --no-limit"
150 local cmd = "echo '" .. input .. "' | gum choose" .. choose_type
151 output, code = run_cmd(cmd)
152 if code == 0 and output ~= "" then
153 for line in output:gmatch("[^\n]+") do
154 table.insert(selected_remotes, line)
155 end
156 end
157 end
158 end
159 end
160 elseif global_config.remotes then
161 local keys = {}
162 for k in pairs(global_config.remotes) do
163 table.insert(keys, k)
164 end
165 table.sort(keys)
166 if #keys > 0 then
167 local input = table.concat(keys, "\n")
168 local choose_type = own and "" or " --no-limit"
169 local cmd = "echo '" .. input .. "' | gum choose" .. choose_type
170 output, code = run_cmd(cmd)
171 if code == 0 and output ~= "" then
172 for line in output:gmatch("[^\n]+") do
173 table.insert(selected_remotes, line)
174 end
175 end
176 end
177 end
178
179 -- Track configured remotes for summary
180 ---@type string[]
181 local configured_remotes = {}
182
183 if own then
184 -- User's own project: origin is their canonical remote
185 if #selected_remotes > 0 then
186 local first_remote = selected_remotes[1]
187 -- Rename origin to first remote
188 output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote rename origin " .. first_remote)
189 if code ~= 0 then
190 io.stderr:write("warning: failed to rename origin to " .. first_remote .. ": " .. output .. "\n")
191 else
192 -- Configure fetch refspec
193 run_cmd(
194 "GIT_DIR="
195 .. git_dir
196 .. " git config remote."
197 .. first_remote
198 .. ".fetch '+refs/heads/*:refs/remotes/"
199 .. first_remote
200 .. "/*'"
201 )
202 table.insert(configured_remotes, first_remote)
203 end
204
205 -- Add additional remotes and push to them
206 for j = 2, #selected_remotes do
207 local remote_name = selected_remotes[j]
208 local template = global_config.remotes and global_config.remotes[remote_name]
209 if template then
210 local remote_url = resolve_url_template(template, project_name)
211 output, code =
212 run_cmd("GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. remote_url)
213 if code ~= 0 then
214 io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
215 else
216 run_cmd(
217 "GIT_DIR="
218 .. git_dir
219 .. " git config remote."
220 .. remote_name
221 .. ".fetch '+refs/heads/*:refs/remotes/"
222 .. remote_name
223 .. "/*'"
224 )
225 -- Push to additional remotes
226 output, code =
227 run_cmd("GIT_DIR=" .. git_dir .. " git push " .. remote_name .. " " .. default_branch)
228 if code ~= 0 then
229 io.stderr:write("warning: failed to push to " .. remote_name .. ": " .. output .. "\n")
230 end
231 table.insert(configured_remotes, remote_name)
232 end
233 else
234 io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
235 end
236 end
237 else
238 -- No remotes selected, keep origin as-is
239 run_cmd("GIT_DIR=" .. git_dir .. " git config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'")
240 table.insert(configured_remotes, "origin")
241 end
242 else
243 -- Contributing to someone else's project
244 -- Rename origin to upstream
245 output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote rename origin upstream")
246 if code ~= 0 then
247 io.stderr:write("warning: failed to rename origin to upstream: " .. output .. "\n")
248 else
249 run_cmd(
250 "GIT_DIR=" .. git_dir .. " git config remote.upstream.fetch '+refs/heads/*:refs/remotes/upstream/*'"
251 )
252 table.insert(configured_remotes, "upstream")
253 end
254
255 -- Add user's remotes and push to each
256 for _, remote_name in ipairs(selected_remotes) do
257 local template = global_config.remotes and global_config.remotes[remote_name]
258 if template then
259 local remote_url = resolve_url_template(template, project_name)
260 output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. remote_url)
261 if code ~= 0 then
262 io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
263 else
264 run_cmd(
265 "GIT_DIR="
266 .. git_dir
267 .. " git config remote."
268 .. remote_name
269 .. ".fetch '+refs/heads/*:refs/remotes/"
270 .. remote_name
271 .. "/*'"
272 )
273 -- Push to this remote
274 output, code =
275 run_cmd("GIT_DIR=" .. git_dir .. " git push " .. remote_name .. " " .. default_branch)
276 if code ~= 0 then
277 io.stderr:write("warning: failed to push to " .. remote_name .. ": " .. output .. "\n")
278 end
279 table.insert(configured_remotes, remote_name)
280 end
281 else
282 io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
283 end
284 end
285 end
286
287 -- Fetch all remotes
288 run_cmd("GIT_DIR=" .. git_dir .. " git fetch --all")
289
290 -- Load config for path style
291 local style = global_config.branch_path_style or "nested"
292 local separator = global_config.flat_separator
293 local worktree_path = branch_to_path(project_path, default_branch, style, separator)
294
295 -- Create initial worktree
296 output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -- " .. worktree_path .. " " .. default_branch)
297 if code ~= 0 then
298 die("failed to create worktree: " .. output, EXIT_SYSTEM_ERROR)
299 end
300
301 -- Print summary
302 print("Created project: " .. project_path)
303 print("Default branch: " .. default_branch)
304 print("Worktree: " .. worktree_path)
305 if #configured_remotes > 0 then
306 print("Remotes: " .. table.concat(configured_remotes, ", "))
307 end
308end
309
310---@param args string[]
311local function cmd_new(args)
312 -- Parse arguments: <project-name> [--remote name]...
313 local project_name = nil
314 ---@type string[]
315 local remote_flags = {}
316
317 local i = 1
318 while i <= #args do
319 local a = args[i]
320 if a == "--remote" then
321 if not args[i + 1] then
322 die("--remote requires a name")
323 end
324 table.insert(remote_flags, args[i + 1])
325 i = i + 1
326 elseif not project_name then
327 project_name = a
328 else
329 die("unexpected argument: " .. a)
330 end
331 i = i + 1
332 end
333
334 if not project_name then
335 die("usage: wt n <project-name> [--remote name]...")
336 return
337 end
338
339 -- Check if project directory already exists
340 local cwd = get_cwd()
341 if not cwd then
342 die("failed to get current directory", EXIT_SYSTEM_ERROR)
343 end
344 local project_path = cwd .. "/" .. project_name
345 local check = io.open(project_path, "r")
346 if check then
347 check:close()
348 die("directory already exists: " .. project_path)
349 end
350
351 -- Load global config
352 local global_config = load_global_config()
353
354 -- Determine which remotes to use
355 ---@type string[]
356 local selected_remotes = {}
357
358 if #remote_flags > 0 then
359 -- Use explicitly provided remotes
360 selected_remotes = remote_flags
361 elseif global_config.default_remotes then
362 if type(global_config.default_remotes) == "table" then
363 selected_remotes = global_config.default_remotes
364 elseif global_config.default_remotes == "prompt" then
365 -- Prompt with gum choose
366 if global_config.remotes then
367 local keys = {}
368 for k in pairs(global_config.remotes) do
369 table.insert(keys, k)
370 end
371 table.sort(keys)
372 if #keys > 0 then
373 local input = table.concat(keys, "\n")
374 local cmd = "echo '" .. input .. "' | gum choose --no-limit"
375 local output, code = run_cmd(cmd)
376 if code == 0 and output ~= "" then
377 for line in output:gmatch("[^\n]+") do
378 table.insert(selected_remotes, line)
379 end
380 end
381 end
382 end
383 end
384 elseif global_config.remotes then
385 -- No default_remotes configured, prompt if remotes exist
386 local keys = {}
387 for k in pairs(global_config.remotes) do
388 table.insert(keys, k)
389 end
390 table.sort(keys)
391 if #keys > 0 then
392 local input = table.concat(keys, "\n")
393 local cmd = "echo '" .. input .. "' | gum choose --no-limit"
394 local output, code = run_cmd(cmd)
395 if code == 0 and output ~= "" then
396 for line in output:gmatch("[^\n]+") do
397 table.insert(selected_remotes, line)
398 end
399 end
400 end
401 end
402
403 -- Create project structure
404 local bare_path = project_path .. "/.bare"
405 local output, code = run_cmd("mkdir -p " .. bare_path)
406 if code ~= 0 then
407 die("failed to create directory: " .. output, EXIT_SYSTEM_ERROR)
408 end
409
410 output, code = run_cmd("git init --bare " .. bare_path)
411 if code ~= 0 then
412 die("failed to init bare repo: " .. output, EXIT_SYSTEM_ERROR)
413 end
414
415 -- Write .git file pointing to .bare
416 local git_file_handle = io.open(project_path .. "/.git", "w")
417 if not git_file_handle then
418 die("failed to create .git file", EXIT_SYSTEM_ERROR)
419 return
420 end
421 git_file_handle:write("gitdir: ./.bare\n")
422 git_file_handle:close()
423
424 -- Add remotes
425 local git_dir = bare_path
426 for _, remote_name in ipairs(selected_remotes) do
427 local template = global_config.remotes and global_config.remotes[remote_name]
428 if template then
429 local url = resolve_url_template(template, project_name)
430 output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. url)
431 if code ~= 0 then
432 io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
433 else
434 -- Configure fetch refspec for the remote
435 run_cmd(
436 "GIT_DIR="
437 .. git_dir
438 .. " git config remote."
439 .. remote_name
440 .. ".fetch '+refs/heads/*:refs/remotes/"
441 .. remote_name
442 .. "/*'"
443 )
444 end
445 else
446 io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
447 end
448 end
449
450 -- Detect default branch
451 local default_branch = get_default_branch()
452
453 -- Load config for path style
454 local style = global_config.branch_path_style or "nested"
455 local separator = global_config.flat_separator
456 local worktree_path = branch_to_path(project_path, default_branch, style, separator)
457
458 -- Create orphan worktree
459 output, code =
460 run_cmd("GIT_DIR=" .. git_dir .. " git worktree add --orphan -b " .. default_branch .. " -- " .. worktree_path)
461 if code ~= 0 then
462 die("failed to create worktree: " .. output, EXIT_SYSTEM_ERROR)
463 end
464
465 -- Print summary
466 print("Created project: " .. project_path)
467 print("Default branch: " .. default_branch)
468 print("Worktree: " .. worktree_path)
469 if #selected_remotes > 0 then
470 print("Remotes: " .. table.concat(selected_remotes, ", "))
471 end
472end
473
474---@param args string[]
475local function cmd_add(args)
476 -- Parse arguments: <branch> [-b [<start-point>]]
477 ---@type string|nil
478 local branch = nil
479 local create_branch = false
480 ---@type string|nil
481 local start_point = nil
482
483 local i = 1
484 while i <= #args do
485 local a = args[i]
486 if a == "-b" then
487 create_branch = true
488 -- Check if next arg is start-point (not another flag)
489 if args[i + 1] and not args[i + 1]:match("^%-") then
490 start_point = args[i + 1]
491 i = i + 1
492 end
493 elseif not branch then
494 branch = a
495 else
496 die("unexpected argument: " .. a)
497 end
498 i = i + 1
499 end
500
501 if not branch then
502 die("usage: wt a <branch> [-b [<start-point>]]")
503 return
504 end
505
506 local root, err = find_project_root()
507 if not root then
508 die(err --[[@as string]])
509 return
510 end
511
512 local git_dir = root .. "/.bare"
513 local source_worktree = detect_source_worktree(root)
514
515 -- Load config for path style
516 local global_config = load_global_config()
517 local style = global_config.branch_path_style or "nested"
518 local separator = global_config.flat_separator or "_"
519
520 local target_path = branch_to_path(root, branch, style, separator)
521
522 -- Check if target already exists
523 local check = io.open(target_path .. "/.git", "r")
524 if check then
525 check:close()
526 die("worktree already exists at " .. target_path)
527 end
528
529 local output, code
530 if create_branch then
531 -- Create new branch with worktree
532 if start_point then
533 output, code = run_cmd(
534 "GIT_DIR="
535 .. git_dir
536 .. " git worktree add -b "
537 .. branch
538 .. " -- "
539 .. target_path
540 .. " "
541 .. start_point
542 )
543 else
544 output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -b " .. branch .. " -- " .. target_path)
545 end
546 else
547 -- Check if branch exists locally or on remotes
548 local exists_local = branch_exists_local(git_dir, branch)
549 local remotes = find_branch_remotes(git_dir, branch)
550
551 if not exists_local and #remotes == 0 then
552 die("branch '" .. branch .. "' not found locally or on any remote (use -b to create)")
553 end
554
555 if #remotes > 1 then
556 die("branch '" .. branch .. "' exists on multiple remotes: " .. table.concat(remotes, ", "))
557 end
558
559 output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -- " .. target_path .. " " .. branch)
560 end
561
562 if code ~= 0 then
563 die("failed to add worktree: " .. output, EXIT_SYSTEM_ERROR)
564 end
565
566 -- Run hooks if we have a source worktree
567 local project_config = load_project_config(root)
568 if source_worktree then
569 if project_config.hooks then
570 run_hooks(source_worktree, target_path, project_config.hooks, root)
571 end
572 elseif project_config.hooks then
573 io.stderr:write("warning: hooks skipped (run from inside a worktree to apply hooks)\n")
574 end
575
576 print(target_path)
577end
578
579---Check if cwd is inside (or equal to) a given path
580---@param target string
581---@return boolean
582local function cwd_inside_path(target)
583 local cwd = get_cwd()
584 if not cwd then
585 return false
586 end
587 return path_inside(cwd, target)
588end
589
590---Get the bare repo's HEAD branch
591---@param git_dir string
592---@return string|nil branch name, nil on error
593local function get_bare_head(git_dir)
594 local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git symbolic-ref --short HEAD")
595 if code ~= 0 then
596 return nil
597 end
598 return (output:gsub("%s+$", ""))
599end
600
601---@param args string[]
602local function cmd_remove(args)
603 -- Parse arguments: <branch> [-b] [-f]
604 local branch = nil
605 local delete_branch = false
606 local force = false
607
608 for _, a in ipairs(args) do
609 if a == "-b" then
610 delete_branch = true
611 elseif a == "-f" then
612 force = true
613 elseif not branch then
614 branch = a
615 else
616 die("unexpected argument: " .. a)
617 end
618 end
619
620 if not branch then
621 die("usage: wt r <branch> [-b] [-f]")
622 return
623 end
624
625 local root, err = find_project_root()
626 if not root then
627 die(err --[[@as string]])
628 return
629 end
630
631 local git_dir = root .. "/.bare"
632
633 -- Find worktree by querying git for actual location (not computed from config)
634 local wt_output, wt_code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain")
635 if wt_code ~= 0 then
636 die("failed to list worktrees", EXIT_SYSTEM_ERROR)
637 return
638 end
639
640 local worktrees = parse_worktree_list(wt_output)
641 local target_path = nil
642 for _, wt in ipairs(worktrees) do
643 if wt.branch == branch then
644 target_path = wt.path
645 break
646 end
647 end
648
649 if not target_path then
650 die("no worktree found for branch '" .. branch .. "'")
651 return
652 end
653
654 -- Error if cwd is inside the worktree
655 if cwd_inside_path(target_path) then
656 die("cannot remove worktree while inside it")
657 end
658
659 -- Check for uncommitted changes
660 if not force then
661 local status_out = run_cmd("git -C " .. target_path .. " status --porcelain")
662 if status_out ~= "" then
663 die("worktree has uncommitted changes (use -f to force)")
664 end
665 end
666
667 -- Remove worktree
668 local remove_cmd = "GIT_DIR=" .. git_dir .. " git worktree remove"
669 if force then
670 remove_cmd = remove_cmd .. " --force"
671 end
672 remove_cmd = remove_cmd .. " -- " .. target_path
673
674 local output, code = run_cmd(remove_cmd)
675 if code ~= 0 then
676 die("failed to remove worktree: " .. output, EXIT_SYSTEM_ERROR)
677 end
678
679 -- Delete branch if requested
680 if delete_branch then
681 -- Check if branch is bare repo's HEAD
682 local bare_head = get_bare_head(git_dir)
683 if bare_head and bare_head == branch then
684 io.stderr:write("warning: cannot delete branch '" .. branch .. "' (it's the bare repo's HEAD)\n")
685 print("Worktree removed; branch retained")
686 return
687 end
688
689 -- Check if branch is checked out elsewhere
690 local checked_out = branch_checked_out_at(git_dir, branch)
691 if checked_out then
692 die("cannot delete branch '" .. branch .. "': checked out at " .. checked_out)
693 end
694
695 -- Delete branch
696 local delete_flag = force and "-D" or "-d"
697 local del_output, del_code = run_cmd("GIT_DIR=" .. git_dir .. " git branch " .. delete_flag .. " " .. branch)
698 if del_code ~= 0 then
699 io.stderr:write("warning: failed to delete branch: " .. del_output .. "\n")
700 print("Worktree removed; branch retained")
701 return
702 end
703
704 print("Worktree and branch '" .. branch .. "' removed")
705 else
706 print("Worktree removed")
707 end
708end
709
710local function cmd_list()
711 local root, err = find_project_root()
712 if not root then
713 die(err --[[@as string]])
714 return
715 end
716
717 local git_dir = root .. "/.bare"
718 local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain")
719 if code ~= 0 then
720 die("failed to list worktrees: " .. output, EXIT_SYSTEM_ERROR)
721 end
722
723 -- Parse porcelain output into worktree entries
724 ---@type {path: string, head: string, branch: string}[]
725 local worktrees = {}
726 local current = {}
727
728 for line in output:gmatch("[^\n]+") do
729 local key, value = line:match("^(%S+)%s*(.*)$")
730 if key == "worktree" and value then
731 if current.path then
732 table.insert(worktrees, current)
733 end
734 -- Skip .bare directory
735 if value:match("/%.bare$") then
736 current = {}
737 else
738 current = { path = value, head = "", branch = "(detached)" }
739 end
740 elseif key == "HEAD" and value then
741 current.head = value:sub(1, 7)
742 elseif key == "branch" and value then
743 current.branch = value:gsub("^refs/heads/", "")
744 elseif key == "bare" then
745 -- Skip bare repo entry
746 current = {}
747 end
748 end
749 if current.path then
750 table.insert(worktrees, current)
751 end
752
753 if #worktrees == 0 then
754 print("No worktrees found")
755 return
756 end
757
758 -- Get current working directory
759 local cwd = get_cwd() or ""
760
761 -- Build table rows with status
762 local rows = {}
763 for _, wt in ipairs(worktrees) do
764 local rel_path = relative_path(cwd, wt.path)
765
766 -- Check dirty status
767 local status_out = run_cmd("git -C " .. wt.path .. " status --porcelain")
768 local status = status_out == "" and "clean" or "dirty"
769
770 table.insert(rows, rel_path .. "," .. wt.branch .. "," .. wt.head .. "," .. status)
771 end
772
773 -- Output via gum table
774 local table_input = "Path,Branch,HEAD,Status\n" .. table.concat(rows, "\n")
775 table_input = table_input:gsub("EOF", "eof")
776 local table_cmd = "gum table --print <<'EOF'\n" .. table_input .. "\nEOF"
777 local table_handle = io.popen(table_cmd, "r")
778 if not table_handle then
779 return
780 end
781 io.write(table_handle:read("*a") or "")
782 table_handle:close()
783end
784
785local function cmd_fetch()
786 local root, err = find_project_root()
787 if not root then
788 die(err --[[@as string]])
789 return
790 end
791
792 local git_dir = root .. "/.bare"
793 local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git fetch --all --prune")
794 io.write(output)
795 if code ~= 0 then
796 os.exit(EXIT_SYSTEM_ERROR)
797 end
798end
799
800---List directory entries (excluding . and ..)
801---@param path string
802---@return string[]
803local function list_dir(path)
804 local entries = {}
805 local handle = io.popen("ls -A " .. path .. " 2>/dev/null")
806 if not handle then
807 return entries
808 end
809 for line in handle:lines() do
810 if line ~= "" then
811 table.insert(entries, line)
812 end
813 end
814 handle:close()
815 return entries
816end
817
818---Check if path is a directory
819---@param path string
820---@return boolean
821local function is_dir(path)
822 local f = io.open(path, "r")
823 if not f then
824 return false
825 end
826 f:close()
827 return run_cmd_silent("test -d " .. path)
828end
829
830---Check if path is a file (not directory)
831---@param path string
832---@return boolean
833local function is_file(path)
834 local f = io.open(path, "r")
835 if not f then
836 return false
837 end
838 f:close()
839 return run_cmd_silent("test -f " .. path)
840end
841
842---@param args string[]
843local function cmd_init(args)
844 -- Parse arguments
845 local dry_run = false
846 local skip_confirm = false
847 for _, a in ipairs(args) do
848 if a == "--dry-run" then
849 dry_run = true
850 elseif a == "-y" or a == "--yes" then
851 skip_confirm = true
852 else
853 die("unexpected argument: " .. a)
854 end
855 end
856
857 local cwd = get_cwd()
858 if not cwd then
859 die("failed to get current directory", EXIT_SYSTEM_ERROR)
860 return
861 end
862
863 -- Case 1: Already wt-managed (.git file pointing to .bare + .bare/ exists)
864 local git_path = cwd .. "/.git"
865 local bare_path = cwd .. "/.bare"
866
867 local bare_exists = is_dir(bare_path)
868 local git_file = io.open(git_path, "r")
869
870 if git_file then
871 local content = git_file:read("*a")
872 git_file:close()
873
874 -- Check if it's a file (not directory) pointing to .bare
875 if is_file(git_path) and content and content:match("gitdir:%s*%.?/?%.bare") then
876 if bare_exists then
877 print("Already using wt bare structure")
878 os.exit(EXIT_SUCCESS)
879 end
880 end
881
882 -- Check if .git is a file pointing elsewhere (inside a worktree)
883 if is_file(git_path) and content and content:match("^gitdir:") then
884 -- It's a worktree, not project root
885 die("inside a worktree; run from project root or use 'wt c' to clone fresh")
886 end
887 end
888
889 -- Check for .git directory
890 local git_dir_exists = is_dir(git_path)
891
892 if not git_dir_exists then
893 -- Case 5: No .git at all, or bare repo without .git dir
894 if bare_exists then
895 die("found .bare/ but no .git; manually create .git file with 'gitdir: ./.bare'")
896 end
897 die("not a git repository (no .git found)")
898 end
899
900 -- Now we have a .git directory
901 -- Case 3: Existing worktree setup (.git/worktrees/ exists)
902 local worktrees_path = git_path .. "/worktrees"
903 if is_dir(worktrees_path) then
904 local worktrees = list_dir(worktrees_path)
905 io.stderr:write("error: repository already uses git worktrees\n")
906 io.stderr:write("\n")
907 io.stderr:write("Converting an existing worktree setup is complex and error-prone.\n")
908 io.stderr:write("Recommend: clone fresh with 'wt c <url>' and recreate worktrees.\n")
909 if #worktrees > 0 then
910 io.stderr:write("\nExisting worktrees:\n")
911 for _, wt in ipairs(worktrees) do
912 io.stderr:write(" " .. wt .. "\n")
913 end
914 end
915 os.exit(EXIT_USER_ERROR)
916 end
917
918 -- Case 4: Normal clone (.git/ directory, no worktrees)
919 -- Check for uncommitted changes
920 local status_out = run_cmd("git status --porcelain")
921 if status_out ~= "" then
922 die("uncommitted changes; commit or stash before converting")
923 end
924
925 -- Detect default branch
926 local default_branch = detect_cloned_default_branch(git_path)
927
928 -- Warnings
929 local warnings = {}
930
931 -- Check for submodules
932 if is_file(cwd .. "/.gitmodules") then
933 table.insert(warnings, "submodules detected (.gitmodules exists); verify they work after conversion")
934 end
935
936 -- Check for nested .git directories (excluding the main one)
937 local nested_git_output, _ = run_cmd("find " .. cwd .. " -mindepth 2 -name .git -type d 2>/dev/null")
938 if nested_git_output ~= "" then
939 table.insert(warnings, "nested .git directories found; these may cause issues")
940 end
941
942 -- Find orphaned files (files in root that will be deleted)
943 local all_entries = list_dir(cwd)
944 local orphaned = {}
945 for _, entry in ipairs(all_entries) do
946 if entry ~= ".git" and entry ~= ".bare" then
947 table.insert(orphaned, entry)
948 end
949 end
950
951 -- Load global config for path style
952 local global_config = load_global_config()
953 local style = global_config.branch_path_style or "nested"
954 local separator = global_config.flat_separator
955 local worktree_path = branch_to_path(cwd, default_branch, style, separator)
956
957 if dry_run then
958 print("Dry run - planned actions:")
959 print("")
960 print("1. Move .git/ to .bare/")
961 print("2. Create .git file pointing to .bare/")
962 print("3. Create worktree: " .. worktree_path .. " (" .. default_branch .. ")")
963 if #orphaned > 0 then
964 print("4. Remove " .. #orphaned .. " orphaned items from root:")
965 for _, item in ipairs(orphaned) do
966 print(" - " .. item)
967 end
968 end
969 if #warnings > 0 then
970 print("")
971 print("Warnings:")
972 for _, w in ipairs(warnings) do
973 print(" ⚠ " .. w)
974 end
975 end
976 os.exit(EXIT_SUCCESS)
977 end
978
979 -- Show warnings
980 for _, w in ipairs(warnings) do
981 io.stderr:write("warning: " .. w .. "\n")
982 end
983
984 -- Confirm with gum (unless -y/--yes)
985 if not skip_confirm then
986 local confirm_msg = "Convert to wt bare structure? This will move .git to .bare"
987 if #orphaned > 0 then
988 confirm_msg = confirm_msg .. " and remove " .. #orphaned .. " items from root"
989 end
990
991 local confirm_code = os.execute("gum confirm '" .. confirm_msg .. "'")
992 if confirm_code ~= true then
993 print("Aborted")
994 os.exit(EXIT_USER_ERROR)
995 end
996 end
997
998 -- Step 1: Move .git to .bare
999 local output, code = run_cmd("mv " .. git_path .. " " .. bare_path)
1000 if code ~= 0 then
1001 die("failed to move .git to .bare: " .. output, EXIT_SYSTEM_ERROR)
1002 end
1003
1004 -- Step 2: Write .git file
1005 local git_file_handle = io.open(git_path, "w")
1006 if not git_file_handle then
1007 -- Try to recover
1008 run_cmd("mv " .. bare_path .. " " .. git_path)
1009 die("failed to create .git file", EXIT_SYSTEM_ERROR)
1010 return
1011 end
1012 git_file_handle:write("gitdir: ./.bare\n")
1013 git_file_handle:close()
1014
1015 -- Step 3: Detach HEAD so branch can be checked out in worktree
1016 -- Point bare repo's HEAD to a placeholder so main branch can be used by worktree
1017 run_cmd("GIT_DIR=" .. bare_path .. " git symbolic-ref HEAD refs/heads/__wt_detached_placeholder__")
1018
1019 -- Step 4: Create worktree for default branch
1020 output, code = run_cmd("GIT_DIR=" .. bare_path .. " git worktree add -- " .. worktree_path .. " " .. default_branch)
1021 if code ~= 0 then
1022 die("failed to create worktree: " .. output, EXIT_SYSTEM_ERROR)
1023 end
1024
1025 -- Step 5: Remove orphaned files from root
1026 for _, item in ipairs(orphaned) do
1027 local item_path = cwd .. "/" .. item
1028 output, code = run_cmd("rm -rf " .. item_path)
1029 if code ~= 0 then
1030 io.stderr:write("warning: failed to remove " .. item .. ": " .. output .. "\n")
1031 end
1032 end
1033
1034 -- Summary
1035 print("Converted to wt bare structure")
1036 print("Bare repo: " .. bare_path)
1037 print("Worktree: " .. worktree_path)
1038 if #orphaned > 0 then
1039 print("Removed: " .. #orphaned .. " items from root")
1040 end
1041end
1042
1043-- Main entry point
1044
1045local function main()
1046 local command = arg[1]
1047
1048 if not command or command == "help" or command == "--help" or command == "-h" then
1049 print_usage()
1050 os.exit(EXIT_SUCCESS)
1051 end
1052
1053 ---@cast command string
1054
1055 -- Collect remaining args
1056 local subargs = {}
1057 for i = 2, #arg do
1058 table.insert(subargs, arg[i])
1059 end
1060
1061 -- Check for --help on any command
1062 if subargs[1] == "--help" or subargs[1] == "-h" then
1063 show_command_help(command)
1064 end
1065
1066 if command == "c" then
1067 cmd_clone(subargs)
1068 elseif command == "n" then
1069 cmd_new(subargs)
1070 elseif command == "a" then
1071 cmd_add(subargs)
1072 elseif command == "r" then
1073 cmd_remove(subargs)
1074 elseif command == "l" then
1075 cmd_list()
1076 elseif command == "f" then
1077 cmd_fetch()
1078 elseif command == "init" then
1079 cmd_init(subargs)
1080 else
1081 die("unknown command: " .. command)
1082 end
1083end
1084
1085-- Export for testing when required as module
1086if pcall(debug.getlocal, 4, 1) then
1087 return {
1088 -- URL/project parsing
1089 extract_project_name = extract_project_name,
1090 resolve_url_template = resolve_url_template,
1091 -- Path manipulation
1092 branch_to_path = branch_to_path,
1093 split_path = split_path,
1094 relative_path = relative_path,
1095 path_inside = path_inside,
1096 -- Config loading
1097 load_global_config = load_global_config,
1098 load_project_config = load_project_config,
1099 -- Git output parsing (testable without git)
1100 parse_branch_remotes = parse_branch_remotes,
1101 parse_worktree_list = parse_worktree_list,
1102 escape_pattern = escape_pattern,
1103 -- Hook helpers (re-exported from wt.hooks)
1104 summarize_hooks = summarize_hooks,
1105 load_hook_permissions = load_hook_permissions,
1106 save_hook_permissions = save_hook_permissions,
1107 run_hooks = run_hooks,
1108 -- Project root detection (re-exported from wt.git)
1109 find_project_root = find_project_root,
1110 detect_source_worktree = detect_source_worktree,
1111 -- Command execution (for integration tests)
1112 run_cmd = run_cmd,
1113 run_cmd_silent = run_cmd_silent,
1114 -- Exit codes (re-exported from wt.exit)
1115 EXIT_SUCCESS = exit.EXIT_SUCCESS,
1116 EXIT_USER_ERROR = exit.EXIT_USER_ERROR,
1117 EXIT_SYSTEM_ERROR = exit.EXIT_SYSTEM_ERROR,
1118 }
1119end
1120
1121main()