@@ -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 <amolith@secluded.site>
+_EMBEDDED_MODULES["wt.help"] = [=[-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
--
-- SPDX-License-Identifier: GPL-3.0-or-later
@@ -752,8 +818,18 @@ M.COMMAND_HELP = {
"Options:",
" --remote <name> Add configured remote from ~/.config/wt/config.lua",
" Can be specified multiple times",
- " --own Treat as your own project: first remote becomes 'origin'",
- " (default: 'origin' renamed to 'upstream', your remotes added)",
+ " --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 <amolith@secluded.site>
+_EMBEDDED_MODULES["wt.cmd.clone"] = [=[-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
--
-- 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 <amolith@secluded.site>
+_EMBEDDED_MODULES["wt.cmd.new"] = [=[-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
--
-- 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 <amolith@secluded.site>
+_EMBEDDED_MODULES["wt.cmd.add"] = [=[-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
--
-- 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 <amolith@secluded.site>
+_EMBEDDED_MODULES["wt.cmd.remove"] = [=[-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
--
-- 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 <amolith@secluded.site>
+_EMBEDDED_MODULES["wt.cmd.list"] = [=[-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
--
-- 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 <amolith@secluded.site>
+_EMBEDDED_MODULES["wt.cmd.fetch"] = [=[-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
--
-- 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 <amolith@secluded.site>
--
@@ -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 <worktree> && 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)
@@ -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)