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