@@ -0,0 +1,1973 @@
+#!/usr/bin/env lua
+
+-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+--
+-- SPDX-License-Identifier: GPL-3.0-or-later
+
+-- AUTO-GENERATED FILE - Do not edit directly
+-- Edit src/*.lua and run 'make dist' to regenerate
+
+
+if _VERSION < "Lua 5.2" then
+ io.stderr:write("error: wt requires Lua 5.2 or later\n")
+ os.exit(1)
+end
+
+-- Exit codes
+local EXIT_SUCCESS = 0
+local EXIT_USER_ERROR = 1
+local EXIT_SYSTEM_ERROR = 2
+
+---Execute command, return output and exit code
+---@param cmd string
+---@return string output
+---@return integer code
+local function run_cmd(cmd)
+ local handle = io.popen(cmd .. " 2>&1")
+ if not handle then
+ return "", EXIT_SYSTEM_ERROR
+ end
+ local output = handle:read("*a") or ""
+ local success, _, code = handle:close()
+ if success then
+ return output, 0
+ end
+ return output, code or EXIT_SYSTEM_ERROR
+end
+
+---Execute command silently, return success boolean
+---@param cmd string
+---@return boolean success
+local function run_cmd_silent(cmd)
+ local success = os.execute(cmd .. " >/dev/null 2>&1")
+ return success == true
+end
+
+---Walk up from cwd looking for .git file pointing to .bare, or .bare/ directory
+---@return string|nil root
+---@return string|nil error
+local function find_project_root()
+ local handle = io.popen("pwd")
+ if not handle then
+ return nil, "failed to get current directory"
+ end
+ local cwd = handle:read("*l")
+ handle:close()
+
+ if not cwd then
+ return nil, "failed to get current directory"
+ end
+
+ local path = cwd
+ while path and path ~= "" and path ~= "/" do
+ -- Check for .bare directory
+ local bare_check = io.open(path .. "/.bare/HEAD", "r")
+ if bare_check then
+ bare_check:close()
+ return path, nil
+ end
+
+ -- Check for .git file pointing to .bare
+ local git_file = io.open(path .. "/.git", "r")
+ if git_file then
+ local content = git_file:read("*a")
+ git_file:close()
+ if content and content:match("gitdir:%s*%.?/?%.bare") then
+ return path, nil
+ end
+ end
+
+ -- Move up one directory
+ path = path:match("(.+)/[^/]+$")
+ end
+
+ return nil, "not in a wt-managed repository"
+end
+
+---Substitute ${project} in template string
+---@param template string
+---@param project_name string
+---@return string
+local function resolve_url_template(template, project_name)
+ local escaped = project_name:gsub("%%", "%%%%")
+ return (template:gsub("%${project}", escaped))
+end
+
+---Parse git URLs to extract project name
+---@param url string
+---@return string|nil
+local function _extract_project_name(url) -- luacheck: ignore 211
+ if not url or url == "" then
+ return nil
+ end
+
+ url = url:gsub("[?#].*$", "")
+ url = url:gsub("/+$", "")
+
+ if url == "" or url == "/" then
+ return nil
+ end
+
+ url = url:gsub("%.git$", "")
+
+ if not url:match("://") then
+ local scp_path = url:match("^[^@]+@[^:]+:(.+)$")
+ if scp_path and scp_path ~= "" then
+ url = scp_path
+ end
+ end
+
+ local name = url:match("([^/]+)$") or url:match("([^:]+)$")
+ if not name or name == "" then
+ return nil
+ end
+ return name
+end
+
+---Print error message and exit
+---@param msg string
+---@param code? integer
+local function die(msg, code)
+ io.stderr:write("error: " .. msg .. "\n")
+ os.exit(code or EXIT_USER_ERROR)
+end
+
+---Print usage information
+local function print_usage()
+ print("wt - git worktree manager")
+ print("")
+ print("Usage: wt <command> [options]")
+ print("")
+ print("Commands:")
+ print(" c <url> [--remote name]... [--own] Clone into bare worktree structure")
+ print(" n <project-name> [--remote name]... Initialize fresh project")
+ print(" a <branch> [-b [<start-point>]] Add worktree with optional hooks")
+ print(" r <branch> [-b] [-f] Remove worktree, optionally delete branch")
+ print(" l List worktrees with status")
+ print(" f Fetch all remotes")
+ print(" init [--dry-run] [-y] Convert existing repo to bare structure")
+ print(" help Show this help message")
+end
+
+-- Per-command help text (using table.concat for performance)
+local COMMAND_HELP = {
+ c = table.concat({
+ "wt c <url> [--remote name]... [--own]",
+ "",
+ "Clone a repository into bare worktree structure.",
+ "",
+ "Arguments:",
+ " <url> Git URL to clone",
+ "",
+ "Options:",
+ " --remote <name> Add configured remote from ~/.config/wt/config.lua",
+ " Can be specified multiple times",
+ " --own Treat as your own project: first remote becomes 'origin'",
+ " (default: 'origin' renamed to 'upstream', your remotes added)",
+ "",
+ "Examples:",
+ " wt c https://github.com/user/repo.git",
+ " wt c git@github.com:user/repo.git --remote github --own",
+ }, "\n"),
+
+ n = table.concat({
+ "wt n <project-name> [--remote name]...",
+ "",
+ "Initialize a fresh project with bare worktree structure.",
+ "",
+ "Arguments:",
+ " <project-name> Name of the new project directory",
+ "",
+ "Options:",
+ " --remote <name> Add configured remote from ~/.config/wt/config.lua",
+ " Can be specified multiple times",
+ "",
+ "Examples:",
+ " wt n my-project",
+ " wt n my-project --remote github --remote gitlab",
+ }, "\n"),
+
+ a = table.concat({
+ "wt a <branch> [-b [<start-point>]]",
+ "",
+ "Add a worktree for a branch.",
+ "",
+ "Arguments:",
+ " <branch> Branch name to checkout or create",
+ "",
+ "Options:",
+ " -b Create a new branch",
+ " <start-point> Base commit/branch for new branch (only with -b)",
+ "",
+ "If run from inside an existing worktree, hooks from .wt.lua will be applied.",
+ "",
+ "Examples:",
+ " wt a main # Checkout existing branch",
+ " wt a feature/new -b # Create new branch from HEAD",
+ " wt a feature/new -b main # Create new branch from main",
+ }, "\n"),
+
+ r = table.concat({
+ "wt r <branch> [-b] [-f]",
+ "",
+ "Remove a worktree.",
+ "",
+ "Arguments:",
+ " <branch> Branch name of worktree to remove",
+ "",
+ "Options:",
+ " -b Also delete the branch after removing worktree",
+ " -f Force removal even with uncommitted changes",
+ "",
+ "Examples:",
+ " wt r feature/old # Remove worktree, keep branch",
+ " wt r feature/old -b # Remove worktree and delete branch",
+ " wt r feature/old -f # Force remove with uncommitted changes",
+ }, "\n"),
+
+ l = table.concat({
+ "wt l",
+ "",
+ "List all worktrees with status information.",
+ "",
+ "Displays a table showing:",
+ " - Branch name",
+ " - Relative path from project root",
+ " - Commit status (ahead/behind remote)",
+ " - Working tree status (clean/dirty)",
+ }, "\n"),
+
+ f = table.concat({
+ "wt f",
+ "",
+ "Fetch from all configured remotes.",
+ "",
+ "Runs 'git fetch --all' in the bare repository.",
+ }, "\n"),
+
+ init = table.concat({
+ "wt init [--dry-run] [-y]",
+ "",
+ "Convert an existing git repository to bare worktree structure.",
+ "",
+ "Options:",
+ " --dry-run Show what would be done without making changes",
+ " -y Skip confirmation prompt",
+ "",
+ "This command:",
+ " 1. Moves .git/ to .bare/",
+ " 2. Creates .git file pointing to .bare/",
+ " 3. Creates a worktree for the current branch",
+ " 4. Removes orphaned files from project root",
+ }, "\n"),
+}
+
+---Show help for a specific command
+---@param cmd string
+local function show_command_help(cmd)
+ local help = COMMAND_HELP[cmd]
+ if help then
+ print(help)
+ else
+ print_usage()
+ end
+ os.exit(EXIT_SUCCESS)
+end
+
+---Parse git URLs to extract project name (exported version)
+---@param url string
+---@return string|nil
+local function extract_project_name(url)
+ if not url or url == "" then
+ return nil
+ end
+
+ url = url:gsub("[?#].*$", "")
+ url = url:gsub("/+$", "")
+
+ if url == "" or url == "/" then
+ return nil
+ end
+
+ url = url:gsub("%.git$", "")
+
+ if not url:match("://") then
+ local scp_path = url:match("^[^@]+@[^:]+:(.+)$")
+ if scp_path and scp_path ~= "" then
+ url = scp_path
+ end
+ end
+
+ local name = url:match("([^/]+)$") or url:match("([^:]+)$")
+ if not name or name == "" then
+ return nil
+ end
+ return name
+end
+
+---Detect default branch from cloned bare repo
+---@param git_dir string
+---@return string
+local function detect_cloned_default_branch(git_dir)
+ -- First try the bare repo's own HEAD (set during clone)
+ local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git symbolic-ref HEAD")
+ if code == 0 and output ~= "" then
+ local branch = output:match("refs/heads/(.+)")
+ if branch then
+ return (branch:gsub("%s+$", ""))
+ end
+ end
+ return "main"
+end
+
+---Get default branch name from git config, fallback to "main"
+---@return string
+local function get_default_branch()
+ local output, code = run_cmd("git config --get init.defaultBranch")
+ if code == 0 and output ~= "" then
+ return (output:gsub("%s+$", ""))
+ end
+ return "main"
+end
+
+---Get current working directory
+---@return string|nil
+local function get_cwd()
+ local handle = io.popen("pwd")
+ if not handle then
+ return nil
+ end
+ local cwd = handle:read("*l")
+ handle:close()
+ return cwd
+end
+
+---Convert branch name to worktree path
+---@param root string
+---@param branch string
+---@param style string "nested" or "flat"
+---@param separator? string separator for flat style
+---@return string
+local function branch_to_path(root, branch, style, separator)
+ if style == "flat" then
+ local sep = separator or "_"
+ local escaped_sep = sep:gsub("%%", "%%%%")
+ local flat_name = branch:gsub("/", escaped_sep)
+ return root .. "/" .. flat_name
+ end
+ -- nested style (default): preserve slashes
+ return root .. "/" .. branch
+end
+
+---Load global config from ~/.config/wt/config.lua
+---@return {branch_path_style?: string, flat_separator?: string, remotes?: table<string, string>, default_remotes?: string[]|string}
+local function load_global_config()
+ local home = os.getenv("HOME")
+ if not home then
+ return {}
+ end
+ local config_path = home .. "/.config/wt/config.lua"
+ local f = io.open(config_path, "r")
+ if not f then
+ return {}
+ end
+ local content = f:read("*a")
+ f:close()
+ local chunk, err = load(content, config_path, "t", {})
+ if not chunk then
+ chunk, err = load("return " .. content, config_path, "t", {})
+ end
+ if not chunk then
+ io.stderr:write("warning: config syntax error in " .. config_path .. ": " .. err .. "\n")
+ return {}
+ end
+ local ok, result = pcall(chunk)
+ if not ok then
+ io.stderr:write("warning: config execution error in " .. config_path .. ": " .. result .. "\n")
+ return {}
+ end
+ if type(result) ~= "table" then
+ io.stderr:write("warning: config must return a table in " .. config_path .. "\n")
+ return {}
+ end
+ return result
+end
+
+---Load project config from <root>/.wt.lua
+---@param root string
+---@return {hooks?: {copy?: string[], symlink?: string[], run?: string[]}}
+local function load_project_config(root)
+ local config_path = root .. "/.wt.lua"
+ local f = io.open(config_path, "r")
+ if not f then
+ return {}
+ end
+ local content = f:read("*a")
+ f:close()
+
+ local chunk, err = load(content, config_path, "t", {})
+ if not chunk then
+ chunk, err = load("return " .. content, config_path, "t", {})
+ end
+ if not chunk then
+ io.stderr:write("warning: config syntax error in " .. config_path .. ": " .. err .. "\n")
+ return {}
+ end
+ local ok, result = pcall(chunk)
+ if not ok then
+ io.stderr:write("warning: config execution error in " .. config_path .. ": " .. result .. "\n")
+ return {}
+ end
+ if type(result) ~= "table" then
+ io.stderr:write("warning: config must return a table in " .. config_path .. "\n")
+ return {}
+ end
+ return result
+end
+
+---Split path into components
+---@param path string
+---@return string[]
+local function split_path(path)
+ local parts = {}
+ for part in path:gmatch("[^/]+") do
+ table.insert(parts, part)
+ end
+ return parts
+end
+
+---Calculate relative path from one absolute path to another
+---@param from string absolute path of starting directory
+---@param to string absolute path of target
+---@return string relative path
+local function relative_path(from, to)
+ if from == to then
+ return "./"
+ end
+
+ local from_parts = split_path(from)
+ local to_parts = split_path(to)
+
+ local common = 0
+ for i = 1, math.min(#from_parts, #to_parts) do
+ if from_parts[i] == to_parts[i] then
+ common = i
+ else
+ break
+ end
+ end
+
+ local up_count = #from_parts - common
+ local result = {}
+
+ for _ = 1, up_count do
+ table.insert(result, "..")
+ end
+
+ for i = common + 1, #to_parts do
+ table.insert(result, to_parts[i])
+ end
+
+ if #result == 0 then
+ return "./"
+ end
+
+ return table.concat(result, "/")
+end
+
+---Check if cwd is inside a worktree (has .git file, not at project root)
+---@param root string
+---@return string|nil source_worktree path if inside worktree, nil if at project root
+local function detect_source_worktree(root)
+ local cwd = get_cwd()
+ if not cwd then
+ return nil
+ end
+ -- If cwd is the project root, no source worktree
+ if cwd == root then
+ return nil
+ end
+ -- Check if cwd has a .git file (indicating it's a worktree)
+ local git_file = io.open(cwd .. "/.git", "r")
+ if git_file then
+ git_file:close()
+ return cwd
+ end
+ -- Walk up to find worktree root
+ ---@type string|nil
+ local path = cwd
+ while path and path ~= "" and path ~= "/" and path ~= root do
+ local gf = io.open(path .. "/.git", "r")
+ if gf then
+ gf:close()
+ return path
+ end
+ path = path:match("(.+)/[^/]+$")
+ end
+ return nil
+end
+
+---Check if branch exists locally
+---@param git_dir string
+---@param branch string
+---@return boolean
+local function branch_exists_local(git_dir, branch)
+ return run_cmd_silent("GIT_DIR=" .. git_dir .. " git show-ref --verify --quiet refs/heads/" .. branch)
+end
+
+---Escape special Lua pattern characters in a string
+---@param str string
+---@return string
+local function escape_pattern(str)
+ return (str:gsub("([%%%-%+%[%]%(%)%.%^%$%*%?])", "%%%1"))
+end
+
+---Parse git branch -r output to extract remotes containing a branch
+---@param output string git branch -r output
+---@param branch string branch name to find
+---@return string[] remote names
+local function parse_branch_remotes(output, branch)
+ local remotes = {}
+ for line in output:gmatch("[^\n]+") do
+ -- Match: " origin/branch-name" or " upstream/feature/foo"
+ -- For branch "feature/foo", we want remote "origin", not "origin/feature"
+ -- The remote name is everything before the LAST occurrence of /branch
+ local trimmed = line:match("^%s*(.-)%s*$")
+ if trimmed then
+ -- Check if line ends with /branch
+ local suffix = "/" .. branch
+ if trimmed:sub(-#suffix) == suffix then
+ local remote = trimmed:sub(1, #trimmed - #suffix)
+ -- Simple remote name (no slashes) - this is what we want
+ -- Remote names with slashes (e.g., "forks/alice") are ambiguous
+ -- and skipped for safety
+ if remote ~= "" and not remote:match("/") then
+ table.insert(remotes, remote)
+ end
+ end
+ end
+ end
+ return remotes
+end
+
+---Find which remotes have the branch
+---@param git_dir string
+---@param branch string
+---@return string[] remote names
+local function find_branch_remotes(git_dir, branch)
+ local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git branch -r --list '*/" .. branch .. "'")
+ if code ~= 0 then
+ return {}
+ end
+ return parse_branch_remotes(output, branch)
+end
+
+---Load hook permissions from ~/.local/share/wt/hook-dirs.lua
+---@return table<string, boolean>
+local function load_hook_permissions()
+ local home = os.getenv("HOME")
+ if not home then
+ return {}
+ end
+ local path = home .. "/.local/share/wt/hook-dirs.lua"
+ local f = io.open(path, "r")
+ if not f then
+ return {}
+ end
+ local content = f:read("*a")
+ f:close()
+ local chunk = load("return " .. content, path, "t", {})
+ if not chunk then
+ return {}
+ end
+ local ok, result = pcall(chunk)
+ if ok and type(result) == "table" then
+ return result
+ end
+ return {}
+end
+
+---Save hook permissions to ~/.local/share/wt/hook-dirs.lua
+---@param perms table<string, boolean>
+local function save_hook_permissions(perms)
+ local home = os.getenv("HOME")
+ if not home then
+ return
+ end
+ local dir = home .. "/.local/share/wt"
+ run_cmd_silent("mkdir -p " .. dir)
+ local path = dir .. "/hook-dirs.lua"
+ local f = io.open(path, "w")
+ if not f then
+ return
+ end
+ f:write("{\n")
+ for k, v in pairs(perms) do
+ f:write('\t["' .. k .. '"] = ' .. tostring(v) .. ",\n")
+ end
+ f:write("}\n")
+ f:close()
+end
+
+---Summarize hooks for confirmation prompt
+---@param hooks {copy?: string[], symlink?: string[], run?: string[]}
+---@return string
+local function summarize_hooks(hooks)
+ local parts = {}
+ if hooks.copy and #hooks.copy > 0 then
+ local items = {}
+ for i = 1, math.min(3, #hooks.copy) do
+ table.insert(items, hooks.copy[i])
+ end
+ local suffix = #hooks.copy > 3 and " (+" .. (#hooks.copy - 3) .. " more)" or ""
+ table.insert(parts, "copy: " .. table.concat(items, ", ") .. suffix)
+ end
+ if hooks.symlink and #hooks.symlink > 0 then
+ local items = {}
+ for i = 1, math.min(3, #hooks.symlink) do
+ table.insert(items, hooks.symlink[i])
+ end
+ local suffix = #hooks.symlink > 3 and " (+" .. (#hooks.symlink - 3) .. " more)" or ""
+ table.insert(parts, "symlink: " .. table.concat(items, ", ") .. suffix)
+ end
+ if hooks.run and #hooks.run > 0 then
+ local items = {}
+ for i = 1, math.min(3, #hooks.run) do
+ table.insert(items, hooks.run[i])
+ end
+ local suffix = #hooks.run > 3 and " (+" .. (#hooks.run - 3) .. " more)" or ""
+ table.insert(parts, "run: " .. table.concat(items, ", ") .. suffix)
+ end
+ return table.concat(parts, "; ")
+end
+
+---Check if hooks are allowed for a project, prompting if unknown
+---@param root string project root path
+---@param hooks {copy?: string[], symlink?: string[], run?: string[]}
+---@return boolean allowed
+local function check_hook_permission(root, hooks)
+ local perms = load_hook_permissions()
+ if perms[root] ~= nil then
+ return perms[root]
+ end
+
+ -- Prompt user
+ local summary = summarize_hooks(hooks)
+ local prompt = "Allow hooks for " .. root .. "?\\n" .. summary
+ local allowed = run_cmd_silent("gum confirm " .. "'" .. prompt:gsub("'", "'\\''") .. "'")
+
+ perms[root] = allowed
+ save_hook_permissions(perms)
+ return allowed
+end
+
+---Run hooks from .wt.lua config
+---@param source string source worktree path
+---@param target string target worktree path
+---@param hooks {copy?: string[], symlink?: string[], run?: string[]}
+---@param root string project root path
+local function run_hooks(source, target, hooks, root)
+ -- Check permission before running any hooks
+ if not check_hook_permission(root, hooks) then
+ io.stderr:write("hooks skipped (not allowed for this project)\n")
+ return
+ end
+
+ if hooks.copy then
+ for _, item in ipairs(hooks.copy) do
+ local src = source .. "/" .. item
+ local dst = target .. "/" .. item
+ -- Create parent directory if needed
+ local parent = dst:match("(.+)/[^/]+$")
+ if parent then
+ run_cmd_silent("mkdir -p " .. parent)
+ end
+ local _, code = run_cmd("cp -r " .. src .. " " .. dst)
+ if code ~= 0 then
+ io.stderr:write("warning: failed to copy " .. item .. "\n")
+ end
+ end
+ end
+ if hooks.symlink then
+ for _, item in ipairs(hooks.symlink) do
+ local src = source .. "/" .. item
+ local dst = target .. "/" .. item
+ -- Create parent directory if needed
+ local parent = dst:match("(.+)/[^/]+$")
+ if parent then
+ run_cmd_silent("mkdir -p " .. parent)
+ end
+ local _, code = run_cmd("ln -s " .. src .. " " .. dst)
+ if code ~= 0 then
+ io.stderr:write("warning: failed to symlink " .. item .. "\n")
+ end
+ end
+ end
+ if hooks.run then
+ for _, cmd in ipairs(hooks.run) do
+ local _, code = run_cmd("cd " .. target .. " && " .. cmd)
+ if code ~= 0 then
+ io.stderr:write("warning: hook command failed: " .. cmd .. "\n")
+ end
+ end
+ end
+end
+
+---@param args string[]
+local function cmd_clone(args)
+ -- Parse arguments: <url> [--remote name]... [--own]
+ local url = nil
+ ---@type string[]
+ local remote_flags = {}
+ local own = false
+
+ local i = 1
+ while i <= #args do
+ local a = args[i]
+ if a == "--remote" then
+ if not args[i + 1] then
+ die("--remote requires a name")
+ end
+ table.insert(remote_flags, args[i + 1])
+ i = i + 1
+ elseif a == "--own" then
+ own = true
+ elseif not url then
+ url = a
+ else
+ die("unexpected argument: " .. a)
+ end
+ i = i + 1
+ end
+
+ if not url then
+ die("usage: wt c <url> [--remote name]... [--own]")
+ return
+ end
+
+ -- Extract project name from URL
+ local project_name = extract_project_name(url)
+ if not project_name then
+ die("could not extract project name from URL: " .. url)
+ return
+ end
+
+ -- Check if project directory already exists
+ local cwd = get_cwd()
+ if not cwd then
+ die("failed to get current directory", EXIT_SYSTEM_ERROR)
+ end
+ local project_path = cwd .. "/" .. project_name
+ local check = io.open(project_path, "r")
+ if check then
+ check:close()
+ die("directory already exists: " .. project_path)
+ end
+
+ -- Clone bare repo
+ local bare_path = project_path .. "/.bare"
+ local output, code = run_cmd("git clone --bare " .. url .. " " .. bare_path)
+ if code ~= 0 then
+ die("failed to clone: " .. output, EXIT_SYSTEM_ERROR)
+ end
+
+ -- Write .git file pointing to .bare
+ local git_file_handle = io.open(project_path .. "/.git", "w")
+ if not git_file_handle then
+ die("failed to create .git file", EXIT_SYSTEM_ERROR)
+ return
+ end
+ git_file_handle:write("gitdir: ./.bare\n")
+ git_file_handle:close()
+
+ -- Detect default branch
+ local git_dir = bare_path
+ local default_branch = detect_cloned_default_branch(git_dir)
+
+ -- Load global config
+ local global_config = load_global_config()
+
+ -- Determine which remotes to use
+ ---@type string[]
+ local selected_remotes = {}
+
+ if #remote_flags > 0 then
+ selected_remotes = remote_flags
+ elseif global_config.default_remotes then
+ if type(global_config.default_remotes) == "table" then
+ selected_remotes = global_config.default_remotes
+ elseif global_config.default_remotes == "prompt" then
+ if global_config.remotes then
+ local keys = {}
+ for k in pairs(global_config.remotes) do
+ table.insert(keys, k)
+ end
+ table.sort(keys)
+ if #keys > 0 then
+ local input = table.concat(keys, "\n")
+ local choose_type = own and "" or " --no-limit"
+ local cmd = "echo '" .. input .. "' | gum choose" .. choose_type
+ output, code = run_cmd(cmd)
+ if code == 0 and output ~= "" then
+ for line in output:gmatch("[^\n]+") do
+ table.insert(selected_remotes, line)
+ end
+ end
+ end
+ end
+ end
+ elseif global_config.remotes then
+ local keys = {}
+ for k in pairs(global_config.remotes) do
+ table.insert(keys, k)
+ end
+ table.sort(keys)
+ if #keys > 0 then
+ local input = table.concat(keys, "\n")
+ local choose_type = own and "" or " --no-limit"
+ local cmd = "echo '" .. input .. "' | gum choose" .. choose_type
+ output, code = run_cmd(cmd)
+ if code == 0 and output ~= "" then
+ for line in output:gmatch("[^\n]+") do
+ table.insert(selected_remotes, line)
+ end
+ end
+ end
+ end
+
+ -- Track configured remotes for summary
+ ---@type string[]
+ local configured_remotes = {}
+
+ if own then
+ -- User's own project: origin is their canonical remote
+ if #selected_remotes > 0 then
+ local first_remote = selected_remotes[1]
+ -- Rename origin to first remote
+ output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote rename origin " .. first_remote)
+ if code ~= 0 then
+ io.stderr:write("warning: failed to rename origin to " .. first_remote .. ": " .. output .. "\n")
+ else
+ -- Configure fetch refspec
+ run_cmd(
+ "GIT_DIR="
+ .. git_dir
+ .. " git config remote."
+ .. first_remote
+ .. ".fetch '+refs/heads/*:refs/remotes/"
+ .. first_remote
+ .. "/*'"
+ )
+ table.insert(configured_remotes, first_remote)
+ end
+
+ -- Add additional remotes and push to them
+ for j = 2, #selected_remotes do
+ local remote_name = selected_remotes[j]
+ local template = global_config.remotes and global_config.remotes[remote_name]
+ if template then
+ local remote_url = resolve_url_template(template, project_name)
+ output, code =
+ run_cmd("GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. remote_url)
+ if code ~= 0 then
+ io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
+ else
+ run_cmd(
+ "GIT_DIR="
+ .. git_dir
+ .. " git config remote."
+ .. remote_name
+ .. ".fetch '+refs/heads/*:refs/remotes/"
+ .. remote_name
+ .. "/*'"
+ )
+ -- Push to additional remotes
+ output, code =
+ run_cmd("GIT_DIR=" .. git_dir .. " git push " .. remote_name .. " " .. default_branch)
+ if code ~= 0 then
+ io.stderr:write("warning: failed to push to " .. remote_name .. ": " .. output .. "\n")
+ end
+ table.insert(configured_remotes, remote_name)
+ end
+ else
+ io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
+ end
+ end
+ else
+ -- No remotes selected, keep origin as-is
+ run_cmd("GIT_DIR=" .. git_dir .. " git config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'")
+ table.insert(configured_remotes, "origin")
+ end
+ else
+ -- Contributing to someone else's project
+ -- Rename origin to upstream
+ output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote rename origin upstream")
+ if code ~= 0 then
+ io.stderr:write("warning: failed to rename origin to upstream: " .. output .. "\n")
+ else
+ run_cmd(
+ "GIT_DIR=" .. git_dir .. " git config remote.upstream.fetch '+refs/heads/*:refs/remotes/upstream/*'"
+ )
+ table.insert(configured_remotes, "upstream")
+ end
+
+ -- Add user's remotes and push to each
+ for _, remote_name in ipairs(selected_remotes) do
+ local template = global_config.remotes and global_config.remotes[remote_name]
+ if template then
+ local remote_url = resolve_url_template(template, project_name)
+ output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. remote_url)
+ if code ~= 0 then
+ io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
+ else
+ run_cmd(
+ "GIT_DIR="
+ .. git_dir
+ .. " git config remote."
+ .. remote_name
+ .. ".fetch '+refs/heads/*:refs/remotes/"
+ .. remote_name
+ .. "/*'"
+ )
+ -- Push to this remote
+ output, code =
+ run_cmd("GIT_DIR=" .. git_dir .. " git push " .. remote_name .. " " .. default_branch)
+ if code ~= 0 then
+ io.stderr:write("warning: failed to push to " .. remote_name .. ": " .. output .. "\n")
+ end
+ table.insert(configured_remotes, remote_name)
+ end
+ else
+ io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
+ end
+ end
+ end
+
+ -- Fetch all remotes
+ run_cmd("GIT_DIR=" .. git_dir .. " git fetch --all")
+
+ -- Load config for path style
+ local style = global_config.branch_path_style or "nested"
+ local separator = global_config.flat_separator
+ local worktree_path = branch_to_path(project_path, default_branch, style, separator)
+
+ -- Create initial worktree
+ output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -- " .. worktree_path .. " " .. default_branch)
+ if code ~= 0 then
+ die("failed to create worktree: " .. output, EXIT_SYSTEM_ERROR)
+ end
+
+ -- Print summary
+ print("Created project: " .. project_path)
+ print("Default branch: " .. default_branch)
+ print("Worktree: " .. worktree_path)
+ if #configured_remotes > 0 then
+ print("Remotes: " .. table.concat(configured_remotes, ", "))
+ end
+end
+
+---@param args string[]
+local function cmd_new(args)
+ -- Parse arguments: <project-name> [--remote name]...
+ local project_name = nil
+ ---@type string[]
+ local remote_flags = {}
+
+ local i = 1
+ while i <= #args do
+ local a = args[i]
+ if a == "--remote" then
+ if not args[i + 1] then
+ die("--remote requires a name")
+ end
+ table.insert(remote_flags, args[i + 1])
+ i = i + 1
+ elseif not project_name then
+ project_name = a
+ else
+ die("unexpected argument: " .. a)
+ end
+ i = i + 1
+ end
+
+ if not project_name then
+ die("usage: wt n <project-name> [--remote name]...")
+ return
+ end
+
+ -- Check if project directory already exists
+ local cwd = get_cwd()
+ if not cwd then
+ die("failed to get current directory", EXIT_SYSTEM_ERROR)
+ end
+ local project_path = cwd .. "/" .. project_name
+ local check = io.open(project_path, "r")
+ if check then
+ check:close()
+ die("directory already exists: " .. project_path)
+ end
+
+ -- Load global config
+ local global_config = load_global_config()
+
+ -- Determine which remotes to use
+ ---@type string[]
+ local selected_remotes = {}
+
+ if #remote_flags > 0 then
+ -- Use explicitly provided remotes
+ selected_remotes = remote_flags
+ elseif global_config.default_remotes then
+ if type(global_config.default_remotes) == "table" then
+ selected_remotes = global_config.default_remotes
+ elseif global_config.default_remotes == "prompt" then
+ -- Prompt with gum choose
+ if global_config.remotes then
+ local keys = {}
+ for k in pairs(global_config.remotes) do
+ table.insert(keys, k)
+ end
+ table.sort(keys)
+ if #keys > 0 then
+ local input = table.concat(keys, "\n")
+ local cmd = "echo '" .. input .. "' | gum choose --no-limit"
+ local output, code = run_cmd(cmd)
+ if code == 0 and output ~= "" then
+ for line in output:gmatch("[^\n]+") do
+ table.insert(selected_remotes, line)
+ end
+ end
+ end
+ end
+ end
+ elseif global_config.remotes then
+ -- No default_remotes configured, prompt if remotes exist
+ local keys = {}
+ for k in pairs(global_config.remotes) do
+ table.insert(keys, k)
+ end
+ table.sort(keys)
+ if #keys > 0 then
+ local input = table.concat(keys, "\n")
+ local cmd = "echo '" .. input .. "' | gum choose --no-limit"
+ local output, code = run_cmd(cmd)
+ if code == 0 and output ~= "" then
+ for line in output:gmatch("[^\n]+") do
+ table.insert(selected_remotes, line)
+ end
+ end
+ end
+ end
+
+ -- Create project structure
+ local bare_path = project_path .. "/.bare"
+ local output, code = run_cmd("mkdir -p " .. bare_path)
+ if code ~= 0 then
+ die("failed to create directory: " .. output, EXIT_SYSTEM_ERROR)
+ end
+
+ output, code = run_cmd("git init --bare " .. bare_path)
+ if code ~= 0 then
+ die("failed to init bare repo: " .. output, EXIT_SYSTEM_ERROR)
+ end
+
+ -- Write .git file pointing to .bare
+ local git_file_handle = io.open(project_path .. "/.git", "w")
+ if not git_file_handle then
+ die("failed to create .git file", EXIT_SYSTEM_ERROR)
+ return
+ end
+ git_file_handle:write("gitdir: ./.bare\n")
+ git_file_handle:close()
+
+ -- Add remotes
+ local git_dir = bare_path
+ for _, remote_name in ipairs(selected_remotes) do
+ local template = global_config.remotes and global_config.remotes[remote_name]
+ if template then
+ local url = resolve_url_template(template, project_name)
+ output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. url)
+ if code ~= 0 then
+ io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
+ else
+ -- Configure fetch refspec for the remote
+ run_cmd(
+ "GIT_DIR="
+ .. git_dir
+ .. " git config remote."
+ .. remote_name
+ .. ".fetch '+refs/heads/*:refs/remotes/"
+ .. remote_name
+ .. "/*'"
+ )
+ end
+ else
+ io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
+ end
+ end
+
+ -- Detect default branch
+ local default_branch = get_default_branch()
+
+ -- Load config for path style
+ local style = global_config.branch_path_style or "nested"
+ local separator = global_config.flat_separator
+ local worktree_path = branch_to_path(project_path, default_branch, style, separator)
+
+ -- Create orphan worktree
+ output, code =
+ run_cmd("GIT_DIR=" .. git_dir .. " git worktree add --orphan -b " .. default_branch .. " -- " .. worktree_path)
+ if code ~= 0 then
+ die("failed to create worktree: " .. output, EXIT_SYSTEM_ERROR)
+ end
+
+ -- Print summary
+ print("Created project: " .. project_path)
+ print("Default branch: " .. default_branch)
+ print("Worktree: " .. worktree_path)
+ if #selected_remotes > 0 then
+ print("Remotes: " .. table.concat(selected_remotes, ", "))
+ end
+end
+
+---@param args string[]
+local function cmd_add(args)
+ -- Parse arguments: <branch> [-b [<start-point>]]
+ ---@type string|nil
+ local branch = nil
+ local create_branch = false
+ ---@type string|nil
+ local start_point = nil
+
+ local i = 1
+ while i <= #args do
+ local a = args[i]
+ if a == "-b" then
+ create_branch = true
+ -- Check if next arg is start-point (not another flag)
+ if args[i + 1] and not args[i + 1]:match("^%-") then
+ start_point = args[i + 1]
+ i = i + 1
+ end
+ elseif not branch then
+ branch = a
+ else
+ die("unexpected argument: " .. a)
+ end
+ i = i + 1
+ end
+
+ if not branch then
+ die("usage: wt a <branch> [-b [<start-point>]]")
+ return
+ end
+
+ local root, err = find_project_root()
+ if not root then
+ die(err --[[@as string]])
+ return
+ end
+
+ local git_dir = root .. "/.bare"
+ local source_worktree = detect_source_worktree(root)
+
+ -- Load 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 or "_"
+
+ local target_path = branch_to_path(root, branch, style, separator)
+
+ -- Check if target already exists
+ local check = io.open(target_path .. "/.git", "r")
+ if check then
+ check:close()
+ die("worktree already exists at " .. target_path)
+ end
+
+ local output, code
+ if create_branch then
+ -- Create new branch with worktree
+ if start_point then
+ output, code = run_cmd(
+ "GIT_DIR="
+ .. git_dir
+ .. " git worktree add -b "
+ .. branch
+ .. " -- "
+ .. target_path
+ .. " "
+ .. start_point
+ )
+ else
+ output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -b " .. branch .. " -- " .. target_path)
+ end
+ else
+ -- Check if branch exists locally or on remotes
+ local exists_local = branch_exists_local(git_dir, branch)
+ local remotes = find_branch_remotes(git_dir, branch)
+
+ if not exists_local and #remotes == 0 then
+ die("branch '" .. branch .. "' not found locally or on any remote (use -b to create)")
+ end
+
+ if #remotes > 1 then
+ die("branch '" .. branch .. "' exists on multiple remotes: " .. table.concat(remotes, ", "))
+ end
+
+ output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -- " .. target_path .. " " .. branch)
+ end
+
+ if code ~= 0 then
+ die("failed to add worktree: " .. output, EXIT_SYSTEM_ERROR)
+ end
+
+ -- Run hooks if we have a source worktree
+ local project_config = load_project_config(root)
+ if source_worktree then
+ if project_config.hooks then
+ run_hooks(source_worktree, target_path, project_config.hooks, root)
+ end
+ elseif project_config.hooks then
+ io.stderr:write("warning: hooks skipped (run from inside a worktree to apply hooks)\n")
+ end
+
+ print(target_path)
+end
+
+---Check if path_a is inside (or equal to) path_b
+---@param path_a string the path to check
+---@param path_b string the container path
+---@return boolean
+local function path_inside(path_a, path_b)
+ -- Normalize: ensure no trailing slash for comparison
+ path_b = path_b:gsub("/$", "")
+ path_a = path_a:gsub("/$", "")
+ return path_a == path_b or path_a:sub(1, #path_b + 1) == path_b .. "/"
+end
+
+---Check if cwd is inside (or equal to) a given path
+---@param target string
+---@return boolean
+local function cwd_inside_path(target)
+ local cwd = get_cwd()
+ if not cwd then
+ return false
+ end
+ return path_inside(cwd, target)
+end
+
+---Get the bare repo's HEAD branch
+---@param git_dir string
+---@return string|nil branch name, nil on error
+local function get_bare_head(git_dir)
+ local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git symbolic-ref --short HEAD")
+ if code ~= 0 then
+ return nil
+ end
+ return (output:gsub("%s+$", ""))
+end
+
+---Parse git worktree list --porcelain output
+---@param output string git worktree list --porcelain output
+---@return table[] array of {path: string, branch?: string, bare?: boolean, detached?: boolean}
+local function parse_worktree_list(output)
+ local worktrees = {}
+ local current = nil
+ for line in output:gmatch("[^\n]+") do
+ local key, value = line:match("^(%S+)%s*(.*)$")
+ if key == "worktree" then
+ if current then
+ table.insert(worktrees, current)
+ end
+ current = { path = value }
+ elseif current then
+ if key == "branch" and value then
+ current.branch = value:gsub("^refs/heads/", "")
+ elseif key == "bare" then
+ current.bare = true
+ elseif key == "detached" then
+ current.detached = true
+ elseif key == "HEAD" then
+ current.head = value
+ end
+ end
+ end
+ if current then
+ table.insert(worktrees, current)
+ end
+ return worktrees
+end
+
+---Check if branch is checked out in any worktree
+---@param git_dir string
+---@param branch string
+---@return string|nil path if checked out, nil otherwise
+local function branch_checked_out_at(git_dir, branch)
+ local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain")
+ if code ~= 0 then
+ return nil
+ end
+ local worktrees = parse_worktree_list(output)
+ for _, wt in ipairs(worktrees) do
+ if wt.branch == branch then
+ return wt.path
+ end
+ end
+ return nil
+end
+
+---@param args string[]
+local function cmd_remove(args)
+ -- Parse arguments: <branch> [-b] [-f]
+ local branch = nil
+ local delete_branch = false
+ local force = false
+
+ for _, a in ipairs(args) do
+ if a == "-b" then
+ delete_branch = true
+ elseif a == "-f" then
+ force = true
+ elseif not branch then
+ branch = a
+ else
+ die("unexpected argument: " .. a)
+ end
+ end
+
+ if not branch then
+ die("usage: wt r <branch> [-b] [-f]")
+ return
+ end
+
+ local root, err = find_project_root()
+ if not root then
+ die(err --[[@as string]])
+ return
+ end
+
+ local git_dir = root .. "/.bare"
+
+ -- Find worktree by querying git for actual location (not computed from config)
+ local wt_output, wt_code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain")
+ if wt_code ~= 0 then
+ die("failed to list worktrees", EXIT_SYSTEM_ERROR)
+ return
+ end
+
+ local worktrees = parse_worktree_list(wt_output)
+ local target_path = nil
+ for _, wt in ipairs(worktrees) do
+ if wt.branch == branch then
+ target_path = wt.path
+ break
+ end
+ end
+
+ if not target_path then
+ die("no worktree found for branch '" .. branch .. "'")
+ return
+ end
+
+ -- Error if cwd is inside the worktree
+ if cwd_inside_path(target_path) then
+ die("cannot remove worktree while inside it")
+ end
+
+ -- Check for uncommitted changes
+ if not force then
+ local status_out = run_cmd("git -C " .. target_path .. " status --porcelain")
+ if status_out ~= "" then
+ die("worktree has uncommitted changes (use -f to force)")
+ end
+ end
+
+ -- Remove worktree
+ local remove_cmd = "GIT_DIR=" .. git_dir .. " git worktree remove"
+ if force then
+ remove_cmd = remove_cmd .. " --force"
+ end
+ remove_cmd = remove_cmd .. " -- " .. target_path
+
+ local output, code = run_cmd(remove_cmd)
+ if code ~= 0 then
+ die("failed to remove worktree: " .. output, EXIT_SYSTEM_ERROR)
+ end
+
+ -- Delete branch if requested
+ if delete_branch then
+ -- Check if branch is bare repo's HEAD
+ local bare_head = get_bare_head(git_dir)
+ if bare_head and bare_head == branch then
+ io.stderr:write("warning: cannot delete branch '" .. branch .. "' (it's the bare repo's HEAD)\n")
+ print("Worktree removed; branch retained")
+ return
+ end
+
+ -- Check if branch is checked out elsewhere
+ local checked_out = branch_checked_out_at(git_dir, branch)
+ if checked_out then
+ die("cannot delete branch '" .. branch .. "': checked out at " .. checked_out)
+ end
+
+ -- Delete branch
+ local delete_flag = force and "-D" or "-d"
+ local del_output, del_code = run_cmd("GIT_DIR=" .. git_dir .. " git branch " .. delete_flag .. " " .. branch)
+ if del_code ~= 0 then
+ io.stderr:write("warning: failed to delete branch: " .. del_output .. "\n")
+ print("Worktree removed; branch retained")
+ return
+ end
+
+ print("Worktree and branch '" .. branch .. "' removed")
+ else
+ print("Worktree removed")
+ end
+end
+
+local function cmd_list()
+ local root, err = find_project_root()
+ if not root then
+ die(err --[[@as string]])
+ return
+ end
+
+ local git_dir = root .. "/.bare"
+ local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain")
+ if code ~= 0 then
+ die("failed to list worktrees: " .. output, EXIT_SYSTEM_ERROR)
+ end
+
+ -- Parse porcelain output into worktree entries
+ ---@type {path: string, head: string, branch: string}[]
+ local worktrees = {}
+ local current = {}
+
+ for line in output:gmatch("[^\n]+") do
+ local key, value = line:match("^(%S+)%s*(.*)$")
+ if key == "worktree" and value then
+ if current.path then
+ table.insert(worktrees, current)
+ end
+ -- Skip .bare directory
+ if value:match("/%.bare$") then
+ current = {}
+ else
+ current = { path = value, head = "", branch = "(detached)" }
+ end
+ elseif key == "HEAD" and value then
+ current.head = value:sub(1, 7)
+ elseif key == "branch" and value then
+ current.branch = value:gsub("^refs/heads/", "")
+ elseif key == "bare" then
+ -- Skip bare repo entry
+ current = {}
+ end
+ end
+ if current.path then
+ table.insert(worktrees, current)
+ end
+
+ if #worktrees == 0 then
+ print("No worktrees found")
+ return
+ end
+
+ -- Get current working directory
+ local cwd = get_cwd() or ""
+
+ -- Build table rows with status
+ local rows = {}
+ for _, wt in ipairs(worktrees) do
+ local rel_path = relative_path(cwd, wt.path)
+
+ -- Check dirty status
+ local status_out = run_cmd("git -C " .. wt.path .. " status --porcelain")
+ local status = status_out == "" and "clean" or "dirty"
+
+ table.insert(rows, rel_path .. "," .. wt.branch .. "," .. wt.head .. "," .. status)
+ end
+
+ -- Output via gum table
+ local table_input = "Path,Branch,HEAD,Status\n" .. table.concat(rows, "\n")
+ table_input = table_input:gsub("EOF", "eof")
+ local table_cmd = "gum table --print <<'EOF'\n" .. table_input .. "\nEOF"
+ local table_handle = io.popen(table_cmd, "r")
+ if not table_handle then
+ return
+ end
+ io.write(table_handle:read("*a") or "")
+ table_handle:close()
+end
+
+local function cmd_fetch()
+ local root, err = find_project_root()
+ if not root then
+ die(err --[[@as string]])
+ return
+ end
+
+ local git_dir = root .. "/.bare"
+ local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git fetch --all --prune")
+ io.write(output)
+ if code ~= 0 then
+ os.exit(EXIT_SYSTEM_ERROR)
+ end
+end
+
+---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 <url>' 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
+
+-- Main entry point
+
+local function main()
+ local command = arg[1]
+
+ if not command or command == "help" or command == "--help" or command == "-h" then
+ print_usage()
+ os.exit(EXIT_SUCCESS)
+ end
+
+ -- Collect remaining args
+ local subargs = {}
+ for i = 2, #arg do
+ table.insert(subargs, arg[i])
+ end
+
+ -- Check for --help on any command
+ if subargs[1] == "--help" or subargs[1] == "-h" then
+ show_command_help(command)
+ end
+
+ if command == "c" then
+ cmd_clone(subargs)
+ elseif command == "n" then
+ cmd_new(subargs)
+ elseif command == "a" then
+ cmd_add(subargs)
+ elseif command == "r" then
+ cmd_remove(subargs)
+ elseif command == "l" then
+ cmd_list()
+ elseif command == "f" then
+ cmd_fetch()
+ elseif command == "init" then
+ cmd_init(subargs)
+ else
+ die("unknown command: " .. command)
+ end
+end
+
+-- Export for testing when required as module
+if pcall(debug.getlocal, 4, 1) then
+ return {
+ -- URL/project parsing
+ extract_project_name = extract_project_name,
+ resolve_url_template = resolve_url_template,
+ -- Path manipulation
+ branch_to_path = branch_to_path,
+ split_path = split_path,
+ relative_path = relative_path,
+ path_inside = path_inside,
+ -- Config loading
+ load_global_config = load_global_config,
+ load_project_config = load_project_config,
+ -- Git output parsing (testable without git)
+ parse_branch_remotes = parse_branch_remotes,
+ parse_worktree_list = parse_worktree_list,
+ escape_pattern = escape_pattern,
+ -- Hook helpers
+ summarize_hooks = summarize_hooks,
+ load_hook_permissions = function(home_override)
+ local home = home_override or os.getenv("HOME")
+ if not home then
+ return {}
+ end
+ local path = home .. "/.local/share/wt/hook-dirs.lua"
+ local f = io.open(path, "r")
+ if not f then
+ return {}
+ end
+ local content = f:read("*a")
+ f:close()
+ local chunk = load("return " .. content, path, "t", {})
+ if not chunk then
+ return {}
+ end
+ local ok, result = pcall(chunk)
+ if ok and type(result) == "table" then
+ return result
+ end
+ return {}
+ end,
+ save_hook_permissions = function(perms, home_override)
+ local home = home_override or os.getenv("HOME")
+ if not home then
+ return
+ end
+ local dir = home .. "/.local/share/wt"
+ run_cmd_silent("mkdir -p " .. dir)
+ local path = dir .. "/hook-dirs.lua"
+ local f = io.open(path, "w")
+ if not f then
+ return
+ end
+ f:write("{\n")
+ for k, v in pairs(perms) do
+ f:write('\t["' .. k .. '"] = ' .. tostring(v) .. ",\n")
+ end
+ f:write("}\n")
+ f:close()
+ end,
+ run_hooks = function(source, target, hooks, root, home_override)
+ local home = home_override or os.getenv("HOME")
+ if not home then
+ return
+ end
+ local perm_path = home .. "/.local/share/wt/hook-dirs.lua"
+ local perms = {}
+ local pf = io.open(perm_path, "r")
+ if pf then
+ local content = pf:read("*a")
+ pf:close()
+ local chunk = load("return " .. content, perm_path, "t", {})
+ if chunk then
+ local ok, result = pcall(chunk)
+ if ok and type(result) == "table" then
+ perms = result
+ end
+ end
+ end
+ if perms[root] == false then
+ io.stderr:write("hooks skipped (not allowed for this project)\n")
+ return
+ end
+ if hooks.copy then
+ for _, item in ipairs(hooks.copy) do
+ local src = source .. "/" .. item
+ local dst = target .. "/" .. item
+ local parent = dst:match("(.+)/[^/]+$")
+ if parent then
+ run_cmd_silent("mkdir -p " .. parent)
+ end
+ run_cmd("cp -r " .. src .. " " .. dst)
+ end
+ end
+ if hooks.symlink then
+ for _, item in ipairs(hooks.symlink) do
+ local src = source .. "/" .. item
+ local dst = target .. "/" .. item
+ local parent = dst:match("(.+)/[^/]+$")
+ if parent then
+ run_cmd_silent("mkdir -p " .. parent)
+ end
+ run_cmd("ln -s " .. src .. " " .. dst)
+ end
+ end
+ if hooks.run then
+ for _, cmd in ipairs(hooks.run) do
+ run_cmd("cd " .. target .. " && " .. cmd)
+ end
+ end
+ end,
+ -- Project root detection
+ find_project_root = function(cwd_override)
+ local cwd = cwd_override or get_cwd()
+ if not cwd then
+ return nil, "failed to get current directory"
+ end
+ local path = cwd
+ while path and path ~= "" and path ~= "/" do
+ local bare_check = io.open(path .. "/.bare/HEAD", "r")
+ if bare_check then
+ bare_check:close()
+ return path, nil
+ end
+ local git_file = io.open(path .. "/.git", "r")
+ if git_file then
+ local content = git_file:read("*a")
+ git_file:close()
+ if content and content:match("gitdir:%s*%.?/?%.bare") then
+ return path, nil
+ end
+ end
+ path = path:match("(.+)/[^/]+$")
+ end
+ return nil, "not in a wt-managed repository"
+ end,
+ detect_source_worktree = function(root, cwd_override)
+ local cwd = cwd_override or get_cwd()
+ if not cwd then
+ return nil
+ end
+ if cwd == root then
+ return nil
+ end
+ local git_file = io.open(cwd .. "/.git", "r")
+ if git_file then
+ git_file:close()
+ return cwd
+ end
+ local path = cwd
+ while path and path ~= "" and path ~= "/" and path ~= root do
+ local gf = io.open(path .. "/.git", "r")
+ if gf then
+ gf:close()
+ return path
+ end
+ path = path:match("(.+)/[^/]+$")
+ end
+ return nil
+ end,
+ -- Command execution (for integration tests)
+ run_cmd = run_cmd,
+ run_cmd_silent = run_cmd_silent,
+ -- Exit codes
+ EXIT_SUCCESS = EXIT_SUCCESS,
+ EXIT_USER_ERROR = EXIT_USER_ERROR,
+ EXIT_SYSTEM_ERROR = EXIT_SYSTEM_ERROR,
+ }
+end
+
+main()