From 32b18843fa6192b008edcd435b5129f1ebd67f0f Mon Sep 17 00:00:00 2001 From: Amolith Date: Sun, 18 Jan 2026 08:17:11 -0700 Subject: [PATCH] build: add bundle script and dist target 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 --- Makefile | 13 +- README.md | 2 +- dist/wt | 1973 ++++++++++++++++++++++++++++++++++++ scripts/bundle.lua | 179 ++++ scripts/bundle.lua.license | 3 + 5 files changed, 2168 insertions(+), 2 deletions(-) create mode 100755 dist/wt create mode 100755 scripts/bundle.lua create mode 100644 scripts/bundle.lua.license diff --git a/Makefile b/Makefile index 7e8dd3e293d05eb3a0a29b4f812a593765fee206..99fcbef53ec97d3a016ca64ce48527f1b1dafb76 100644 --- a/Makefile +++ b/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 diff --git a/README.md b/README.md index 7e929c7e63bc99a755ebb5e294f2d880fb7fe41c..1bbb87c0e642d783ac2b52342659b43d4901e8f2 100644 --- a/README.md +++ b/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 ``` diff --git a/dist/wt b/dist/wt new file mode 100755 index 0000000000000000000000000000000000000000..4b82627d9265b50c402e5e4383d816aa6d88b3c8 --- /dev/null +++ b/dist/wt @@ -0,0 +1,1973 @@ +#!/usr/bin/env lua + +-- SPDX-FileCopyrightText: Amolith +-- +-- 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 [options]") + print("") + print("Commands:") + print(" c [--remote name]... [--own] Clone into bare worktree structure") + print(" n [--remote name]... Initialize fresh project") + print(" a [-b []] Add worktree with optional hooks") + print(" r [-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 [--remote name]... [--own]", + "", + "Clone a repository into bare worktree structure.", + "", + "Arguments:", + " Git URL to clone", + "", + "Options:", + " --remote 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 [--remote name]...", + "", + "Initialize a fresh project with bare worktree structure.", + "", + "Arguments:", + " Name of the new project directory", + "", + "Options:", + " --remote 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 [-b []]", + "", + "Add a worktree for a branch.", + "", + "Arguments:", + " Branch name to checkout or create", + "", + "Options:", + " -b Create a new branch", + " 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 [-b] [-f]", + "", + "Remove a worktree.", + "", + "Arguments:", + " 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, 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 /.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 +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 +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: [--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 [--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: [--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 [--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: [-b []] + ---@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 [-b []]") + 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: [-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 [-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 ' 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() diff --git a/scripts/bundle.lua b/scripts/bundle.lua new file mode 100755 index 0000000000000000000000000000000000000000..330ae7c575c068fd6cd9fe47594f7a805d933c51 --- /dev/null +++ b/scripts/bundle.lua @@ -0,0 +1,179 @@ +#!/usr/bin/env lua + +-- SPDX-FileCopyrightText: Amolith +-- +-- 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 +-- +-- 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() diff --git a/scripts/bundle.lua.license b/scripts/bundle.lua.license new file mode 100644 index 0000000000000000000000000000000000000000..8aa787a4e1b6a21ad5bdf6f110199e60113c81e9 --- /dev/null +++ b/scripts/bundle.lua.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: Amolith + +SPDX-License-Identifier: GPL-3.0-or-later