From d76c1dd8400b69fb06693dc49495aba9a502cbed Mon Sep 17 00:00:00 2001 From: Amolith Date: Sun, 18 Jan 2026 09:20:00 -0700 Subject: [PATCH] refactor(wt): extract cmd.clone module Assisted-by: Claude Opus 4.5 via Amp --- dist/wt | 1408 ++++++++++++++++++++++-------------------- src/main.lua | 254 +------- src/wt/cmd/clone.lua | 273 ++++++++ 3 files changed, 1002 insertions(+), 933 deletions(-) create mode 100644 src/wt/cmd/clone.lua diff --git a/dist/wt b/dist/wt index 9ed0b6721ac6edda16c3b320663b3fa79aedac0f..2f67b6742f6e44201b47ecd139a589c93f9387da 100755 --- a/dist/wt +++ b/dist/wt @@ -861,7 +861,7 @@ end return M ]] -_EMBEDDED_MODULES["wt.cmd.add"] = [[-- SPDX-FileCopyrightText: Amolith +_EMBEDDED_MODULES["wt.cmd.clone"] = [[-- SPDX-FileCopyrightText: Amolith -- -- SPDX-License-Identifier: GPL-3.0-or-later @@ -870,261 +870,273 @@ local shell = require("wt.shell") local git = require("wt.git") local path_mod = require("wt.path") local config = require("wt.config") -local hooks = require("wt.hooks") ----@class wt.cmd.add +---@class wt.cmd.clone local M = {} ----Add a worktree for an existing or new branch +---Clone a repository with bare repo structure ---@param args string[] -function M.cmd_add(args) - -- Parse arguments: [-b []] - ---@type string|nil - local branch = nil - local create_branch = false - ---@type string|nil - local start_point = nil +function M.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 == "-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 + if a == "--remote" then + if not args[i + 1] then + shell.die("--remote requires a name") end - elseif not branch then - branch = a + table.insert(remote_flags, args[i + 1]) + i = i + 1 + elseif a == "--own" then + own = true + elseif not url then + url = a else shell.die("unexpected argument: " .. a) end i = i + 1 end - if not branch then - shell.die("usage: wt a [-b []]") + if not url then + shell.die("usage: wt c [--remote name]... [--own]") return end - local root, err = git.find_project_root() - if not root then - shell.die(err --[[@as string]]) + -- Extract project name from URL + local project_name = config.extract_project_name(url) + if not project_name then + shell.die("could not extract project name from URL: " .. url) return end - local git_dir = root .. "/.bare" - local source_worktree = git.detect_source_worktree(root) - - -- Load config for path style - local global_config = config.load_global_config() - local style = global_config.branch_path_style or "nested" - local separator = global_config.flat_separator or "_" - - local target_path = path_mod.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() - shell.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 = shell.run_cmd( - "GIT_DIR=" - .. git_dir - .. " git worktree add -b " - .. branch - .. " -- " - .. target_path - .. " " - .. start_point - ) - else - output, code = - shell.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 = git.branch_exists_local(git_dir, branch) - local remotes = git.find_branch_remotes(git_dir, branch) - - if not exists_local and #remotes == 0 then - shell.die("branch '" .. branch .. "' not found locally or on any remote (use -b to create)") - end - - if #remotes > 1 then - shell.die("branch '" .. branch .. "' exists on multiple remotes: " .. table.concat(remotes, ", ")) - end - - output, code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -- " .. target_path .. " " .. branch) - end - - if code ~= 0 then - shell.die("failed to add worktree: " .. output, exit.EXIT_SYSTEM_ERROR) - end - - -- Run hooks if we have a source worktree - local project_config = config.load_project_config(root) - if source_worktree then - if project_config.hooks then - hooks.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 - -return M -]] - -_EMBEDDED_MODULES["wt.cmd.remove"] = [[-- SPDX-FileCopyrightText: Amolith --- --- SPDX-License-Identifier: GPL-3.0-or-later - -local exit = require("wt.exit") -local shell = require("wt.shell") -local git = require("wt.git") -local path_mod = require("wt.path") - ----@class wt.cmd.remove -local M = {} - ----Check if cwd is inside (or equal to) a given path ----@param target string ----@return boolean -local function cwd_inside_path(target) + -- Check if project directory already exists local cwd = shell.get_cwd() if not cwd then - return false + shell.die("failed to get current directory", exit.EXIT_SYSTEM_ERROR) end - return path_mod.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 = shell.run_cmd("GIT_DIR=" .. git_dir .. " git symbolic-ref --short HEAD") - if code ~= 0 then - return nil + ---@cast cwd string + local project_path = cwd .. "/" .. project_name + local check = io.open(project_path, "r") + if check then + check:close() + shell.die("directory already exists: " .. project_path) end - return (output:gsub("%s+$", "")) -end - ----Remove a worktree and optionally its branch ----@param args string[] -function M.cmd_remove(args) - 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 - shell.die("unexpected argument: " .. a) - end + -- Clone bare repo + local bare_path = project_path .. "/.bare" + local output, code = shell.run_cmd("git clone --bare " .. url .. " " .. bare_path) + if code ~= 0 then + shell.die("failed to clone: " .. output, exit.EXIT_SYSTEM_ERROR) end - if not branch then - shell.die("usage: wt r [-b] [-f]") + -- Write .git file pointing to .bare + local git_file_handle = io.open(project_path .. "/.git", "w") + if not git_file_handle then + shell.die("failed to create .git file", exit.EXIT_SYSTEM_ERROR) return end + git_file_handle:write("gitdir: ./.bare\n") + git_file_handle:close() - local root, err = git.find_project_root() - if not root then - shell.die(err --[[@as string]]) - return - end + -- Detect default branch + local git_dir = bare_path + local default_branch = git.detect_cloned_default_branch(git_dir) - local git_dir = root .. "/.bare" + -- Load global config + local global_config = config.load_global_config() - local wt_output, wt_code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain") - if wt_code ~= 0 then - shell.die("failed to list worktrees", exit.EXIT_SYSTEM_ERROR) - return - end + -- Determine which remotes to use + ---@type string[] + local selected_remotes = {} - local worktrees = git.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 + 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 --[[@as string[] ]] + 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 = shell.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 - end - - if not target_path then - shell.die("no worktree found for branch '" .. branch .. "'") - return - end - - if cwd_inside_path(target_path) then - shell.die("cannot remove worktree while inside it") - end - - if not force then - local status_out = shell.run_cmd("git -C " .. target_path .. " status --porcelain") - if status_out ~= "" then - shell.die("worktree has uncommitted changes (use -f to force)") + 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 = shell.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 - 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 + -- Track configured remotes for summary + ---@type string[] + local configured_remotes = {} - local output, code = shell.run_cmd(remove_cmd) - if code ~= 0 then - shell.die("failed to remove worktree: " .. output, exit.EXIT_SYSTEM_ERROR) - end + 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 = shell.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 + shell.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 - if delete_branch then - 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 + -- 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 = config.resolve_url_template(template, project_name) + output, code = + shell.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 + shell.run_cmd( + "GIT_DIR=" + .. git_dir + .. " git config remote." + .. remote_name + .. ".fetch '+refs/heads/*:refs/remotes/" + .. remote_name + .. "/*'" + ) + -- Push to additional remotes + output, code = + shell.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 + shell.run_cmd( + "GIT_DIR=" .. git_dir .. " git config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'" + ) + table.insert(configured_remotes, "origin") end - - local checked_out = git.branch_checked_out_at(git_dir, branch) - if checked_out then - shell.die("cannot delete branch '" .. branch .. "': checked out at " .. checked_out) + else + -- Contributing to someone else's project + -- Rename origin to upstream + output, code = shell.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 + shell.run_cmd( + "GIT_DIR=" .. git_dir .. " git config remote.upstream.fetch '+refs/heads/*:refs/remotes/upstream/*'" + ) + table.insert(configured_remotes, "upstream") end - local delete_flag = force and "-D" or "-d" - local del_output, del_code = - shell.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 + -- 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 = config.resolve_url_template(template, project_name) + output, code = + shell.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 + shell.run_cmd( + "GIT_DIR=" + .. git_dir + .. " git config remote." + .. remote_name + .. ".fetch '+refs/heads/*:refs/remotes/" + .. remote_name + .. "/*'" + ) + -- Push to this remote + output, code = + shell.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 - print("Worktree and branch '" .. branch .. "' removed") - else - print("Worktree removed") + -- Fetch all remotes + shell.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 = path_mod.branch_to_path(project_path, default_branch, style, separator) + + -- Create initial worktree + output, code = + shell.run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -- " .. worktree_path .. " " .. default_branch) + if code ~= 0 then + shell.die("failed to create worktree: " .. output, exit.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 return M ]] -_EMBEDDED_MODULES["wt.cmd.list"] = [[-- SPDX-FileCopyrightText: Amolith +_EMBEDDED_MODULES["wt.cmd.new"] = [[-- SPDX-FileCopyrightText: Amolith -- -- SPDX-License-Identifier: GPL-3.0-or-later @@ -1132,588 +1144,622 @@ local exit = require("wt.exit") local shell = require("wt.shell") local git = require("wt.git") local path_mod = require("wt.path") +local config = require("wt.config") ----@class wt.cmd.list +---@class wt.cmd.new local M = {} ----List all worktrees with status -function M.cmd_list() - local root, err = git.find_project_root() - if not root then - shell.die(err --[[@as string]]) +---Create a new project with bare repo structure +---@param args string[] +function M.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 + shell.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 + shell.die("unexpected argument: " .. a) + end + i = i + 1 + end + + if not project_name then + shell.die("usage: wt n [--remote name]...") return end - local git_dir = root .. "/.bare" - local output, code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain") - if code ~= 0 then - shell.die("failed to list worktrees: " .. output, exit.EXIT_SYSTEM_ERROR) + -- Check if project directory already exists + local cwd = shell.get_cwd() + if not cwd then + shell.die("failed to get current directory", exit.EXIT_SYSTEM_ERROR) + end + ---@cast cwd string + local project_path = cwd .. "/" .. project_name + local check = io.open(project_path, "r") + if check then + check:close() + shell.die("directory already exists: " .. project_path) end - ---@type {path: string, head: string, branch: string}[] - local worktrees = {} - local current = {} + -- Load global config + local global_config = config.load_global_config() - 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) + -- 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 --[[@as string[] ]] + 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 = shell.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 - if value:match("/%.bare$") then - current = {} - else - current = { path = value, head = "", branch = "(detached)" } + 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 = shell.run_cmd(cmd) + if code == 0 and output ~= "" then + for line in output:gmatch("[^\n]+") do + table.insert(selected_remotes, line) + end 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 - current = {} end end - if current.path then - table.insert(worktrees, current) - end - if #worktrees == 0 then - print("No worktrees found") - return + -- Create project structure + local bare_path = project_path .. "/.bare" + local output, code = shell.run_cmd("mkdir -p " .. bare_path) + if code ~= 0 then + shell.die("failed to create directory: " .. output, exit.EXIT_SYSTEM_ERROR) end - local cwd = shell.get_cwd() or "" - - local rows = {} - for _, wt in ipairs(worktrees) do - local rel_path = path_mod.relative_path(cwd, wt.path) - - local status_out = shell.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) + output, code = shell.run_cmd("git init --bare " .. bare_path) + if code ~= 0 then + shell.die("failed to init bare repo: " .. output, exit.EXIT_SYSTEM_ERROR) end - 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 + -- Write .git file pointing to .bare + local git_file_handle = io.open(project_path .. "/.git", "w") + if not git_file_handle then + shell.die("failed to create .git file", exit.EXIT_SYSTEM_ERROR) return end - io.write(table_handle:read("*a") or "") - table_handle:close() -end - -return M -]] + git_file_handle:write("gitdir: ./.bare\n") + git_file_handle:close() -_EMBEDDED_MODULES["wt.cmd.fetch"] = [[-- SPDX-FileCopyrightText: Amolith --- --- SPDX-License-Identifier: GPL-3.0-or-later + -- 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 = config.resolve_url_template(template, project_name) + output, code = shell.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 + shell.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 -local exit = require("wt.exit") -local shell = require("wt.shell") -local git = require("wt.git") + -- Detect default branch + local default_branch = git.get_default_branch() ----@class wt.cmd.fetch -local M = {} + -- Load config for path style + local style = global_config.branch_path_style or "nested" + local separator = global_config.flat_separator + local worktree_path = path_mod.branch_to_path(project_path, default_branch, style, separator) ----Fetch all remotes with pruning -function M.cmd_fetch() - local root, err = git.find_project_root() - if not root then - shell.die(err --[[@as string]]) - return + -- Create orphan worktree + output, code = shell.run_cmd( + "GIT_DIR=" .. git_dir .. " git worktree add --orphan -b " .. default_branch .. " -- " .. worktree_path + ) + if code ~= 0 then + shell.die("failed to create worktree: " .. output, exit.EXIT_SYSTEM_ERROR) end - local git_dir = root .. "/.bare" - local output, code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git fetch --all --prune") - io.write(output) - if code ~= 0 then - os.exit(exit.EXIT_SYSTEM_ERROR) + -- 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 return M ]] - -if _VERSION < "Lua 5.2" then - io.stderr:write("error: wt requires Lua 5.2 or later\n") - os.exit(1) -end +_EMBEDDED_MODULES["wt.cmd.add"] = [[-- SPDX-FileCopyrightText: Amolith +-- +-- SPDX-License-Identifier: GPL-3.0-or-later local exit = require("wt.exit") -local EXIT_SUCCESS = exit.EXIT_SUCCESS -local EXIT_USER_ERROR = exit.EXIT_USER_ERROR -local EXIT_SYSTEM_ERROR = exit.EXIT_SYSTEM_ERROR - local shell = require("wt.shell") -local run_cmd = shell.run_cmd -local run_cmd_silent = shell.run_cmd_silent -local get_cwd = shell.get_cwd -local die = shell.die - +local git = require("wt.git") local path_mod = require("wt.path") -local branch_to_path = path_mod.branch_to_path -local split_path = path_mod.split_path -local relative_path = path_mod.relative_path -local path_inside = path_mod.path_inside -local escape_pattern = path_mod.escape_pattern - -local git_mod = require("wt.git") -local find_project_root = git_mod.find_project_root -local detect_source_worktree = git_mod.detect_source_worktree -local detect_cloned_default_branch = git_mod.detect_cloned_default_branch -local get_default_branch = git_mod.get_default_branch -local parse_branch_remotes = git_mod.parse_branch_remotes -local parse_worktree_list = git_mod.parse_worktree_list - -local config_mod = require("wt.config") -local resolve_url_template = config_mod.resolve_url_template -local extract_project_name = config_mod.extract_project_name -local load_global_config = config_mod.load_global_config -local load_project_config = config_mod.load_project_config - -local hooks_mod = require("wt.hooks") -local load_hook_permissions = hooks_mod.load_hook_permissions -local save_hook_permissions = hooks_mod.save_hook_permissions -local summarize_hooks = hooks_mod.summarize_hooks -local run_hooks = hooks_mod.run_hooks - -local help_mod = require("wt.help") -local print_usage = help_mod.print_usage -local show_command_help = help_mod.show_command_help - -local fetch_mod = require("wt.cmd.fetch") -local cmd_fetch = fetch_mod.cmd_fetch - -local list_mod = require("wt.cmd.list") -local cmd_list = list_mod.cmd_list - -local remove_mod = require("wt.cmd.remove") -local cmd_remove = remove_mod.cmd_remove +local config = require("wt.config") +local hooks = require("wt.hooks") -local add_mod = require("wt.cmd.add") -local cmd_add = add_mod.cmd_add +---@class wt.cmd.add +local M = {} +---Add a worktree for an existing or new branch ---@param args string[] -local function cmd_clone(args) - -- Parse arguments: [--remote name]... [--own] - local url = nil - ---@type string[] - local remote_flags = {} - local own = false +function M.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 == "--remote" then - if not args[i + 1] then - die("--remote requires a name") + 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 - table.insert(remote_flags, args[i + 1]) - i = i + 1 - elseif a == "--own" then - own = true - elseif not url then - url = a + elseif not branch then + branch = a else - die("unexpected argument: " .. a) + shell.die("unexpected argument: " .. a) end i = i + 1 end - if not url then - die("usage: wt c [--remote name]... [--own]") + if not branch then + shell.die("usage: wt a [-b []]") 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) + local root, err = git.find_project_root() + if not root then + shell.die(err --[[@as string]]) 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") + local git_dir = root .. "/.bare" + local source_worktree = git.detect_source_worktree(root) + + -- Load config for path style + local global_config = config.load_global_config() + local style = global_config.branch_path_style or "nested" + local separator = global_config.flat_separator or "_" + + local target_path = path_mod.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("directory already exists: " .. project_path) + shell.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 = shell.run_cmd( + "GIT_DIR=" + .. git_dir + .. " git worktree add -b " + .. branch + .. " -- " + .. target_path + .. " " + .. start_point + ) + else + output, code = + shell.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 = git.branch_exists_local(git_dir, branch) + local remotes = git.find_branch_remotes(git_dir, branch) + + if not exists_local and #remotes == 0 then + shell.die("branch '" .. branch .. "' not found locally or on any remote (use -b to create)") + end + + if #remotes > 1 then + shell.die("branch '" .. branch .. "' exists on multiple remotes: " .. table.concat(remotes, ", ")) + end + + output, code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -- " .. target_path .. " " .. branch) 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) + shell.die("failed to add worktree: " .. output, exit.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 + -- Run hooks if we have a source worktree + local project_config = config.load_project_config(root) + if source_worktree then + if project_config.hooks then + hooks.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 - 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) + print(target_path) +end - -- Load global config - local global_config = load_global_config() +return M +]] - -- Determine which remotes to use - ---@type string[] - local selected_remotes = {} +_EMBEDDED_MODULES["wt.cmd.remove"] = [[-- SPDX-FileCopyrightText: Amolith +-- +-- SPDX-License-Identifier: GPL-3.0-or-later - 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 +local exit = require("wt.exit") +local shell = require("wt.shell") +local git = require("wt.git") +local path_mod = require("wt.path") + +---@class wt.cmd.remove +local M = {} + +---Check if cwd is inside (or equal to) a given path +---@param target string +---@return boolean +local function cwd_inside_path(target) + local cwd = shell.get_cwd() + if not cwd then + return false + end + return path_mod.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 = shell.run_cmd("GIT_DIR=" .. git_dir .. " git symbolic-ref --short HEAD") + if code ~= 0 then + return nil + end + return (output:gsub("%s+$", "")) +end + +---Remove a worktree and optionally its branch +---@param args string[] +function M.cmd_remove(args) + 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 + shell.die("unexpected argument: " .. a) end - elseif global_config.remotes then - local keys = {} - for k in pairs(global_config.remotes) do - table.insert(keys, k) + end + + if not branch then + shell.die("usage: wt r [-b] [-f]") + return + end + + local root, err = git.find_project_root() + if not root then + shell.die(err --[[@as string]]) + return + end + + local git_dir = root .. "/.bare" + + local wt_output, wt_code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain") + if wt_code ~= 0 then + shell.die("failed to list worktrees", exit.EXIT_SYSTEM_ERROR) + return + end + + local worktrees = git.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 - 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 + + if not target_path then + shell.die("no worktree found for branch '" .. branch .. "'") + return + end + + if cwd_inside_path(target_path) then + shell.die("cannot remove worktree while inside it") + end + + if not force then + local status_out = shell.run_cmd("git -C " .. target_path .. " status --porcelain") + if status_out ~= "" then + shell.die("worktree has uncommitted changes (use -f to force)") end end - -- Track configured remotes for summary - ---@type string[] - local configured_remotes = {} + 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 - 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 + local output, code = shell.run_cmd(remove_cmd) + if code ~= 0 then + shell.die("failed to remove worktree: " .. output, exit.EXIT_SYSTEM_ERROR) + 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") + if delete_branch then + 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 - 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") + + local checked_out = git.branch_checked_out_at(git_dir, branch) + if checked_out then + shell.die("cannot delete branch '" .. branch .. "': checked out at " .. checked_out) 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 + local delete_flag = force and "-D" or "-d" + local del_output, del_code = + shell.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 + +return M +]] + +_EMBEDDED_MODULES["wt.cmd.list"] = [[-- SPDX-FileCopyrightText: Amolith +-- +-- SPDX-License-Identifier: GPL-3.0-or-later + +local exit = require("wt.exit") +local shell = require("wt.shell") +local git = require("wt.git") +local path_mod = require("wt.path") + +---@class wt.cmd.list +local M = {} + +---List all worktrees with status +function M.cmd_list() + local root, err = git.find_project_root() + if not root then + shell.die(err --[[@as string]]) + return + end + + local git_dir = root .. "/.bare" + local output, code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain") + if code ~= 0 then + shell.die("failed to list worktrees: " .. output, exit.EXIT_SYSTEM_ERROR) + end + + ---@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 + if value:match("/%.bare$") then + current = {} else - io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n") + 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 + current = {} end end + if current.path then + table.insert(worktrees, current) + end - -- Fetch all remotes - run_cmd("GIT_DIR=" .. git_dir .. " git fetch --all") + if #worktrees == 0 then + print("No worktrees found") + return + end - -- 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) + local cwd = shell.get_cwd() or "" - -- 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) + local rows = {} + for _, wt in ipairs(worktrees) do + local rel_path = path_mod.relative_path(cwd, wt.path) + + local status_out = shell.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 - -- 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, ", ")) + 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 ----@param args string[] -local function cmd_new(args) - -- Parse arguments: [--remote name]... - local project_name = nil - ---@type string[] - local remote_flags = {} +return M +]] - 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 +_EMBEDDED_MODULES["wt.cmd.fetch"] = [[-- SPDX-FileCopyrightText: Amolith +-- +-- SPDX-License-Identifier: GPL-3.0-or-later - if not project_name then - die("usage: wt n [--remote name]...") +local exit = require("wt.exit") +local shell = require("wt.shell") +local git = require("wt.git") + +---@class wt.cmd.fetch +local M = {} + +---Fetch all remotes with pruning +function M.cmd_fetch() + local root, err = git.find_project_root() + if not root then + shell.die(err --[[@as string]]) 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) + local git_dir = root .. "/.bare" + local output, code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git fetch --all --prune") + io.write(output) + if code ~= 0 then + os.exit(exit.EXIT_SYSTEM_ERROR) end +end - -- Load global config - local global_config = load_global_config() +return M +]] - -- 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 +if _VERSION < "Lua 5.2" then + io.stderr:write("error: wt requires Lua 5.2 or later\n") + os.exit(1) +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 +local exit = require("wt.exit") +local EXIT_SUCCESS = exit.EXIT_SUCCESS +local EXIT_USER_ERROR = exit.EXIT_USER_ERROR +local EXIT_SYSTEM_ERROR = exit.EXIT_SYSTEM_ERROR - output, code = run_cmd("git init --bare " .. bare_path) - if code ~= 0 then - die("failed to init bare repo: " .. output, EXIT_SYSTEM_ERROR) - end +local shell = require("wt.shell") +local run_cmd = shell.run_cmd +local run_cmd_silent = shell.run_cmd_silent +local get_cwd = shell.get_cwd +local die = shell.die - -- 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() +local path_mod = require("wt.path") +local branch_to_path = path_mod.branch_to_path +local split_path = path_mod.split_path +local relative_path = path_mod.relative_path +local path_inside = path_mod.path_inside +local escape_pattern = path_mod.escape_pattern - -- 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 +local git_mod = require("wt.git") +local find_project_root = git_mod.find_project_root +local detect_source_worktree = git_mod.detect_source_worktree +local detect_cloned_default_branch = git_mod.detect_cloned_default_branch +local parse_branch_remotes = git_mod.parse_branch_remotes +local parse_worktree_list = git_mod.parse_worktree_list - -- Detect default branch - local default_branch = get_default_branch() +local config_mod = require("wt.config") +local resolve_url_template = config_mod.resolve_url_template +local extract_project_name = config_mod.extract_project_name +local load_global_config = config_mod.load_global_config +local load_project_config = config_mod.load_project_config - -- 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) +local hooks_mod = require("wt.hooks") +local load_hook_permissions = hooks_mod.load_hook_permissions +local save_hook_permissions = hooks_mod.save_hook_permissions +local summarize_hooks = hooks_mod.summarize_hooks +local run_hooks = hooks_mod.run_hooks - -- 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 +local help_mod = require("wt.help") +local print_usage = help_mod.print_usage +local show_command_help = help_mod.show_command_help - -- 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 +local fetch_mod = require("wt.cmd.fetch") +local cmd_fetch = fetch_mod.cmd_fetch + +local list_mod = require("wt.cmd.list") +local cmd_list = list_mod.cmd_list + +local remove_mod = require("wt.cmd.remove") +local cmd_remove = remove_mod.cmd_remove + +local add_mod = require("wt.cmd.add") +local cmd_add = add_mod.cmd_add + +local new_mod = require("wt.cmd.new") +local cmd_new = new_mod.cmd_new + +local clone_mod = require("wt.cmd.clone") +local cmd_clone = clone_mod.cmd_clone ---List directory entries (excluding . and ..) ---@param path string diff --git a/src/main.lua b/src/main.lua index b2dd9ef203891eb174ad455bbc91ff839d3b340b..0b14921ea323b3c18e014c5189dda77fe6c61603 100644 --- a/src/main.lua +++ b/src/main.lua @@ -65,258 +65,8 @@ local cmd_add = add_mod.cmd_add local new_mod = require("wt.cmd.new") local cmd_new = new_mod.cmd_new ----@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 +local clone_mod = require("wt.cmd.clone") +local cmd_clone = clone_mod.cmd_clone ---List directory entries (excluding . and ..) ---@param path string diff --git a/src/wt/cmd/clone.lua b/src/wt/cmd/clone.lua new file mode 100644 index 0000000000000000000000000000000000000000..a6838388d80cf22331b5affc1e2104f8e8b80742 --- /dev/null +++ b/src/wt/cmd/clone.lua @@ -0,0 +1,273 @@ +-- SPDX-FileCopyrightText: Amolith +-- +-- SPDX-License-Identifier: GPL-3.0-or-later + +local exit = require("wt.exit") +local shell = require("wt.shell") +local git = require("wt.git") +local path_mod = require("wt.path") +local config = require("wt.config") + +---@class wt.cmd.clone +local M = {} + +---Clone a repository with bare repo structure +---@param args string[] +function M.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 + shell.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 + shell.die("unexpected argument: " .. a) + end + i = i + 1 + end + + if not url then + shell.die("usage: wt c [--remote name]... [--own]") + return + end + + -- Extract project name from URL + local project_name = config.extract_project_name(url) + if not project_name then + shell.die("could not extract project name from URL: " .. url) + return + end + + -- Check if project directory already exists + local cwd = shell.get_cwd() + if not cwd then + shell.die("failed to get current directory", exit.EXIT_SYSTEM_ERROR) + end + ---@cast cwd string + local project_path = cwd .. "/" .. project_name + local check = io.open(project_path, "r") + if check then + check:close() + shell.die("directory already exists: " .. project_path) + end + + -- Clone bare repo + local bare_path = project_path .. "/.bare" + local output, code = shell.run_cmd("git clone --bare " .. url .. " " .. bare_path) + if code ~= 0 then + shell.die("failed to clone: " .. output, exit.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 + shell.die("failed to create .git file", exit.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 = git.detect_cloned_default_branch(git_dir) + + -- Load global config + local global_config = 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 --[[@as string[] ]] + 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 = shell.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 = shell.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 = shell.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 + shell.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 = config.resolve_url_template(template, project_name) + output, code = + shell.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 + shell.run_cmd( + "GIT_DIR=" + .. git_dir + .. " git config remote." + .. remote_name + .. ".fetch '+refs/heads/*:refs/remotes/" + .. remote_name + .. "/*'" + ) + -- Push to additional remotes + output, code = + shell.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 + shell.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 = shell.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 + shell.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 = config.resolve_url_template(template, project_name) + output, code = + shell.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 + shell.run_cmd( + "GIT_DIR=" + .. git_dir + .. " git config remote." + .. remote_name + .. ".fetch '+refs/heads/*:refs/remotes/" + .. remote_name + .. "/*'" + ) + -- Push to this remote + output, code = + shell.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 + shell.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 = path_mod.branch_to_path(project_path, default_branch, style, separator) + + -- Create initial worktree + output, code = + shell.run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -- " .. worktree_path .. " " .. default_branch) + if code ~= 0 then + shell.die("failed to create worktree: " .. output, exit.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 + +return M