build: add bundle script and dist target

Amolith created

Adds infrastructure for splitting src/main.lua into modules while
preserving the single-file curl installation story.

- scripts/bundle.lua concatenates modules into dist/wt
- Makefile gains 'dist' and 'clean' targets
- README updated to curl from dist/wt

Assisted-by: Claude Opus 4.5 via Amp

Change summary

Makefile                   |   13 
README.md                  |    2 
dist/wt                    | 1973 ++++++++++++++++++++++++++++++++++++++++
scripts/bundle.lua         |  179 +++
scripts/bundle.lua.license |    3 
5 files changed, 2,168 insertions(+), 2 deletions(-)

Detailed changes

Makefile 🔗

@@ -2,10 +2,21 @@
 #
 # SPDX-License-Identifier: CC0-1.0
 
-.PHONY: all fmt fmt-check lint check test ci
+.PHONY: all fmt fmt-check lint check test ci dist clean
 
 all: fmt lint check test
 
+# Bundle all modules into single distributable file
+dist: dist/wt
+
+dist/wt: src/main.lua $(wildcard src/wt/*.lua) $(wildcard src/wt/cmd/*.lua) scripts/bundle.lua
+	@mkdir -p dist
+	lua scripts/bundle.lua > dist/wt
+	chmod +x dist/wt
+
+clean:
+	rm -rf dist
+
 fmt:
 	lx fmt
 

README.md 🔗

@@ -42,7 +42,7 @@ Requires Lua 5.2 or later and
 [gum](https://github.com/charmbracelet/gum) for interactive prompts
 
 ```
-curl -o ~/.local/bin/wt https://git.secluded.site/wt/blob/main/src/main.lua?raw=1
+curl -o ~/.local/bin/wt https://git.secluded.site/wt/blob/main/dist/wt?raw=1
 chmod +x ~/.local/bin/wt
 ```
 

dist/wt 🔗

@@ -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()

scripts/bundle.lua 🔗

@@ -0,0 +1,179 @@
+#!/usr/bin/env lua
+
+-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+--
+-- SPDX-License-Identifier: GPL-3.0-or-later
+
+--[[
+Bundle script for wt - concatenates Lua modules into a single distributable file.
+
+The bundled output embeds each module's source and provides a custom require()
+that loads from the embedded sources first, falling back to the real require()
+for standard library modules.
+
+Usage: lua scripts/bundle.lua > wt
+]]
+
+--- Read entire file contents
+---@param path string
+---@return string|nil content
+---@return string|nil error
+local function read_file(path)
+	local f, err = io.open(path, "r")
+	if not f then
+		return nil, err
+	end
+	local content = f:read("*a")
+	f:close()
+	return content
+end
+
+--- Check if file exists
+---@param path string
+---@return boolean
+local function file_exists(path)
+	local f = io.open(path, "r")
+	if f then
+		f:close()
+		return true
+	end
+	return false
+end
+
+--- Escape string for embedding in Lua long string
+---@param s string
+---@return string
+local function escape_for_longstring(s)
+	local level = 0
+	while s:find("%]" .. string.rep("=", level) .. "%]", 1, true) do
+		level = level + 1
+	end
+	local open = "[" .. string.rep("=", level) .. "["
+	local close = "]" .. string.rep("=", level) .. "]"
+	return open, close, s
+end
+
+-- Module loading order matters: dependencies must come before dependents
+-- This will be populated as modules are extracted
+local MODULE_ORDER = {
+	-- Core utilities (no internal dependencies)
+	"wt.exit",    -- exit codes
+	"wt.shell",   -- run_cmd, run_cmd_silent
+	"wt.path",    -- path manipulation utilities
+	"wt.git",     -- git utilities
+	"wt.config",  -- config loading
+	"wt.hooks",   -- hook system
+	"wt.help",    -- help text
+	-- Commands (depend on utilities)
+	"wt.cmd.clone",
+	"wt.cmd.new",
+	"wt.cmd.add",
+	"wt.cmd.remove",
+	"wt.cmd.list",
+	"wt.cmd.fetch",
+	"wt.cmd.init",
+}
+
+-- Map module names to file paths
+local MODULE_PATHS = {
+	["wt.exit"]       = "src/wt/exit.lua",
+	["wt.shell"]      = "src/wt/shell.lua",
+	["wt.path"]       = "src/wt/path.lua",
+	["wt.git"]        = "src/wt/git.lua",
+	["wt.config"]     = "src/wt/config.lua",
+	["wt.hooks"]      = "src/wt/hooks.lua",
+	["wt.help"]       = "src/wt/help.lua",
+	["wt.cmd.clone"]  = "src/wt/cmd/clone.lua",
+	["wt.cmd.new"]    = "src/wt/cmd/new.lua",
+	["wt.cmd.add"]    = "src/wt/cmd/add.lua",
+	["wt.cmd.remove"] = "src/wt/cmd/remove.lua",
+	["wt.cmd.list"]   = "src/wt/cmd/list.lua",
+	["wt.cmd.fetch"]  = "src/wt/cmd/fetch.lua",
+	["wt.cmd.init"]   = "src/wt/cmd/init.lua",
+}
+
+local function main()
+	-- Collect all modules that exist
+	local modules = {}
+	for _, mod_name in ipairs(MODULE_ORDER) do
+		local path = MODULE_PATHS[mod_name]
+		if path and file_exists(path) then
+			local content, err = read_file(path)
+			if content then
+				modules[mod_name] = content
+			else
+				io.stderr:write("warning: failed to read " .. path .. ": " .. (err or "unknown error") .. "\n")
+			end
+		end
+	end
+
+	-- Read main entry point
+	local main_content, err = read_file("src/main.lua")
+	if not main_content then
+		io.stderr:write("error: failed to read src/main.lua: " .. (err or "unknown error") .. "\n")
+		os.exit(1)
+	end
+
+	-- Start output
+	io.write([[#!/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 we have modules to embed, set up the custom require system
+	local has_modules = next(modules) ~= nil
+
+	if has_modules then
+		io.write([[-- Embedded module loader
+local _EMBEDDED_MODULES = {}
+local _LOADED_MODULES = {}
+local _real_require = require
+
+local function _embedded_require(name)
+	if _LOADED_MODULES[name] then
+		return _LOADED_MODULES[name]
+	end
+	if _EMBEDDED_MODULES[name] then
+		local loader = load(_EMBEDDED_MODULES[name], name)
+		if loader then
+			local result = loader()
+			_LOADED_MODULES[name] = result or true
+			return _LOADED_MODULES[name]
+		end
+	end
+	return _real_require(name)
+end
+require = _embedded_require
+
+]])
+
+		-- Embed each module
+		for _, mod_name in ipairs(MODULE_ORDER) do
+			local content = modules[mod_name]
+			if content then
+				local open, close, escaped = escape_for_longstring(content)
+				io.write("_EMBEDDED_MODULES[\"" .. mod_name .. "\"] = " .. open .. escaped .. close .. "\n\n")
+			end
+		end
+	end
+
+	-- Write main content (strip shebang if present, we already wrote one)
+	if main_content:sub(1, 2) == "#!" then
+		main_content = main_content:gsub("^#![^\n]*\n", "")
+	end
+
+	-- Strip SPDX header if present (we already wrote one)
+	main_content = main_content:gsub("^%s*%-%-[^\n]*SPDX%-FileCopyrightText[^\n]*\n", "")
+	main_content = main_content:gsub("^%s*%-%-[^\n]*\n", "") -- blank comment
+	main_content = main_content:gsub("^%s*%-%-[^\n]*SPDX%-License%-Identifier[^\n]*\n", "")
+
+	io.write(main_content)
+end
+
+main()

scripts/bundle.lua.license 🔗

@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+
+SPDX-License-Identifier: GPL-3.0-or-later