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