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