From 1d8edeefbf62e5593ce4e7ea38fc001ff3653832 Mon Sep 17 00:00:00 2001 From: Amolith Date: Sun, 18 Jan 2026 09:24:57 -0700 Subject: [PATCH] refactor(wt): extract cmd.init module Assisted-by: Claude Opus 4.5 via Amp --- dist/wt | 189 +++++++++++++++++--------------- src/main.lua | 248 +----------------------------------------- src/wt/cmd/init.lua | 259 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 364 insertions(+), 332 deletions(-) create mode 100644 src/wt/cmd/init.lua diff --git a/dist/wt b/dist/wt index 2f67b6742f6e44201b47ecd139a589c93f9387da..1409b9c70fdcbfdd6b680cc3e3c0c064b50e7ccf 100755 --- a/dist/wt +++ b/dist/wt @@ -1696,70 +1696,18 @@ end return M ]] - -if _VERSION < "Lua 5.2" then - io.stderr:write("error: wt requires Lua 5.2 or later\n") - os.exit(1) -end +_EMBEDDED_MODULES["wt.cmd.init"] = [[-- SPDX-FileCopyrightText: Amolith +-- +-- SPDX-License-Identifier: GPL-3.0-or-later local exit = require("wt.exit") -local EXIT_SUCCESS = exit.EXIT_SUCCESS -local EXIT_USER_ERROR = exit.EXIT_USER_ERROR -local EXIT_SYSTEM_ERROR = exit.EXIT_SYSTEM_ERROR - local shell = require("wt.shell") -local run_cmd = shell.run_cmd -local run_cmd_silent = shell.run_cmd_silent -local get_cwd = shell.get_cwd -local die = shell.die - +local git = require("wt.git") local path_mod = require("wt.path") -local branch_to_path = path_mod.branch_to_path -local split_path = path_mod.split_path -local relative_path = path_mod.relative_path -local path_inside = path_mod.path_inside -local escape_pattern = path_mod.escape_pattern - -local git_mod = require("wt.git") -local find_project_root = git_mod.find_project_root -local detect_source_worktree = git_mod.detect_source_worktree -local detect_cloned_default_branch = git_mod.detect_cloned_default_branch -local parse_branch_remotes = git_mod.parse_branch_remotes -local parse_worktree_list = git_mod.parse_worktree_list - -local config_mod = require("wt.config") -local resolve_url_template = config_mod.resolve_url_template -local extract_project_name = config_mod.extract_project_name -local load_global_config = config_mod.load_global_config -local load_project_config = config_mod.load_project_config - -local hooks_mod = require("wt.hooks") -local load_hook_permissions = hooks_mod.load_hook_permissions -local save_hook_permissions = hooks_mod.save_hook_permissions -local summarize_hooks = hooks_mod.summarize_hooks -local run_hooks = hooks_mod.run_hooks - -local help_mod = require("wt.help") -local print_usage = help_mod.print_usage -local show_command_help = help_mod.show_command_help - -local fetch_mod = require("wt.cmd.fetch") -local cmd_fetch = fetch_mod.cmd_fetch - -local list_mod = require("wt.cmd.list") -local cmd_list = list_mod.cmd_list - -local remove_mod = require("wt.cmd.remove") -local cmd_remove = remove_mod.cmd_remove - -local add_mod = require("wt.cmd.add") -local cmd_add = add_mod.cmd_add - -local new_mod = require("wt.cmd.new") -local cmd_new = new_mod.cmd_new +local config = require("wt.config") -local clone_mod = require("wt.cmd.clone") -local cmd_clone = clone_mod.cmd_clone +---@class wt.cmd.init +local M = {} ---List directory entries (excluding . and ..) ---@param path string @@ -1788,7 +1736,7 @@ local function is_dir(path) return false end f:close() - return run_cmd_silent("test -d " .. path) + return shell.run_cmd_silent("test -d " .. path) end ---Check if path is a file (not directory) @@ -1800,11 +1748,12 @@ local function is_file(path) return false end f:close() - return run_cmd_silent("test -f " .. path) + return shell.run_cmd_silent("test -f " .. path) end +---Convert existing git repository to wt bare structure ---@param args string[] -local function cmd_init(args) +function M.cmd_init(args) -- Parse arguments local dry_run = false local skip_confirm = false @@ -1814,13 +1763,13 @@ local function cmd_init(args) elseif a == "-y" or a == "--yes" then skip_confirm = true else - die("unexpected argument: " .. a) + shell.die("unexpected argument: " .. a) end end - local cwd = get_cwd() + local cwd = shell.get_cwd() if not cwd then - die("failed to get current directory", EXIT_SYSTEM_ERROR) + shell.die("failed to get current directory", exit.EXIT_SYSTEM_ERROR) return end @@ -1839,14 +1788,14 @@ local function cmd_init(args) if is_file(git_path) and content and content:match("gitdir:%s*%.?/?%.bare") then if bare_exists then print("Already using wt bare structure") - os.exit(EXIT_SUCCESS) + os.exit(exit.EXIT_SUCCESS) end end -- Check if .git is a file pointing elsewhere (inside a worktree) if is_file(git_path) and content and content:match("^gitdir:") then -- It's a worktree, not project root - die("inside a worktree; run from project root or use 'wt c' to clone fresh") + shell.die("inside a worktree; run from project root or use 'wt c' to clone fresh") end end @@ -1856,9 +1805,9 @@ local function cmd_init(args) if not git_dir_exists then -- Case 5: No .git at all, or bare repo without .git dir if bare_exists then - die("found .bare/ but no .git; manually create .git file with 'gitdir: ./.bare'") + shell.die("found .bare/ but no .git; manually create .git file with 'gitdir: ./.bare'") end - die("not a git repository (no .git found)") + shell.die("not a git repository (no .git found)") end -- Now we have a .git directory @@ -1876,18 +1825,18 @@ local function cmd_init(args) io.stderr:write(" " .. wt .. "\n") end end - os.exit(EXIT_USER_ERROR) + os.exit(exit.EXIT_USER_ERROR) end -- Case 4: Normal clone (.git/ directory, no worktrees) -- Check for uncommitted changes - local status_out = run_cmd("git status --porcelain") + local status_out = shell.run_cmd("git status --porcelain") if status_out ~= "" then - die("uncommitted changes; commit or stash before converting") + shell.die("uncommitted changes; commit or stash before converting") end -- Detect default branch - local default_branch = detect_cloned_default_branch(git_path) + local default_branch = git.detect_cloned_default_branch(git_path) -- Warnings local warnings = {} @@ -1898,7 +1847,7 @@ local function cmd_init(args) end -- Check for nested .git directories (excluding the main one) - local nested_git_output, _ = run_cmd("find " .. cwd .. " -mindepth 2 -name .git -type d 2>/dev/null") + local nested_git_output, _ = shell.run_cmd("find " .. cwd .. " -mindepth 2 -name .git -type d 2>/dev/null") if nested_git_output ~= "" then table.insert(warnings, "nested .git directories found; these may cause issues") end @@ -1913,10 +1862,10 @@ local function cmd_init(args) end -- Load global config for path style - local global_config = load_global_config() + local global_config = config.load_global_config() local style = global_config.branch_path_style or "nested" local separator = global_config.flat_separator - local worktree_path = branch_to_path(cwd, default_branch, style, separator) + local worktree_path = path_mod.branch_to_path(cwd, default_branch, style, separator) if dry_run then print("Dry run - planned actions:") @@ -1937,7 +1886,7 @@ local function cmd_init(args) print(" ⚠ " .. w) end end - os.exit(EXIT_SUCCESS) + os.exit(exit.EXIT_SUCCESS) end -- Show warnings @@ -1955,22 +1904,22 @@ local function cmd_init(args) local confirm_code = os.execute("gum confirm '" .. confirm_msg .. "'") if confirm_code ~= true then print("Aborted") - os.exit(EXIT_USER_ERROR) + os.exit(exit.EXIT_USER_ERROR) end end -- Step 1: Move .git to .bare - local output, code = run_cmd("mv " .. git_path .. " " .. bare_path) + local output, code = shell.run_cmd("mv " .. git_path .. " " .. bare_path) if code ~= 0 then - die("failed to move .git to .bare: " .. output, EXIT_SYSTEM_ERROR) + shell.die("failed to move .git to .bare: " .. output, exit.EXIT_SYSTEM_ERROR) end -- Step 2: Write .git file local git_file_handle = io.open(git_path, "w") if not git_file_handle then -- Try to recover - run_cmd("mv " .. bare_path .. " " .. git_path) - die("failed to create .git file", EXIT_SYSTEM_ERROR) + shell.run_cmd("mv " .. bare_path .. " " .. git_path) + shell.die("failed to create .git file", exit.EXIT_SYSTEM_ERROR) return end git_file_handle:write("gitdir: ./.bare\n") @@ -1978,18 +1927,19 @@ local function cmd_init(args) -- Step 3: Detach HEAD so branch can be checked out in worktree -- Point bare repo's HEAD to a placeholder so main branch can be used by worktree - run_cmd("GIT_DIR=" .. bare_path .. " git symbolic-ref HEAD refs/heads/__wt_detached_placeholder__") + shell.run_cmd("GIT_DIR=" .. bare_path .. " git symbolic-ref HEAD refs/heads/__wt_detached_placeholder__") -- Step 4: Create worktree for default branch - output, code = run_cmd("GIT_DIR=" .. bare_path .. " git worktree add -- " .. worktree_path .. " " .. default_branch) + output, code = + shell.run_cmd("GIT_DIR=" .. bare_path .. " git worktree add -- " .. worktree_path .. " " .. default_branch) if code ~= 0 then - die("failed to create worktree: " .. output, EXIT_SYSTEM_ERROR) + shell.die("failed to create worktree: " .. output, exit.EXIT_SYSTEM_ERROR) end -- Step 5: Remove orphaned files from root for _, item in ipairs(orphaned) do local item_path = cwd .. "/" .. item - output, code = run_cmd("rm -rf " .. item_path) + output, code = shell.run_cmd("rm -rf " .. item_path) if code ~= 0 then io.stderr:write("warning: failed to remove " .. item .. ": " .. output .. "\n") end @@ -2004,6 +1954,73 @@ local function cmd_init(args) end end +return M +]] + + +if _VERSION < "Lua 5.2" then + io.stderr:write("error: wt requires Lua 5.2 or later\n") + os.exit(1) +end + +local exit = require("wt.exit") +local EXIT_SUCCESS = exit.EXIT_SUCCESS + +local shell = require("wt.shell") +local run_cmd = shell.run_cmd +local run_cmd_silent = shell.run_cmd_silent +local die = shell.die + +local path_mod = require("wt.path") +local branch_to_path = path_mod.branch_to_path +local split_path = path_mod.split_path +local relative_path = path_mod.relative_path +local path_inside = path_mod.path_inside +local escape_pattern = path_mod.escape_pattern + +local git_mod = require("wt.git") +local find_project_root = git_mod.find_project_root +local detect_source_worktree = git_mod.detect_source_worktree +local parse_branch_remotes = git_mod.parse_branch_remotes +local parse_worktree_list = git_mod.parse_worktree_list + +local config_mod = require("wt.config") +local resolve_url_template = config_mod.resolve_url_template +local extract_project_name = config_mod.extract_project_name +local load_global_config = config_mod.load_global_config +local load_project_config = config_mod.load_project_config + +local hooks_mod = require("wt.hooks") +local load_hook_permissions = hooks_mod.load_hook_permissions +local save_hook_permissions = hooks_mod.save_hook_permissions +local summarize_hooks = hooks_mod.summarize_hooks +local run_hooks = hooks_mod.run_hooks + +local help_mod = require("wt.help") +local print_usage = help_mod.print_usage +local show_command_help = help_mod.show_command_help + +local fetch_mod = require("wt.cmd.fetch") +local cmd_fetch = fetch_mod.cmd_fetch + +local list_mod = require("wt.cmd.list") +local cmd_list = list_mod.cmd_list + +local remove_mod = require("wt.cmd.remove") +local cmd_remove = remove_mod.cmd_remove + +local add_mod = require("wt.cmd.add") +local cmd_add = add_mod.cmd_add + +local new_mod = require("wt.cmd.new") +local cmd_new = new_mod.cmd_new + +local clone_mod = require("wt.cmd.clone") +local cmd_clone = clone_mod.cmd_clone + +local init_mod = require("wt.cmd.init") +local cmd_init = init_mod.cmd_init + -- Main entry point local function main() diff --git a/src/main.lua b/src/main.lua index 0b14921ea323b3c18e014c5189dda77fe6c61603..0be0ed2573ee9a73aeda678b72caecbde669f609 100644 --- a/src/main.lua +++ b/src/main.lua @@ -11,13 +11,10 @@ end local exit = require("wt.exit") local EXIT_SUCCESS = exit.EXIT_SUCCESS -local EXIT_USER_ERROR = exit.EXIT_USER_ERROR -local EXIT_SYSTEM_ERROR = exit.EXIT_SYSTEM_ERROR local shell = require("wt.shell") local run_cmd = shell.run_cmd local run_cmd_silent = shell.run_cmd_silent -local get_cwd = shell.get_cwd local die = shell.die local path_mod = require("wt.path") @@ -30,7 +27,6 @@ local escape_pattern = path_mod.escape_pattern local git_mod = require("wt.git") local find_project_root = git_mod.find_project_root local detect_source_worktree = git_mod.detect_source_worktree -local detect_cloned_default_branch = git_mod.detect_cloned_default_branch local parse_branch_remotes = git_mod.parse_branch_remotes local parse_worktree_list = git_mod.parse_worktree_list @@ -68,248 +64,8 @@ local cmd_new = new_mod.cmd_new local clone_mod = require("wt.cmd.clone") local cmd_clone = clone_mod.cmd_clone ----List directory entries (excluding . and ..) ----@param path string ----@return string[] -local function list_dir(path) - local entries = {} - local handle = io.popen("ls -A " .. path .. " 2>/dev/null") - if not handle then - return entries - end - for line in handle:lines() do - if line ~= "" then - table.insert(entries, line) - end - end - handle:close() - return entries -end - ----Check if path is a directory ----@param path string ----@return boolean -local function is_dir(path) - local f = io.open(path, "r") - if not f then - return false - end - f:close() - return run_cmd_silent("test -d " .. path) -end - ----Check if path is a file (not directory) ----@param path string ----@return boolean -local function is_file(path) - local f = io.open(path, "r") - if not f then - return false - end - f:close() - return run_cmd_silent("test -f " .. path) -end - ----@param args string[] -local function cmd_init(args) - -- Parse arguments - local dry_run = false - local skip_confirm = false - for _, a in ipairs(args) do - if a == "--dry-run" then - dry_run = true - elseif a == "-y" or a == "--yes" then - skip_confirm = true - else - die("unexpected argument: " .. a) - end - end - - local cwd = get_cwd() - if not cwd then - die("failed to get current directory", EXIT_SYSTEM_ERROR) - return - end - - -- Case 1: Already wt-managed (.git file pointing to .bare + .bare/ exists) - local git_path = cwd .. "/.git" - local bare_path = cwd .. "/.bare" - - local bare_exists = is_dir(bare_path) - local git_file = io.open(git_path, "r") - - if git_file then - local content = git_file:read("*a") - git_file:close() - - -- Check if it's a file (not directory) pointing to .bare - if is_file(git_path) and content and content:match("gitdir:%s*%.?/?%.bare") then - if bare_exists then - print("Already using wt bare structure") - os.exit(EXIT_SUCCESS) - end - end - - -- Check if .git is a file pointing elsewhere (inside a worktree) - if is_file(git_path) and content and content:match("^gitdir:") then - -- It's a worktree, not project root - die("inside a worktree; run from project root or use 'wt c' to clone fresh") - end - end - - -- Check for .git directory - local git_dir_exists = is_dir(git_path) - - if not git_dir_exists then - -- Case 5: No .git at all, or bare repo without .git dir - if bare_exists then - die("found .bare/ but no .git; manually create .git file with 'gitdir: ./.bare'") - end - die("not a git repository (no .git found)") - end - - -- Now we have a .git directory - -- Case 3: Existing worktree setup (.git/worktrees/ exists) - local worktrees_path = git_path .. "/worktrees" - if is_dir(worktrees_path) then - local worktrees = list_dir(worktrees_path) - io.stderr:write("error: repository already uses git worktrees\n") - io.stderr:write("\n") - io.stderr:write("Converting an existing worktree setup is complex and error-prone.\n") - io.stderr:write("Recommend: clone fresh with 'wt c ' and recreate worktrees.\n") - if #worktrees > 0 then - io.stderr:write("\nExisting worktrees:\n") - for _, wt in ipairs(worktrees) do - io.stderr:write(" " .. wt .. "\n") - end - end - os.exit(EXIT_USER_ERROR) - end - - -- Case 4: Normal clone (.git/ directory, no worktrees) - -- Check for uncommitted changes - local status_out = run_cmd("git status --porcelain") - if status_out ~= "" then - die("uncommitted changes; commit or stash before converting") - end - - -- Detect default branch - local default_branch = detect_cloned_default_branch(git_path) - - -- Warnings - local warnings = {} - - -- Check for submodules - if is_file(cwd .. "/.gitmodules") then - table.insert(warnings, "submodules detected (.gitmodules exists); verify they work after conversion") - end - - -- Check for nested .git directories (excluding the main one) - local nested_git_output, _ = run_cmd("find " .. cwd .. " -mindepth 2 -name .git -type d 2>/dev/null") - if nested_git_output ~= "" then - table.insert(warnings, "nested .git directories found; these may cause issues") - end - - -- Find orphaned files (files in root that will be deleted) - local all_entries = list_dir(cwd) - local orphaned = {} - for _, entry in ipairs(all_entries) do - if entry ~= ".git" and entry ~= ".bare" then - table.insert(orphaned, entry) - end - end - - -- Load global config for path style - local global_config = load_global_config() - local style = global_config.branch_path_style or "nested" - local separator = global_config.flat_separator - local worktree_path = branch_to_path(cwd, default_branch, style, separator) - - if dry_run then - print("Dry run - planned actions:") - print("") - print("1. Move .git/ to .bare/") - print("2. Create .git file pointing to .bare/") - print("3. Create worktree: " .. worktree_path .. " (" .. default_branch .. ")") - if #orphaned > 0 then - print("4. Remove " .. #orphaned .. " orphaned items from root:") - for _, item in ipairs(orphaned) do - print(" - " .. item) - end - end - if #warnings > 0 then - print("") - print("Warnings:") - for _, w in ipairs(warnings) do - print(" ⚠ " .. w) - end - end - os.exit(EXIT_SUCCESS) - end - - -- Show warnings - for _, w in ipairs(warnings) do - io.stderr:write("warning: " .. w .. "\n") - end - - -- Confirm with gum (unless -y/--yes) - if not skip_confirm then - local confirm_msg = "Convert to wt bare structure? This will move .git to .bare" - if #orphaned > 0 then - confirm_msg = confirm_msg .. " and remove " .. #orphaned .. " items from root" - end - - local confirm_code = os.execute("gum confirm '" .. confirm_msg .. "'") - if confirm_code ~= true then - print("Aborted") - os.exit(EXIT_USER_ERROR) - end - end - - -- Step 1: Move .git to .bare - local output, code = run_cmd("mv " .. git_path .. " " .. bare_path) - if code ~= 0 then - die("failed to move .git to .bare: " .. output, EXIT_SYSTEM_ERROR) - end - - -- Step 2: Write .git file - local git_file_handle = io.open(git_path, "w") - if not git_file_handle then - -- Try to recover - run_cmd("mv " .. bare_path .. " " .. git_path) - die("failed to create .git file", EXIT_SYSTEM_ERROR) - return - end - git_file_handle:write("gitdir: ./.bare\n") - git_file_handle:close() - - -- Step 3: Detach HEAD so branch can be checked out in worktree - -- Point bare repo's HEAD to a placeholder so main branch can be used by worktree - run_cmd("GIT_DIR=" .. bare_path .. " git symbolic-ref HEAD refs/heads/__wt_detached_placeholder__") - - -- Step 4: Create worktree for default branch - output, code = run_cmd("GIT_DIR=" .. bare_path .. " git worktree add -- " .. worktree_path .. " " .. default_branch) - if code ~= 0 then - die("failed to create worktree: " .. output, EXIT_SYSTEM_ERROR) - end - - -- Step 5: Remove orphaned files from root - for _, item in ipairs(orphaned) do - local item_path = cwd .. "/" .. item - output, code = run_cmd("rm -rf " .. item_path) - if code ~= 0 then - io.stderr:write("warning: failed to remove " .. item .. ": " .. output .. "\n") - end - end - - -- Summary - print("Converted to wt bare structure") - print("Bare repo: " .. bare_path) - print("Worktree: " .. worktree_path) - if #orphaned > 0 then - print("Removed: " .. #orphaned .. " items from root") - end -end +local init_mod = require("wt.cmd.init") +local cmd_init = init_mod.cmd_init -- Main entry point diff --git a/src/wt/cmd/init.lua b/src/wt/cmd/init.lua new file mode 100644 index 0000000000000000000000000000000000000000..b5d9f8e8a9891e7d526b5b258d0903a8700c0d83 --- /dev/null +++ b/src/wt/cmd/init.lua @@ -0,0 +1,259 @@ +-- SPDX-FileCopyrightText: Amolith +-- +-- SPDX-License-Identifier: GPL-3.0-or-later + +local exit = require("wt.exit") +local shell = require("wt.shell") +local git = require("wt.git") +local path_mod = require("wt.path") +local config = require("wt.config") + +---@class wt.cmd.init +local M = {} + +---List directory entries (excluding . and ..) +---@param path string +---@return string[] +local function list_dir(path) + local entries = {} + local handle = io.popen("ls -A " .. path .. " 2>/dev/null") + if not handle then + return entries + end + for line in handle:lines() do + if line ~= "" then + table.insert(entries, line) + end + end + handle:close() + return entries +end + +---Check if path is a directory +---@param path string +---@return boolean +local function is_dir(path) + local f = io.open(path, "r") + if not f then + return false + end + f:close() + return shell.run_cmd_silent("test -d " .. path) +end + +---Check if path is a file (not directory) +---@param path string +---@return boolean +local function is_file(path) + local f = io.open(path, "r") + if not f then + return false + end + f:close() + return shell.run_cmd_silent("test -f " .. path) +end + +---Convert existing git repository to wt bare structure +---@param args string[] +function M.cmd_init(args) + -- Parse arguments + local dry_run = false + local skip_confirm = false + for _, a in ipairs(args) do + if a == "--dry-run" then + dry_run = true + elseif a == "-y" or a == "--yes" then + skip_confirm = true + else + shell.die("unexpected argument: " .. a) + end + end + + local cwd = shell.get_cwd() + if not cwd then + shell.die("failed to get current directory", exit.EXIT_SYSTEM_ERROR) + return + end + + -- Case 1: Already wt-managed (.git file pointing to .bare + .bare/ exists) + local git_path = cwd .. "/.git" + local bare_path = cwd .. "/.bare" + + local bare_exists = is_dir(bare_path) + local git_file = io.open(git_path, "r") + + if git_file then + local content = git_file:read("*a") + git_file:close() + + -- Check if it's a file (not directory) pointing to .bare + if is_file(git_path) and content and content:match("gitdir:%s*%.?/?%.bare") then + if bare_exists then + print("Already using wt bare structure") + os.exit(exit.EXIT_SUCCESS) + end + end + + -- Check if .git is a file pointing elsewhere (inside a worktree) + if is_file(git_path) and content and content:match("^gitdir:") then + -- It's a worktree, not project root + shell.die("inside a worktree; run from project root or use 'wt c' to clone fresh") + end + end + + -- Check for .git directory + local git_dir_exists = is_dir(git_path) + + if not git_dir_exists then + -- Case 5: No .git at all, or bare repo without .git dir + if bare_exists then + shell.die("found .bare/ but no .git; manually create .git file with 'gitdir: ./.bare'") + end + shell.die("not a git repository (no .git found)") + end + + -- Now we have a .git directory + -- Case 3: Existing worktree setup (.git/worktrees/ exists) + local worktrees_path = git_path .. "/worktrees" + if is_dir(worktrees_path) then + local worktrees = list_dir(worktrees_path) + io.stderr:write("error: repository already uses git worktrees\n") + io.stderr:write("\n") + io.stderr:write("Converting an existing worktree setup is complex and error-prone.\n") + io.stderr:write("Recommend: clone fresh with 'wt c ' and recreate worktrees.\n") + if #worktrees > 0 then + io.stderr:write("\nExisting worktrees:\n") + for _, wt in ipairs(worktrees) do + io.stderr:write(" " .. wt .. "\n") + end + end + os.exit(exit.EXIT_USER_ERROR) + end + + -- Case 4: Normal clone (.git/ directory, no worktrees) + -- Check for uncommitted changes + local status_out = shell.run_cmd("git status --porcelain") + if status_out ~= "" then + shell.die("uncommitted changes; commit or stash before converting") + end + + -- Detect default branch + local default_branch = git.detect_cloned_default_branch(git_path) + + -- Warnings + local warnings = {} + + -- Check for submodules + if is_file(cwd .. "/.gitmodules") then + table.insert(warnings, "submodules detected (.gitmodules exists); verify they work after conversion") + end + + -- Check for nested .git directories (excluding the main one) + local nested_git_output, _ = shell.run_cmd("find " .. cwd .. " -mindepth 2 -name .git -type d 2>/dev/null") + if nested_git_output ~= "" then + table.insert(warnings, "nested .git directories found; these may cause issues") + end + + -- Find orphaned files (files in root that will be deleted) + local all_entries = list_dir(cwd) + local orphaned = {} + for _, entry in ipairs(all_entries) do + if entry ~= ".git" and entry ~= ".bare" then + table.insert(orphaned, entry) + end + end + + -- Load global config for path style + local global_config = config.load_global_config() + local style = global_config.branch_path_style or "nested" + local separator = global_config.flat_separator + local worktree_path = path_mod.branch_to_path(cwd, default_branch, style, separator) + + if dry_run then + print("Dry run - planned actions:") + print("") + print("1. Move .git/ to .bare/") + print("2. Create .git file pointing to .bare/") + print("3. Create worktree: " .. worktree_path .. " (" .. default_branch .. ")") + if #orphaned > 0 then + print("4. Remove " .. #orphaned .. " orphaned items from root:") + for _, item in ipairs(orphaned) do + print(" - " .. item) + end + end + if #warnings > 0 then + print("") + print("Warnings:") + for _, w in ipairs(warnings) do + print(" ⚠ " .. w) + end + end + os.exit(exit.EXIT_SUCCESS) + end + + -- Show warnings + for _, w in ipairs(warnings) do + io.stderr:write("warning: " .. w .. "\n") + end + + -- Confirm with gum (unless -y/--yes) + if not skip_confirm then + local confirm_msg = "Convert to wt bare structure? This will move .git to .bare" + if #orphaned > 0 then + confirm_msg = confirm_msg .. " and remove " .. #orphaned .. " items from root" + end + + local confirm_code = os.execute("gum confirm '" .. confirm_msg .. "'") + if confirm_code ~= true then + print("Aborted") + os.exit(exit.EXIT_USER_ERROR) + end + end + + -- Step 1: Move .git to .bare + local output, code = shell.run_cmd("mv " .. git_path .. " " .. bare_path) + if code ~= 0 then + shell.die("failed to move .git to .bare: " .. output, exit.EXIT_SYSTEM_ERROR) + end + + -- Step 2: Write .git file + local git_file_handle = io.open(git_path, "w") + if not git_file_handle then + -- Try to recover + shell.run_cmd("mv " .. bare_path .. " " .. git_path) + shell.die("failed to create .git file", exit.EXIT_SYSTEM_ERROR) + return + end + git_file_handle:write("gitdir: ./.bare\n") + git_file_handle:close() + + -- Step 3: Detach HEAD so branch can be checked out in worktree + -- Point bare repo's HEAD to a placeholder so main branch can be used by worktree + shell.run_cmd("GIT_DIR=" .. bare_path .. " git symbolic-ref HEAD refs/heads/__wt_detached_placeholder__") + + -- Step 4: Create worktree for default branch + output, code = + shell.run_cmd("GIT_DIR=" .. bare_path .. " git worktree add -- " .. worktree_path .. " " .. default_branch) + if code ~= 0 then + shell.die("failed to create worktree: " .. output, exit.EXIT_SYSTEM_ERROR) + end + + -- Step 5: Remove orphaned files from root + for _, item in ipairs(orphaned) do + local item_path = cwd .. "/" .. item + output, code = shell.run_cmd("rm -rf " .. item_path) + if code ~= 0 then + io.stderr:write("warning: failed to remove " .. item .. ": " .. output .. "\n") + end + end + + -- Summary + print("Converted to wt bare structure") + print("Bare repo: " .. bare_path) + print("Worktree: " .. worktree_path) + if #orphaned > 0 then + print("Removed: " .. #orphaned .. " items from root") + end +end + +return M