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 detect_cloned_default_branch = git_mod.detect_cloned_default_branch
34local parse_branch_remotes = git_mod.parse_branch_remotes
35local parse_worktree_list = git_mod.parse_worktree_list
36
37local config_mod = require("wt.config")
38local resolve_url_template = config_mod.resolve_url_template
39local extract_project_name = config_mod.extract_project_name
40local load_global_config = config_mod.load_global_config
41local load_project_config = config_mod.load_project_config
42
43local hooks_mod = require("wt.hooks")
44local load_hook_permissions = hooks_mod.load_hook_permissions
45local save_hook_permissions = hooks_mod.save_hook_permissions
46local summarize_hooks = hooks_mod.summarize_hooks
47local run_hooks = hooks_mod.run_hooks
48
49local help_mod = require("wt.help")
50local print_usage = help_mod.print_usage
51local show_command_help = help_mod.show_command_help
52
53local fetch_mod = require("wt.cmd.fetch")
54local cmd_fetch = fetch_mod.cmd_fetch
55
56local list_mod = require("wt.cmd.list")
57local cmd_list = list_mod.cmd_list
58
59local remove_mod = require("wt.cmd.remove")
60local cmd_remove = remove_mod.cmd_remove
61
62local add_mod = require("wt.cmd.add")
63local cmd_add = add_mod.cmd_add
64
65local new_mod = require("wt.cmd.new")
66local cmd_new = new_mod.cmd_new
67
68local clone_mod = require("wt.cmd.clone")
69local cmd_clone = clone_mod.cmd_clone
70
71---List directory entries (excluding . and ..)
72---@param path string
73---@return string[]
74local function list_dir(path)
75 local entries = {}
76 local handle = io.popen("ls -A " .. path .. " 2>/dev/null")
77 if not handle then
78 return entries
79 end
80 for line in handle:lines() do
81 if line ~= "" then
82 table.insert(entries, line)
83 end
84 end
85 handle:close()
86 return entries
87end
88
89---Check if path is a directory
90---@param path string
91---@return boolean
92local function is_dir(path)
93 local f = io.open(path, "r")
94 if not f then
95 return false
96 end
97 f:close()
98 return run_cmd_silent("test -d " .. path)
99end
100
101---Check if path is a file (not directory)
102---@param path string
103---@return boolean
104local function is_file(path)
105 local f = io.open(path, "r")
106 if not f then
107 return false
108 end
109 f:close()
110 return run_cmd_silent("test -f " .. path)
111end
112
113---@param args string[]
114local function cmd_init(args)
115 -- Parse arguments
116 local dry_run = false
117 local skip_confirm = false
118 for _, a in ipairs(args) do
119 if a == "--dry-run" then
120 dry_run = true
121 elseif a == "-y" or a == "--yes" then
122 skip_confirm = true
123 else
124 die("unexpected argument: " .. a)
125 end
126 end
127
128 local cwd = get_cwd()
129 if not cwd then
130 die("failed to get current directory", EXIT_SYSTEM_ERROR)
131 return
132 end
133
134 -- Case 1: Already wt-managed (.git file pointing to .bare + .bare/ exists)
135 local git_path = cwd .. "/.git"
136 local bare_path = cwd .. "/.bare"
137
138 local bare_exists = is_dir(bare_path)
139 local git_file = io.open(git_path, "r")
140
141 if git_file then
142 local content = git_file:read("*a")
143 git_file:close()
144
145 -- Check if it's a file (not directory) pointing to .bare
146 if is_file(git_path) and content and content:match("gitdir:%s*%.?/?%.bare") then
147 if bare_exists then
148 print("Already using wt bare structure")
149 os.exit(EXIT_SUCCESS)
150 end
151 end
152
153 -- Check if .git is a file pointing elsewhere (inside a worktree)
154 if is_file(git_path) and content and content:match("^gitdir:") then
155 -- It's a worktree, not project root
156 die("inside a worktree; run from project root or use 'wt c' to clone fresh")
157 end
158 end
159
160 -- Check for .git directory
161 local git_dir_exists = is_dir(git_path)
162
163 if not git_dir_exists then
164 -- Case 5: No .git at all, or bare repo without .git dir
165 if bare_exists then
166 die("found .bare/ but no .git; manually create .git file with 'gitdir: ./.bare'")
167 end
168 die("not a git repository (no .git found)")
169 end
170
171 -- Now we have a .git directory
172 -- Case 3: Existing worktree setup (.git/worktrees/ exists)
173 local worktrees_path = git_path .. "/worktrees"
174 if is_dir(worktrees_path) then
175 local worktrees = list_dir(worktrees_path)
176 io.stderr:write("error: repository already uses git worktrees\n")
177 io.stderr:write("\n")
178 io.stderr:write("Converting an existing worktree setup is complex and error-prone.\n")
179 io.stderr:write("Recommend: clone fresh with 'wt c <url>' and recreate worktrees.\n")
180 if #worktrees > 0 then
181 io.stderr:write("\nExisting worktrees:\n")
182 for _, wt in ipairs(worktrees) do
183 io.stderr:write(" " .. wt .. "\n")
184 end
185 end
186 os.exit(EXIT_USER_ERROR)
187 end
188
189 -- Case 4: Normal clone (.git/ directory, no worktrees)
190 -- Check for uncommitted changes
191 local status_out = run_cmd("git status --porcelain")
192 if status_out ~= "" then
193 die("uncommitted changes; commit or stash before converting")
194 end
195
196 -- Detect default branch
197 local default_branch = detect_cloned_default_branch(git_path)
198
199 -- Warnings
200 local warnings = {}
201
202 -- Check for submodules
203 if is_file(cwd .. "/.gitmodules") then
204 table.insert(warnings, "submodules detected (.gitmodules exists); verify they work after conversion")
205 end
206
207 -- Check for nested .git directories (excluding the main one)
208 local nested_git_output, _ = run_cmd("find " .. cwd .. " -mindepth 2 -name .git -type d 2>/dev/null")
209 if nested_git_output ~= "" then
210 table.insert(warnings, "nested .git directories found; these may cause issues")
211 end
212
213 -- Find orphaned files (files in root that will be deleted)
214 local all_entries = list_dir(cwd)
215 local orphaned = {}
216 for _, entry in ipairs(all_entries) do
217 if entry ~= ".git" and entry ~= ".bare" then
218 table.insert(orphaned, entry)
219 end
220 end
221
222 -- Load global config for path style
223 local global_config = load_global_config()
224 local style = global_config.branch_path_style or "nested"
225 local separator = global_config.flat_separator
226 local worktree_path = branch_to_path(cwd, default_branch, style, separator)
227
228 if dry_run then
229 print("Dry run - planned actions:")
230 print("")
231 print("1. Move .git/ to .bare/")
232 print("2. Create .git file pointing to .bare/")
233 print("3. Create worktree: " .. worktree_path .. " (" .. default_branch .. ")")
234 if #orphaned > 0 then
235 print("4. Remove " .. #orphaned .. " orphaned items from root:")
236 for _, item in ipairs(orphaned) do
237 print(" - " .. item)
238 end
239 end
240 if #warnings > 0 then
241 print("")
242 print("Warnings:")
243 for _, w in ipairs(warnings) do
244 print(" ⚠ " .. w)
245 end
246 end
247 os.exit(EXIT_SUCCESS)
248 end
249
250 -- Show warnings
251 for _, w in ipairs(warnings) do
252 io.stderr:write("warning: " .. w .. "\n")
253 end
254
255 -- Confirm with gum (unless -y/--yes)
256 if not skip_confirm then
257 local confirm_msg = "Convert to wt bare structure? This will move .git to .bare"
258 if #orphaned > 0 then
259 confirm_msg = confirm_msg .. " and remove " .. #orphaned .. " items from root"
260 end
261
262 local confirm_code = os.execute("gum confirm '" .. confirm_msg .. "'")
263 if confirm_code ~= true then
264 print("Aborted")
265 os.exit(EXIT_USER_ERROR)
266 end
267 end
268
269 -- Step 1: Move .git to .bare
270 local output, code = run_cmd("mv " .. git_path .. " " .. bare_path)
271 if code ~= 0 then
272 die("failed to move .git to .bare: " .. output, EXIT_SYSTEM_ERROR)
273 end
274
275 -- Step 2: Write .git file
276 local git_file_handle = io.open(git_path, "w")
277 if not git_file_handle then
278 -- Try to recover
279 run_cmd("mv " .. bare_path .. " " .. git_path)
280 die("failed to create .git file", EXIT_SYSTEM_ERROR)
281 return
282 end
283 git_file_handle:write("gitdir: ./.bare\n")
284 git_file_handle:close()
285
286 -- Step 3: Detach HEAD so branch can be checked out in worktree
287 -- Point bare repo's HEAD to a placeholder so main branch can be used by worktree
288 run_cmd("GIT_DIR=" .. bare_path .. " git symbolic-ref HEAD refs/heads/__wt_detached_placeholder__")
289
290 -- Step 4: Create worktree for default branch
291 output, code = run_cmd("GIT_DIR=" .. bare_path .. " git worktree add -- " .. worktree_path .. " " .. default_branch)
292 if code ~= 0 then
293 die("failed to create worktree: " .. output, EXIT_SYSTEM_ERROR)
294 end
295
296 -- Step 5: Remove orphaned files from root
297 for _, item in ipairs(orphaned) do
298 local item_path = cwd .. "/" .. item
299 output, code = run_cmd("rm -rf " .. item_path)
300 if code ~= 0 then
301 io.stderr:write("warning: failed to remove " .. item .. ": " .. output .. "\n")
302 end
303 end
304
305 -- Summary
306 print("Converted to wt bare structure")
307 print("Bare repo: " .. bare_path)
308 print("Worktree: " .. worktree_path)
309 if #orphaned > 0 then
310 print("Removed: " .. #orphaned .. " items from root")
311 end
312end
313
314-- Main entry point
315
316local function main()
317 local command = arg[1]
318
319 if not command or command == "help" or command == "--help" or command == "-h" then
320 print_usage()
321 os.exit(EXIT_SUCCESS)
322 end
323
324 ---@cast command string
325
326 -- Collect remaining args
327 local subargs = {}
328 for i = 2, #arg do
329 table.insert(subargs, arg[i])
330 end
331
332 -- Check for --help on any command
333 if subargs[1] == "--help" or subargs[1] == "-h" then
334 show_command_help(command)
335 end
336
337 if command == "c" then
338 cmd_clone(subargs)
339 elseif command == "n" then
340 cmd_new(subargs)
341 elseif command == "a" then
342 cmd_add(subargs)
343 elseif command == "r" then
344 cmd_remove(subargs)
345 elseif command == "l" then
346 cmd_list()
347 elseif command == "f" then
348 cmd_fetch()
349 elseif command == "init" then
350 cmd_init(subargs)
351 else
352 die("unknown command: " .. command)
353 end
354end
355
356-- Export for testing when required as module
357if pcall(debug.getlocal, 4, 1) then
358 return {
359 -- URL/project parsing
360 extract_project_name = extract_project_name,
361 resolve_url_template = resolve_url_template,
362 -- Path manipulation
363 branch_to_path = branch_to_path,
364 split_path = split_path,
365 relative_path = relative_path,
366 path_inside = path_inside,
367 -- Config loading
368 load_global_config = load_global_config,
369 load_project_config = load_project_config,
370 -- Git output parsing (testable without git)
371 parse_branch_remotes = parse_branch_remotes,
372 parse_worktree_list = parse_worktree_list,
373 escape_pattern = escape_pattern,
374 -- Hook helpers (re-exported from wt.hooks)
375 summarize_hooks = summarize_hooks,
376 load_hook_permissions = load_hook_permissions,
377 save_hook_permissions = save_hook_permissions,
378 run_hooks = run_hooks,
379 -- Project root detection (re-exported from wt.git)
380 find_project_root = find_project_root,
381 detect_source_worktree = detect_source_worktree,
382 -- Command execution (for integration tests)
383 run_cmd = run_cmd,
384 run_cmd_silent = run_cmd_silent,
385 -- Exit codes (re-exported from wt.exit)
386 EXIT_SUCCESS = exit.EXIT_SUCCESS,
387 EXIT_USER_ERROR = exit.EXIT_USER_ERROR,
388 EXIT_SYSTEM_ERROR = exit.EXIT_SYSTEM_ERROR,
389 }
390end
391
392main()