cmd_remove_spec.lua

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