cmd_remove_spec.lua

  1package.path = package.path .. ";./?.lua"
  2local wt = dofile("src/main.lua")
  3
  4describe("cmd_remove", function()
  5	local temp_dir
  6	local original_cwd
  7
  8	setup(function()
  9		local handle = io.popen("pwd")
 10		if handle then
 11			original_cwd = handle:read("*l")
 12			handle:close()
 13		end
 14		handle = io.popen("mktemp -d")
 15		if handle then
 16			temp_dir = handle:read("*l")
 17			handle:close()
 18		end
 19	end)
 20
 21	teardown(function()
 22		if original_cwd then
 23			os.execute("cd " .. original_cwd)
 24		end
 25		if temp_dir then
 26			os.execute("rm -rf " .. temp_dir)
 27		end
 28	end)
 29
 30	---Helper to create a wt-managed project with worktrees
 31	---@param name string project name
 32	---@param branches string[] branches to create worktrees for
 33	---@return string project_path
 34	local function create_wt_project(name, branches)
 35		local project = temp_dir .. "/" .. name
 36		os.execute("mkdir -p " .. project .. "/.bare")
 37		os.execute("git init --bare " .. project .. "/.bare")
 38		local f = io.open(project .. "/.git", "w")
 39		if f then
 40			f:write("gitdir: ./.bare\n")
 41			f:close()
 42		end
 43
 44		local first_branch = branches[1] or "main"
 45		os.execute("GIT_DIR=" .. project .. "/.bare git symbolic-ref HEAD refs/heads/" .. first_branch)
 46		local first_wt = project .. "/" .. first_branch
 47		os.execute("GIT_DIR=" .. project .. "/.bare git worktree add --orphan -b " .. first_branch .. " -- " .. first_wt)
 48		os.execute("cd " .. first_wt .. " && git commit --allow-empty -m 'initial'")
 49
 50		for i = 2, #branches do
 51			local branch = branches[i]
 52			local wt_path = project .. "/" .. branch:gsub("/", "_")
 53			local cmd = "GIT_DIR=" .. project .. "/.bare git worktree add -b "
 54				.. branch .. " -- " .. wt_path .. " " .. first_branch
 55			os.execute(cmd)
 56		end
 57
 58		return project
 59	end
 60
 61	describe("worktree not found", function()
 62		it("errors when target worktree does not exist", function()
 63			if not temp_dir then
 64				pending("temp_dir not available")
 65				return
 66			end
 67			local project = create_wt_project("no-wt", { "main" })
 68
 69			local output, code = wt.run_cmd("cd " .. project .. " && lua " .. original_cwd .. "/src/main.lua r nonexistent 2>&1")
 70			assert.are_not.equal(0, code)
 71			assert.is_truthy(output:match("no worktree found") or output:match("not found"))
 72		end)
 73	end)
 74
 75	describe("cwd inside target worktree", function()
 76		it("errors when trying to remove worktree while inside it", function()
 77			if not temp_dir then
 78				pending("temp_dir not available")
 79				return
 80			end
 81			local project = create_wt_project("inside-wt", { "main", "feature" })
 82			local feature_wt = project .. "/feature"
 83
 84			local output, code = wt.run_cmd("cd " .. feature_wt .. " && lua " .. original_cwd .. "/src/main.lua r feature 2>&1")
 85			assert.are_not.equal(0, code)
 86			assert.is_truthy(output:match("inside") or output:match("cannot remove"))
 87		end)
 88	end)
 89
 90	describe("uncommitted changes", function()
 91		it("errors when worktree has uncommitted changes without -f", function()
 92			if not temp_dir then
 93				pending("temp_dir not available")
 94				return
 95			end
 96			local project = create_wt_project("dirty-wt", { "main", "dirty" })
 97			local dirty_wt = project .. "/dirty"
 98
 99			local f = io.open(dirty_wt .. "/uncommitted.txt", "w")
100			if f then
101				f:write("dirty file\n")
102				f:close()
103			end
104			os.execute("cd " .. dirty_wt .. " && git add uncommitted.txt")
105
106			local output, code = wt.run_cmd("cd " .. project .. "/main && lua " .. original_cwd .. "/src/main.lua r dirty 2>&1")
107			assert.are_not.equal(0, code)
108			assert.is_truthy(output:match("uncommitted") or output:match("%-f") or output:match("force"))
109		end)
110
111		it("succeeds with -f when worktree has uncommitted changes", function()
112			if not temp_dir then
113				pending("temp_dir not available")
114				return
115			end
116			local project = create_wt_project("force-dirty", { "main", "force-test" })
117			local target_wt = project .. "/force-test"
118
119			local f = io.open(target_wt .. "/uncommitted.txt", "w")
120			if f then
121				f:write("dirty file\n")
122				f:close()
123			end
124			os.execute("cd " .. target_wt .. " && git add uncommitted.txt")
125
126			local cmd = "cd " .. project .. "/main && lua " .. original_cwd .. "/src/main.lua r force-test -f 2>&1"
127			local output, code = wt.run_cmd(cmd)
128			assert.are.equal(0, code)
129			assert.is_truthy(output:match("[Rr]emoved") or output:match("[Ss]uccess"))
130
131			local check = io.open(target_wt .. "/.git", "r")
132			assert.is_nil(check)
133		end)
134	end)
135
136	describe("branch deletion with -b", function()
137		it("refuses to delete branch if it's bare repo HEAD", function()
138			if not temp_dir then
139				pending("temp_dir not available")
140				return
141			end
142			local project = create_wt_project("bare-head", { "main", "other" })
143
144			os.execute("GIT_DIR=" .. project .. "/.bare git worktree remove -- " .. project .. "/main")
145
146			os.execute("GIT_DIR=" .. project .. "/.bare git symbolic-ref HEAD refs/heads/main")
147			os.execute("GIT_DIR=" .. project .. "/.bare git worktree add -- " .. project .. "/main main")
148
149			local cmd = "cd " .. project .. "/other && lua " .. original_cwd .. "/src/main.lua r main -b 2>&1"
150			local output, _ = wt.run_cmd(cmd)
151			assert.is_truthy(output:match("HEAD") or output:match("retained") or output:match("cannot delete"))
152		end)
153
154		it("refuses to delete branch if checked out in another worktree", function()
155			if not temp_dir then
156				pending("temp_dir not available")
157				return
158			end
159			local project = create_wt_project("multi-checkout", { "main", "feature", "other" })
160
161			local cmd = "cd " .. project .. "/other && lua " .. original_cwd .. "/src/main.lua r feature -b 2>&1"
162			local output, code = wt.run_cmd(cmd)
163			assert.are.equal(0, code)
164			assert.is_truthy(output:match("[Rr]emoved"))
165			local ref_cmd = "GIT_DIR=" .. project .. "/.bare git show-ref --verify --quiet refs/heads/feature"
166			local branch_exists = wt.run_cmd_silent(ref_cmd)
167			assert.is_false(branch_exists)
168		end)
169
170		it("deletes branch when no conflicts exist", function()
171			if not temp_dir then
172				pending("temp_dir not available")
173				return
174			end
175			local project = create_wt_project("clean-delete", { "main", "deleteme" })
176
177			os.execute("GIT_DIR=" .. project .. "/.bare git symbolic-ref HEAD refs/heads/main")
178
179			local cmd = "cd " .. project .. "/main && lua " .. original_cwd .. "/src/main.lua r deleteme -b 2>&1"
180			local output, code = wt.run_cmd(cmd)
181			assert.are.equal(0, code)
182			assert.is_truthy(output:match("branch") and output:match("[Rr]emoved"))
183
184			local ref_cmd = "GIT_DIR=" .. project .. "/.bare git show-ref --verify --quiet refs/heads/deleteme"
185			local branch_exists = wt.run_cmd_silent(ref_cmd)
186			assert.is_false(branch_exists)
187		end)
188	end)
189
190	describe("successful removal", function()
191		it("removes worktree without -b", function()
192			if not temp_dir then
193				pending("temp_dir not available")
194				return
195			end
196			local project = create_wt_project("simple-remove", { "main", "removeme" })
197
198			local cmd = "cd " .. project .. "/main && lua " .. original_cwd .. "/src/main.lua r removeme 2>&1"
199			local output, code = wt.run_cmd(cmd)
200			assert.are.equal(0, code)
201			assert.is_truthy(output:match("[Rr]emoved"))
202
203			local check = io.open(project .. "/removeme/.git", "r")
204			assert.is_nil(check)
205
206			local ref_cmd = "GIT_DIR=" .. project .. "/.bare git show-ref --verify --quiet refs/heads/removeme"
207			local branch_exists = wt.run_cmd_silent(ref_cmd)
208			assert.is_true(branch_exists)
209		end)
210
211		it("finds worktree by branch regardless of path style", function()
212			if not temp_dir then
213				pending("temp_dir not available")
214				return
215			end
216			local project = create_wt_project("path-agnostic", { "main" })
217
218			-- Create worktree at flat-style path even though default config is nested
219			local branch = "feature/auth"
220			local flat_path = project .. "/feature_auth"
221			local wt_cmd = "GIT_DIR=" .. project .. "/.bare git worktree add -b "
222				.. branch .. " -- " .. flat_path .. " main 2>&1"
223			os.execute(wt_cmd)
224
225			-- wt r should find it by branch, not by computed path
226			local cmd = "cd " .. project .. "/main && lua " .. original_cwd .. "/src/main.lua r feature/auth 2>&1"
227			local output, code = wt.run_cmd(cmd)
228			assert.are.equal(0, code)
229			assert.is_truthy(output:match("[Rr]emoved"))
230
231			-- Verify worktree is gone
232			local check = io.open(flat_path .. "/.git", "r")
233			assert.is_nil(check)
234		end)
235
236		it("finds worktree at arbitrary path", function()
237			if not temp_dir then
238				pending("temp_dir not available")
239				return
240			end
241			local project = create_wt_project("arbitrary-path", { "main" })
242
243			-- Create worktree at completely arbitrary path
244			local branch = "my-feature"
245			local arbitrary_path = project .. "/some/weird/location"
246			os.execute("mkdir -p " .. project .. "/some/weird")
247			local wt_cmd = "GIT_DIR=" .. project .. "/.bare git worktree add -b "
248				.. branch .. " -- " .. arbitrary_path .. " main 2>&1"
249			os.execute(wt_cmd)
250
251			local cmd = "cd " .. project .. "/main && lua " .. original_cwd .. "/src/main.lua r my-feature 2>&1"
252			local output, code = wt.run_cmd(cmd)
253			assert.are.equal(0, code)
254			assert.is_truthy(output:match("[Rr]emoved"))
255		end)
256	end)
257
258	describe("usage errors", function()
259		it("errors when no branch argument provided", function()
260			if not temp_dir then
261				pending("temp_dir not available")
262				return
263			end
264			local project = create_wt_project("no-arg", { "main" })
265
266			local output, code = wt.run_cmd("cd " .. project .. " && lua " .. original_cwd .. "/src/main.lua r 2>&1")
267			assert.are_not.equal(0, code)
268			assert.is_truthy(output:match("usage") or output:match("Usage"))
269		end)
270	end)
271end)