diff --git a/dist/wt b/dist/wt index 5d1e98f160738395fc44e93fc54964d0f0d8c094..588503d42018909226ed0b2e99dd847bbbe39ca4 100755 --- a/dist/wt +++ b/dist/wt @@ -99,6 +99,13 @@ function M.die(msg, code) os.exit(code or exit.EXIT_USER_ERROR) end +---Quote a string for safe shell use +---@param str string +---@return string +function M.quote(str) + return "'" .. str:gsub("'", "'\\''") .. "'" +end + return M ]] @@ -194,6 +201,15 @@ function M.escape_pattern(str) return (str:gsub("([%%%-%+%[%]%(%)%.%^%$%*%?])", "%%%1")) end +---Get the parent directory of a path +---@param path string +---@return string|nil parent or nil if path has no parent +function M.parent_dir(path) + path = path:gsub("/$", "") + local parent = path:match("(.+)/[^/]+$") + return parent +end + return M ]] @@ -392,6 +408,24 @@ function M.parse_worktree_list(output) return worktrees end +---Get the branch checked out in a specific worktree +---@param git_dir string +---@param worktree_path string +---@return string|nil branch name, or nil if detached/not found +function M.get_worktree_branch(git_dir, worktree_path) + local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain") + if code ~= 0 then + return nil + end + local worktrees = M.parse_worktree_list(output) + for _, wt in ipairs(worktrees) do + if wt.path == worktree_path then + return wt.branch + end + end + return nil +end + ---Check if branch is checked out in any worktree ---@param git_dir string ---@param branch string @@ -410,6 +444,32 @@ function M.branch_checked_out_at(git_dir, branch) return nil end +---Check if a ref (branch) has any commits +---@param git_dir string +---@param ref string +---@return boolean has_commits +function M.ref_has_commits(git_dir, ref) + return run_cmd_silent("GIT_DIR=" .. git_dir .. " git rev-parse --verify --quiet " .. ref) +end + +---Find worktree by branch name +---@param git_dir string +---@param branch string +---@return {path: string, branch: string}|nil +function M.find_worktree_by_branch(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 = M.parse_worktree_list(output) + for _, wt in ipairs(worktrees) do + if wt.branch == branch and not wt.bare then + return { path = wt.path, branch = wt.branch } + end + end + return nil +end + return M ]] @@ -701,7 +761,13 @@ function M.run_hooks(source, target, hooks, root, home_override) end if hooks.run then for _, cmd in ipairs(hooks.run) do - local _, code = run_cmd("cd " .. target .. " && " .. cmd) + local output, code = run_cmd("cd " .. target .. " && " .. cmd) + if output ~= "" then + io.write(output) + if not output:match("\n$") then + io.write("\n") + end + end if code ~= 0 then io.stderr:write("warning: hook command failed: " .. cmd .. "\n") end @@ -712,7 +778,7 @@ end return M ]] -_EMBEDDED_MODULES["wt.help"] = [[-- SPDX-FileCopyrightText: Amolith +_EMBEDDED_MODULES["wt.help"] = [=[-- SPDX-FileCopyrightText: Amolith -- -- SPDX-License-Identifier: GPL-3.0-or-later @@ -752,8 +818,18 @@ M.COMMAND_HELP = { "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)", + " --own Treat as your own project (see Push Behavior below)", + "", + "Push Behavior:", + " Contributor mode (default):", + " - 'origin' renamed to 'upstream'", + " - Selected remotes added from config", + " - Pushes default branch to ALL selected remotes (creates fork copies)", + "", + " Own mode (--own):", + " - 'origin' renamed to first selected remote", + " - Additional remotes added from config", + " - Pushes only to ADDITIONAL remotes (mirrors), not the first", "", "Examples:", " wt c https://github.com/user/repo.git", @@ -865,9 +941,9 @@ function M.show_command_help(cmd) end return M -]] +]=] -_EMBEDDED_MODULES["wt.cmd.clone"] = [[-- SPDX-FileCopyrightText: Amolith +_EMBEDDED_MODULES["wt.cmd.clone"] = [=[-- SPDX-FileCopyrightText: Amolith -- -- SPDX-License-Identifier: GPL-3.0-or-later @@ -1153,9 +1229,9 @@ function M.cmd_clone(args) end return M -]] +]=] -_EMBEDDED_MODULES["wt.cmd.new"] = [[-- SPDX-FileCopyrightText: Amolith +_EMBEDDED_MODULES["wt.cmd.new"] = [=[-- SPDX-FileCopyrightText: Amolith -- -- SPDX-License-Identifier: GPL-3.0-or-later @@ -1342,9 +1418,9 @@ function M.cmd_new(args) end return M -]] +]=] -_EMBEDDED_MODULES["wt.cmd.add"] = [[-- SPDX-FileCopyrightText: Amolith +_EMBEDDED_MODULES["wt.cmd.add"] = [=[-- SPDX-FileCopyrightText: Amolith -- -- SPDX-License-Identifier: GPL-3.0-or-later @@ -1407,15 +1483,55 @@ function M.cmd_add(args) local target_path = path_mod.branch_to_path(root, branch, style, separator) - -- Check if target already exists + -- Check if worktree already exists for this branch (idempotent behavior) + local existing_wt = git.find_worktree_by_branch(git_dir, branch) + if existing_wt then + -- Validate the worktree path actually exists (not stale metadata) + local wt_check = io.open(existing_wt.path .. "/.git", "r") + if not wt_check then + shell.die( + "worktree for '" + .. branch + .. "' is registered but missing at " + .. existing_wt.path + .. "\nhint: run `git worktree prune` to clean up stale entries" + ) + end + wt_check:close() + + local project_config = config.load_project_config(root) + io.stderr:write("worktree already exists at " .. existing_wt.path .. "\n") + -- Warn if path differs from current config + if existing_wt.path ~= target_path then + io.stderr:write("note: current config would place it at " .. target_path .. "\n") + end + -- If running from root and hooks exist, offer to run them now + if not source_worktree and project_config.hooks then + io.stderr:write("hint: run `wt a` from inside a worktree to apply hooks from .wt.lua\n") + end + print(existing_wt.path) + return + end + + -- Check if target path has a .git but for a different branch (conflict) local check = io.open(target_path .. "/.git", "r") if check then check:close() - shell.die("worktree already exists at " .. target_path) + shell.die("directory already exists at " .. target_path .. " but is not a worktree for '" .. branch .. "'") end local output, code if create_branch then + -- Default start-point to source worktree's branch if inside one + if not start_point and source_worktree then + start_point = git.get_worktree_branch(git_dir, source_worktree) + end + + -- Check if start_point resolves to a valid commit (catches orphan branch case) + if start_point and not git.ref_has_commits(git_dir, start_point) then + shell.die("'" .. start_point .. "' has no commits yet; make an initial commit first") + end + -- Create new branch with worktree if start_point then output, code = shell.run_cmd( @@ -1442,7 +1558,10 @@ function M.cmd_add(args) end if #remotes > 1 then - shell.die("branch '" .. branch .. "' exists on multiple remotes: " .. table.concat(remotes, ", ")) + local hint = "use `-b " .. remotes[1] .. "/" .. branch .. "` to specify which remote to track" + shell.die( + "branch '" .. branch .. "' exists on multiple remotes: " .. table.concat(remotes, ", ") .. "\n" .. hint + ) end output, code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -- " .. target_path .. " " .. branch) @@ -1459,16 +1578,16 @@ function M.cmd_add(args) 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") + io.stderr:write("hint: hooks skipped; run `wt a` from inside a worktree to apply hooks from .wt.lua\n") end print(target_path) end return M -]] +]=] -_EMBEDDED_MODULES["wt.cmd.remove"] = [[-- SPDX-FileCopyrightText: Amolith +_EMBEDDED_MODULES["wt.cmd.remove"] = [=[-- SPDX-FileCopyrightText: Amolith -- -- SPDX-License-Identifier: GPL-3.0-or-later @@ -1502,6 +1621,18 @@ local function get_bare_head(git_dir) return (output:gsub("%s+$", "")) end +---Remove empty parent directories between target and project root +---@param target_path string the removed worktree path +---@param project_root string the project root (stop here) +local function cleanup_empty_parents(target_path, project_root) + project_root = project_root:gsub("/$", "") + local parent = path_mod.parent_dir(target_path) + while parent and parent ~= project_root and path_mod.path_inside(parent, project_root) do + shell.run_cmd_silent("rmdir " .. shell.quote(parent)) + parent = path_mod.parent_dir(parent) + end +end + ---Remove a worktree and optionally its branch ---@param args string[] function M.cmd_remove(args) @@ -1576,6 +1707,8 @@ function M.cmd_remove(args) shell.die("failed to remove worktree: " .. output, exit.EXIT_SYSTEM_ERROR) end + cleanup_empty_parents(target_path, root) + if delete_branch then local bare_head = get_bare_head(git_dir) if bare_head and bare_head == branch then @@ -1605,9 +1738,9 @@ function M.cmd_remove(args) end return M -]] +]=] -_EMBEDDED_MODULES["wt.cmd.list"] = [[-- SPDX-FileCopyrightText: Amolith +_EMBEDDED_MODULES["wt.cmd.list"] = [=[-- SPDX-FileCopyrightText: Amolith -- -- SPDX-License-Identifier: GPL-3.0-or-later @@ -1689,9 +1822,9 @@ function M.cmd_list() end return M -]] +]=] -_EMBEDDED_MODULES["wt.cmd.fetch"] = [[-- SPDX-FileCopyrightText: Amolith +_EMBEDDED_MODULES["wt.cmd.fetch"] = [=[-- SPDX-FileCopyrightText: Amolith -- -- SPDX-License-Identifier: GPL-3.0-or-later @@ -1702,7 +1835,25 @@ local git = require("wt.git") ---@class wt.cmd.fetch local M = {} ----Fetch all remotes with pruning +---Get list of configured remotes +---@param git_dir string +---@return string[] remotes +local function get_remotes(git_dir) + local output, code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git remote") + if code ~= 0 then + return {} + end + local remotes = {} + for line in output:gmatch("[^\n]+") do + local trimmed = line:match("^%s*(.-)%s*$") + if trimmed and trimmed ~= "" then + table.insert(remotes, trimmed) + end + end + return remotes +end + +---Fetch all remotes with pruning, tolerating partial failures function M.cmd_fetch() local root, err = git.find_project_root() if not root then @@ -1711,15 +1862,41 @@ function M.cmd_fetch() 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 + local remotes = get_remotes(git_dir) + + if #remotes == 0 then + shell.die("no remotes configured", exit.EXIT_USER_ERROR) + return + end + + local succeeded = 0 + local failures = {} + + for _, remote in ipairs(remotes) do + local output, code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git fetch --prune " .. shell.quote(remote)) + if code == 0 then + succeeded = succeeded + 1 + io.write(output) + else + table.insert(failures, { remote = remote, output = output }) + end + end + + for _, f in ipairs(failures) do + io.stderr:write("warning: failed to fetch " .. f.remote .. "\n") + if f.output and f.output ~= "" then + io.stderr:write(f.output) + end + end + + if succeeded == 0 then + io.stderr:write("error: all remotes failed to fetch\n") os.exit(exit.EXIT_SYSTEM_ERROR) end end return M -]] +]=] _EMBEDDED_MODULES["wt.cmd.init"] = [[-- SPDX-FileCopyrightText: Amolith -- @@ -1857,7 +2034,10 @@ function M.cmd_init(args) -- Check for uncommitted changes local status_out = shell.run_cmd("git status --porcelain") if status_out ~= "" then - shell.die("uncommitted changes; commit or stash before converting") + io.stderr:write("error: uncommitted changes\n") + io.stderr:write("hint: commit, or stash and restore after:\n") + io.stderr:write(" git stash -u && wt init && cd && git stash pop\n") + os.exit(exit.EXIT_USER_ERROR) end -- Detect default branch @@ -1919,14 +2099,22 @@ function M.cmd_init(args) 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" + -- Prominent warning about file deletion + if #orphaned > 0 then + io.stderr:write("\n") + io.stderr:write("WARNING: The following " .. #orphaned .. " items will be DELETED from project root:\n") + for _, item in ipairs(orphaned) do + io.stderr:write(" - " .. item .. "\n") end + io.stderr:write("\n") + io.stderr:write("These files are preserved in the new worktree at:\n") + io.stderr:write(" " .. worktree_path .. "\n") + io.stderr:write("\n") + end - local confirm_code = os.execute("gum confirm '" .. confirm_msg .. "'") + -- Confirm with gum (unless -y/--yes) + if not skip_confirm then + local confirm_code = os.execute("gum confirm 'Convert to wt bare structure?'") if confirm_code ~= true then print("Aborted") os.exit(exit.EXIT_USER_ERROR) diff --git a/src/wt/cmd/add.lua b/src/wt/cmd/add.lua index 05b1bc23958217dd476cf55952f6209b7a932b9c..fb6ee94fd14342a746f8c4358190cab04710314e 100644 --- a/src/wt/cmd/add.lua +++ b/src/wt/cmd/add.lua @@ -61,11 +61,41 @@ function M.cmd_add(args) local target_path = path_mod.branch_to_path(root, branch, style, separator) - -- Check if target already exists + -- Check if worktree already exists for this branch (idempotent behavior) + local existing_wt = git.find_worktree_by_branch(git_dir, branch) + if existing_wt then + -- Validate the worktree path actually exists (not stale metadata) + local wt_check = io.open(existing_wt.path .. "/.git", "r") + if not wt_check then + shell.die( + "worktree for '" + .. branch + .. "' is registered but missing at " + .. existing_wt.path + .. "\nhint: run `git worktree prune` to clean up stale entries" + ) + end + wt_check:close() + + local project_config = config.load_project_config(root) + io.stderr:write("worktree already exists at " .. existing_wt.path .. "\n") + -- Warn if path differs from current config + if existing_wt.path ~= target_path then + io.stderr:write("note: current config would place it at " .. target_path .. "\n") + end + -- If running from root and hooks exist, offer to run them now + if not source_worktree and project_config.hooks then + io.stderr:write("hint: run `wt a` from inside a worktree to apply hooks from .wt.lua\n") + end + print(existing_wt.path) + return + end + + -- Check if target path has a .git but for a different branch (conflict) local check = io.open(target_path .. "/.git", "r") if check then check:close() - shell.die("worktree already exists at " .. target_path) + shell.die("directory already exists at " .. target_path .. " but is not a worktree for '" .. branch .. "'") end local output, code @@ -74,6 +104,12 @@ function M.cmd_add(args) if not start_point and source_worktree then start_point = git.get_worktree_branch(git_dir, source_worktree) end + + -- Check if start_point resolves to a valid commit (catches orphan branch case) + if start_point and not git.ref_has_commits(git_dir, start_point) then + shell.die("'" .. start_point .. "' has no commits yet; make an initial commit first") + end + -- Create new branch with worktree if start_point then output, code = shell.run_cmd( @@ -100,7 +136,10 @@ function M.cmd_add(args) end if #remotes > 1 then - shell.die("branch '" .. branch .. "' exists on multiple remotes: " .. table.concat(remotes, ", ")) + local hint = "use `-b " .. remotes[1] .. "/" .. branch .. "` to specify which remote to track" + shell.die( + "branch '" .. branch .. "' exists on multiple remotes: " .. table.concat(remotes, ", ") .. "\n" .. hint + ) end output, code = shell.run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -- " .. target_path .. " " .. branch) @@ -117,7 +156,7 @@ function M.cmd_add(args) 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") + io.stderr:write("hint: hooks skipped; run `wt a` from inside a worktree to apply hooks from .wt.lua\n") end print(target_path) diff --git a/src/wt/git.lua b/src/wt/git.lua index a3d3ca493c6eef906d37452f6f3ed582a950ab03..9e16a32a3fe4e9a9378d20319238aab6df841cb2 100644 --- a/src/wt/git.lua +++ b/src/wt/git.lua @@ -229,4 +229,30 @@ function M.branch_checked_out_at(git_dir, branch) return nil end +---Check if a ref (branch) has any commits +---@param git_dir string +---@param ref string +---@return boolean has_commits +function M.ref_has_commits(git_dir, ref) + return run_cmd_silent("GIT_DIR=" .. git_dir .. " git rev-parse --verify --quiet " .. ref) +end + +---Find worktree by branch name +---@param git_dir string +---@param branch string +---@return {path: string, branch: string}|nil +function M.find_worktree_by_branch(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 = M.parse_worktree_list(output) + for _, wt in ipairs(worktrees) do + if wt.branch == branch and not wt.bare then + return { path = wt.path, branch = wt.branch } + end + end + return nil +end + return M