1-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2--
3-- SPDX-License-Identifier: GPL-3.0-or-later
4
5local exit = require("wt.exit")
6local shell = require("wt.shell")
7local git = require("wt.git")
8local path_mod = require("wt.path")
9local config = require("wt.config")
10
11---@class wt.cmd.init
12local M = {}
13
14---List directory entries (excluding . and ..)
15---@param path string
16---@return string[]
17local function list_dir(path)
18 local entries = {}
19 local handle = io.popen("ls -A " .. shell.quote(path) .. " 2>/dev/null")
20 if not handle then
21 return entries
22 end
23 for line in handle:lines() do
24 if line ~= "" then
25 table.insert(entries, line)
26 end
27 end
28 handle:close()
29 return entries
30end
31
32---@class FileStatus
33---@field path string
34---@field status string
35
36---Parse git status output to classify files
37---@return FileStatus[] tracked_changes Files with tracked modifications (abort condition)
38---@return FileStatus[] untracked Untracked files (to preserve)
39---@return FileStatus[] ignored Ignored files (to preserve)
40local function parse_git_status()
41 local tracked_changes = {}
42 local untracked = {}
43 local ignored = {}
44
45 local handle = io.popen("git status --porcelain=v1 -z --ignored=matching 2>&1")
46 if not handle then
47 return tracked_changes, untracked, ignored
48 end
49
50 local output = handle:read("*a") or ""
51 handle:close()
52
53 -- Parse NUL-delimited records: "XY path\0" or "XY old\0new\0" for renames
54 local i = 1
55 while i <= #output do
56 -- Find next NUL
57 local nul_pos = output:find("\0", i, true)
58 if not nul_pos then
59 break
60 end
61
62 local record = output:sub(i, nul_pos - 1)
63 if #record >= 3 then
64 local xy = record:sub(1, 2)
65 local file_path = record:sub(4)
66
67 if xy == "??" then
68 table.insert(untracked, { path = file_path, status = xy })
69 elseif xy == "!!" then
70 table.insert(ignored, { path = file_path, status = xy })
71 else
72 table.insert(tracked_changes, { path = file_path, status = xy })
73 end
74
75 -- Handle renames (R) and copies (C) which have two paths
76 if xy:sub(1, 1) == "R" or xy:sub(1, 1) == "C" then
77 -- Skip the next NUL-delimited field (the new path)
78 local next_nul = output:find("\0", nul_pos + 1, true)
79 if next_nul then
80 nul_pos = next_nul
81 end
82 end
83 end
84
85 i = nul_pos + 1
86 end
87
88 return tracked_changes, untracked, ignored
89end
90
91---Copy a file or directory to destination, preserving metadata
92---@param src string Source path
93---@param dest string Destination path
94---@return boolean success
95---@return string? error_msg
96local function copy_preserve(src, dest)
97 -- Create parent directory if needed
98 local parent = dest:match("(.+)/[^/]+$")
99 if parent then
100 local _, code = shell.run_cmd("mkdir -p " .. shell.quote(parent))
101 if code ~= 0 then
102 return false, "failed to create parent directory"
103 end
104 end
105
106 local output, code = shell.run_cmd("cp -a " .. shell.quote(src) .. " " .. shell.quote(dest))
107 if code ~= 0 then
108 return false, output
109 end
110 return true, nil
111end
112
113---Check if path is a directory
114---@param path string
115---@return boolean
116local function is_dir(path)
117 local f = io.open(path, "r")
118 if not f then
119 return false
120 end
121 f:close()
122 return shell.run_cmd_silent("test -d " .. path)
123end
124
125---Check if path is a file (not directory)
126---@param path string
127---@return boolean
128local function is_file(path)
129 local f = io.open(path, "r")
130 if not f then
131 return false
132 end
133 f:close()
134 return shell.run_cmd_silent("test -f " .. path)
135end
136
137---Convert existing git repository to wt bare structure
138---@param args string[]
139function M.cmd_init(args)
140 -- Parse arguments
141 local dry_run = false
142 local skip_confirm = false
143 for _, a in ipairs(args) do
144 if a == "--dry-run" then
145 dry_run = true
146 elseif a == "-y" or a == "--yes" then
147 skip_confirm = true
148 else
149 shell.die("unexpected argument: " .. a)
150 end
151 end
152
153 local cwd = shell.get_cwd()
154 if not cwd then
155 shell.die("failed to get current directory", exit.EXIT_SYSTEM_ERROR)
156 return
157 end
158
159 -- Case 1: Already wt-managed (.git file pointing to .bare + .bare/ exists)
160 local git_path = cwd .. "/.git"
161 local bare_path = cwd .. "/.bare"
162
163 local bare_exists = is_dir(bare_path)
164 local git_file = io.open(git_path, "r")
165
166 if git_file then
167 local content = git_file:read("*a")
168 git_file:close()
169
170 -- Check if it's a file (not directory) pointing to .bare
171 if is_file(git_path) and content and content:match("gitdir:%s*%.?/?%.bare") then
172 if bare_exists then
173 print("Already using wt bare structure")
174 os.exit(exit.EXIT_SUCCESS)
175 end
176 end
177
178 -- Check if .git is a file pointing elsewhere (inside a worktree)
179 if is_file(git_path) and content and content:match("^gitdir:") then
180 -- It's a worktree, not project root
181 shell.die("inside a worktree; run from project root or use 'wt c' to clone fresh")
182 end
183 end
184
185 -- Check for .git directory
186 local git_dir_exists = is_dir(git_path)
187
188 if not git_dir_exists then
189 -- Case 5: No .git at all, or bare repo without .git dir
190 if bare_exists then
191 shell.die("found .bare/ but no .git; manually create .git file with 'gitdir: ./.bare'")
192 end
193 shell.die("not a git repository (no .git found)")
194 end
195
196 -- Now we have a .git directory
197 -- Case 3: Existing worktree setup (.git/worktrees/ exists)
198 local worktrees_path = git_path .. "/worktrees"
199 if is_dir(worktrees_path) then
200 local worktrees = list_dir(worktrees_path)
201 io.stderr:write("error: repository already uses git worktrees\n")
202 io.stderr:write("\n")
203 io.stderr:write("Converting an existing worktree setup is complex and error-prone.\n")
204 io.stderr:write("Recommend: clone fresh with 'wt c <url>' and recreate worktrees.\n")
205 if #worktrees > 0 then
206 io.stderr:write("\nExisting worktrees:\n")
207 for _, wt in ipairs(worktrees) do
208 io.stderr:write(" " .. wt .. "\n")
209 end
210 end
211 os.exit(exit.EXIT_USER_ERROR)
212 end
213
214 -- Case 4: Normal clone (.git/ directory, no worktrees)
215 -- Parse git status to classify files
216 local tracked_changes, untracked, ignored = parse_git_status()
217
218 -- Abort on tracked modifications (staged or unstaged)
219 if #tracked_changes > 0 then
220 io.stderr:write("error: uncommitted changes\n")
221 for _, f in ipairs(tracked_changes) do
222 io.stderr:write(" " .. f.status .. " " .. f.path .. "\n")
223 end
224 io.stderr:write("\nhint: commit, or stash and restore after:\n")
225 io.stderr:write(" git stash -u && wt init && cd <worktree> && git stash pop\n")
226 os.exit(exit.EXIT_USER_ERROR)
227 end
228
229 -- Collect files to preserve (untracked + ignored)
230 local to_preserve = {}
231 for _, f in ipairs(untracked) do
232 table.insert(to_preserve, { path = f.path, kind = "untracked" })
233 end
234 for _, f in ipairs(ignored) do
235 table.insert(to_preserve, { path = f.path, kind = "ignored" })
236 end
237
238 -- Detect default branch
239 local default_branch = git.detect_cloned_default_branch(git_path)
240
241 -- Warnings
242 local warnings = {}
243
244 -- Check for submodules
245 if is_file(cwd .. "/.gitmodules") then
246 table.insert(warnings, "submodules detected (.gitmodules exists); verify they work after conversion")
247 end
248
249 -- Check for nested .git directories (excluding the main one)
250 local nested_git_output, _ =
251 shell.run_cmd("find " .. shell.quote(cwd) .. " -mindepth 2 -name .git -type d 2>/dev/null")
252 if nested_git_output ~= "" then
253 table.insert(warnings, "nested .git directories found; these may cause issues")
254 end
255
256 -- Find orphaned files (files in root that will be deleted after preservation)
257 local all_entries = list_dir(cwd)
258 local orphaned = {}
259 for _, entry in ipairs(all_entries) do
260 if entry ~= ".git" and entry ~= ".bare" then
261 table.insert(orphaned, entry)
262 end
263 end
264
265 -- Load global config for path style
266 local global_config = config.load_global_config()
267 local style = global_config.branch_path_style or "nested"
268 local separator = global_config.flat_separator
269 local worktree_path = path_mod.branch_to_path(cwd, default_branch, style, separator)
270
271 if dry_run then
272 print("Dry run - planned actions:")
273 print("")
274 print("1. Move .git/ to .bare/")
275 print("2. Create .git file pointing to .bare/")
276 print("3. Create worktree: " .. worktree_path .. " (" .. default_branch .. ")")
277 if #to_preserve > 0 then
278 print("4. Copy " .. #to_preserve .. " untracked/ignored items to worktree:")
279 for _, item in ipairs(to_preserve) do
280 print(" - " .. item.path .. " (" .. item.kind .. ")")
281 end
282 end
283 if #orphaned > 0 then
284 local step = #to_preserve > 0 and "5" or "4"
285 print(step .. ". Remove " .. #orphaned .. " items from root after worktree is ready")
286 end
287 if #warnings > 0 then
288 print("")
289 print("Warnings:")
290 for _, w in ipairs(warnings) do
291 print(" ⚠ " .. w)
292 end
293 end
294 os.exit(exit.EXIT_SUCCESS)
295 end
296
297 -- Show warnings
298 for _, w in ipairs(warnings) do
299 io.stderr:write("warning: " .. w .. "\n")
300 end
301
302 -- Show what will be preserved
303 if #to_preserve > 0 then
304 io.stderr:write("\n")
305 io.stderr:write(
306 "The following " .. #to_preserve .. " untracked/ignored items will be copied to the new worktree:\n"
307 )
308 for _, item in ipairs(to_preserve) do
309 io.stderr:write(" - " .. item.path .. " (" .. item.kind .. ")\n")
310 end
311 io.stderr:write("\n")
312 end
313
314 -- Show what will be deleted from root
315 if #orphaned > 0 then
316 io.stderr:write("After copying, " .. #orphaned .. " items will be removed from project root.\n")
317 io.stderr:write("Worktree location: " .. worktree_path .. "\n")
318 io.stderr:write("\n")
319 end
320
321 -- Confirm with gum (unless -y/--yes)
322 if not skip_confirm then
323 local confirm_code = os.execute("gum confirm 'Convert to wt bare structure?'")
324 if confirm_code ~= true then
325 print("Aborted")
326 os.exit(exit.EXIT_USER_ERROR)
327 end
328 end
329
330 -- Step 1: Move .git to .bare
331 local output, code = shell.run_cmd("mv " .. shell.quote(git_path) .. " " .. shell.quote(bare_path))
332 if code ~= 0 then
333 shell.die("failed to move .git to .bare: " .. output, exit.EXIT_SYSTEM_ERROR)
334 end
335
336 -- Step 2: Write .git file
337 local git_file_handle = io.open(git_path, "w")
338 if not git_file_handle then
339 -- Try to recover
340 shell.run_cmd("mv " .. shell.quote(bare_path) .. " " .. shell.quote(git_path))
341 shell.die("failed to create .git file", exit.EXIT_SYSTEM_ERROR)
342 return
343 end
344 git_file_handle:write("gitdir: ./.bare\n")
345 git_file_handle:close()
346
347 -- Step 3: Detach HEAD so branch can be checked out in worktree
348 -- Point bare repo's HEAD to a placeholder so main branch can be used by worktree
349 shell.run_cmd(
350 "GIT_DIR=" .. shell.quote(bare_path) .. " git symbolic-ref HEAD refs/heads/__wt_detached_placeholder__"
351 )
352
353 -- Step 4: Create worktree for default branch
354 output, code = shell.run_cmd(
355 "GIT_DIR="
356 .. shell.quote(bare_path)
357 .. " git worktree add -- "
358 .. shell.quote(worktree_path)
359 .. " "
360 .. shell.quote(default_branch)
361 )
362 if code ~= 0 then
363 shell.die("failed to create worktree: " .. output, exit.EXIT_SYSTEM_ERROR)
364 end
365
366 -- Step 5: Copy untracked/ignored files to worktree before deleting
367 local copy_failed = false
368 for _, item in ipairs(to_preserve) do
369 local src = cwd .. "/" .. item.path
370 local dest = worktree_path .. "/" .. item.path
371
372 -- Check for conflict: destination already exists
373 local dest_exists = shell.run_cmd_silent("test -e " .. shell.quote(dest))
374 if dest_exists then
375 io.stderr:write("error: destination already exists: " .. dest .. "\n")
376 io.stderr:write("hint: this may be a tracked file; cannot overwrite\n")
377 copy_failed = true
378 else
379 local ok, err = copy_preserve(src, dest)
380 if not ok then
381 io.stderr:write("error: failed to copy " .. item.path .. ": " .. (err or "unknown error") .. "\n")
382 copy_failed = true
383 end
384 end
385 end
386
387 if copy_failed then
388 io.stderr:write("\nConversion partially complete but some files could not be preserved.\n")
389 io.stderr:write("The worktree exists at: " .. worktree_path .. "\n")
390 io.stderr:write("Original files remain in project root; manually copy them before deleting.\n")
391 os.exit(exit.EXIT_SYSTEM_ERROR)
392 end
393
394 -- Step 6: Remove orphaned files from root
395 for _, item in ipairs(orphaned) do
396 local item_path = cwd .. "/" .. item
397 output, code = shell.run_cmd("rm -rf " .. shell.quote(item_path))
398 if code ~= 0 then
399 io.stderr:write("warning: failed to remove " .. item .. ": " .. output .. "\n")
400 end
401 end
402
403 -- Summary
404 print("Converted to wt bare structure")
405 print("Bare repo: " .. bare_path)
406 print("Worktree: " .. worktree_path)
407 if #to_preserve > 0 then
408 print("Preserved: " .. #to_preserve .. " untracked/ignored items")
409 end
410 if #orphaned > 0 then
411 print("Cleaned: " .. #orphaned .. " items from root")
412 end
413end
414
415return M