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