feat(add): improve UX with idempotency and hints

Amolith created

- Make wt a idempotent: succeed if worktree exists for branch
- Validate existing worktree path (detect stale entries)
- Warn if existing path differs from current config
- Friendlier orphan branch error with actionable message
- Add hint for multiple remotes case
- Improve hook skip warning to be more actionable

Add helper functions to git.lua:
- ref_has_commits() for orphan detection
- find_worktree_by_branch() for idempotency check

Assisted-by: Claude Opus 4.5 via Amp

Change summary

dist/wt            | 252 +++++++++++++++++++++++++++++++++++++++++------
src/wt/cmd/add.lua |  47 ++++++++
src/wt/git.lua     |  26 ++++
3 files changed, 289 insertions(+), 36 deletions(-)

Detailed changes

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

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)

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