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