1-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2--
3-- SPDX-License-Identifier: GPL-3.0-or-later
4
5-- Load main.lua as a module (it exports functions when required)
6package.path = package.path .. ";./?.lua"
7local wt = dofile("src/main.lua")
8
9describe("escape_pattern", function()
10 it("escapes percent sign", function()
11 assert.are.equal("100%%", wt.escape_pattern("100%"))
12 end)
13
14 it("escapes dot (wildcard)", function()
15 assert.are.equal("file%.txt", wt.escape_pattern("file.txt"))
16 end)
17
18 it("escapes hyphen", function()
19 assert.are.equal("my%-branch", wt.escape_pattern("my-branch"))
20 end)
21
22 it("escapes brackets", function()
23 assert.are.equal("%[test%]", wt.escape_pattern("[test]"))
24 end)
25
26 it("escapes parentheses", function()
27 assert.are.equal("func%(%)", wt.escape_pattern("func()"))
28 end)
29
30 it("escapes caret and dollar", function()
31 assert.are.equal("%^start end%$", wt.escape_pattern("^start end$"))
32 end)
33
34 it("escapes asterisk and question mark", function()
35 assert.are.equal("glob%*%?", wt.escape_pattern("glob*?"))
36 end)
37
38 it("escapes plus sign", function()
39 assert.are.equal("a%+b", wt.escape_pattern("a+b"))
40 end)
41
42 it("leaves alphanumeric and slashes unchanged", function()
43 assert.are.equal("feature/foo/bar123", wt.escape_pattern("feature/foo/bar123"))
44 end)
45end)
46
47describe("parse_branch_remotes", function()
48 describe("simple branches", function()
49 it("parses single remote", function()
50 local output = " origin/main\n"
51 local remotes = wt.parse_branch_remotes(output, "main")
52 assert.are.same({ "origin" }, remotes)
53 end)
54
55 it("parses multiple remotes", function()
56 local output = " origin/main\n upstream/main\n"
57 local remotes = wt.parse_branch_remotes(output, "main")
58 assert.are.same({ "origin", "upstream" }, remotes)
59 end)
60
61 it("returns empty for no matches", function()
62 local output = " origin/develop\n"
63 local remotes = wt.parse_branch_remotes(output, "main")
64 assert.are.same({}, remotes)
65 end)
66
67 it("handles empty output", function()
68 local remotes = wt.parse_branch_remotes("", "main")
69 assert.are.same({}, remotes)
70 end)
71 end)
72
73 describe("slashy branches (the tricky case)", function()
74 it("correctly parses feature/foo on origin", function()
75 local output = " origin/feature/foo\n"
76 local remotes = wt.parse_branch_remotes(output, "feature/foo")
77 assert.are.same({ "origin" }, remotes)
78 end)
79
80 it("correctly parses deeply nested branch", function()
81 local output = " upstream/user/alice/feature/auth\n"
82 local remotes = wt.parse_branch_remotes(output, "user/alice/feature/auth")
83 assert.are.same({ "upstream" }, remotes)
84 end)
85
86 it("handles multiple remotes with slashy branch", function()
87 local output = " origin/feature/auth\n upstream/feature/auth\n"
88 local remotes = wt.parse_branch_remotes(output, "feature/auth")
89 assert.are.same({ "origin", "upstream" }, remotes)
90 end)
91
92 it("does not confuse similar branch names", function()
93 local output = " origin/feature/foo\n origin/feature/foobar\n"
94 local remotes = wt.parse_branch_remotes(output, "feature/foo")
95 assert.are.same({ "origin" }, remotes)
96 end)
97 end)
98
99 describe("special characters in branch names", function()
100 it("handles dots in branch name", function()
101 local output = " origin/release/1.2.3\n"
102 local remotes = wt.parse_branch_remotes(output, "release/1.2.3")
103 assert.are.same({ "origin" }, remotes)
104 end)
105
106 it("handles hyphens in branch name", function()
107 local output = " origin/fix/bug-123-thing\n"
108 local remotes = wt.parse_branch_remotes(output, "fix/bug-123-thing")
109 assert.are.same({ "origin" }, remotes)
110 end)
111
112 it("handles underscores", function()
113 local output = " origin/my_feature_branch\n"
114 local remotes = wt.parse_branch_remotes(output, "my_feature_branch")
115 assert.are.same({ "origin" }, remotes)
116 end)
117 end)
118
119 describe("edge cases", function()
120 it("handles extra whitespace", function()
121 local output = " origin/main \n"
122 local remotes = wt.parse_branch_remotes(output, "main")
123 assert.are.same({ "origin" }, remotes)
124 end)
125
126 it("ignores HEAD pointer lines", function()
127 -- git branch -r sometimes shows: origin/HEAD -> origin/main
128 local output = " origin/HEAD -> origin/main\n origin/main\n"
129 local remotes = wt.parse_branch_remotes(output, "main")
130 assert.are.same({ "origin" }, remotes)
131 end)
132
133 it("handles branch name that looks like remote/branch", function()
134 local output = " upstream/origin/main\n"
135 local remotes = wt.parse_branch_remotes(output, "origin/main")
136 assert.are.same({ "upstream" }, remotes)
137 end)
138 end)
139end)
140
141describe("parse_worktree_list", function()
142 describe("basic parsing", function()
143 it("parses single worktree with branch", function()
144 local output = [[worktree /home/user/project/main
145HEAD abc123def456
146branch refs/heads/main
147]]
148 local worktrees = wt.parse_worktree_list(output)
149 assert.are.equal(1, #worktrees)
150 assert.are.equal("/home/user/project/main", worktrees[1].path)
151 assert.are.equal("main", worktrees[1].branch)
152 assert.are.equal("abc123def456", worktrees[1].head)
153 end)
154
155 it("parses multiple worktrees", function()
156 local output = [[worktree /home/user/project/.bare
157bare
158
159worktree /home/user/project/main
160HEAD abc123
161branch refs/heads/main
162
163worktree /home/user/project/feature/auth
164HEAD def456
165branch refs/heads/feature/auth
166]]
167 local worktrees = wt.parse_worktree_list(output)
168 assert.are.equal(3, #worktrees)
169
170 assert.are.equal("/home/user/project/.bare", worktrees[1].path)
171 assert.is_true(worktrees[1].bare)
172
173 assert.are.equal("/home/user/project/main", worktrees[2].path)
174 assert.are.equal("main", worktrees[2].branch)
175
176 assert.are.equal("/home/user/project/feature/auth", worktrees[3].path)
177 assert.are.equal("feature/auth", worktrees[3].branch)
178 end)
179 end)
180
181 describe("special states", function()
182 it("parses detached HEAD worktree", function()
183 local output = [[worktree /home/user/project/detached
184HEAD abc123
185detached
186]]
187 local worktrees = wt.parse_worktree_list(output)
188 assert.are.equal(1, #worktrees)
189 assert.is_true(worktrees[1].detached)
190 assert.is_nil(worktrees[1].branch)
191 end)
192
193 it("parses bare repository entry", function()
194 local output = [[worktree /home/user/project/.bare
195bare
196]]
197 local worktrees = wt.parse_worktree_list(output)
198 assert.are.equal(1, #worktrees)
199 assert.is_true(worktrees[1].bare)
200 end)
201 end)
202
203 describe("branch name handling", function()
204 it("strips refs/heads/ prefix", function()
205 local output = [[worktree /path
206HEAD abc
207branch refs/heads/my-branch
208]]
209 local worktrees = wt.parse_worktree_list(output)
210 assert.are.equal("my-branch", worktrees[1].branch)
211 end)
212
213 it("handles slashy branch names", function()
214 local output = [[worktree /path
215HEAD abc
216branch refs/heads/feature/auth/oauth2
217]]
218 local worktrees = wt.parse_worktree_list(output)
219 assert.are.equal("feature/auth/oauth2", worktrees[1].branch)
220 end)
221 end)
222
223 describe("edge cases", function()
224 it("handles empty output", function()
225 local worktrees = wt.parse_worktree_list("")
226 assert.are.same({}, worktrees)
227 end)
228
229 it("handles paths with spaces", function()
230 local output = [[worktree /home/user/my project/main
231HEAD abc
232branch refs/heads/main
233]]
234 local worktrees = wt.parse_worktree_list(output)
235 assert.are.equal("/home/user/my project/main", worktrees[1].path)
236 end)
237 end)
238end)
239
240describe("path_inside", function()
241 describe("exact matches", function()
242 it("returns true for identical paths", function()
243 assert.is_true(wt.path_inside("/home/user", "/home/user"))
244 end)
245
246 it("returns true when both have trailing slashes", function()
247 assert.is_true(wt.path_inside("/home/user/", "/home/user/"))
248 end)
249
250 it("returns true with mixed trailing slashes", function()
251 assert.is_true(wt.path_inside("/home/user", "/home/user/"))
252 assert.is_true(wt.path_inside("/home/user/", "/home/user"))
253 end)
254 end)
255
256 describe("containment", function()
257 it("returns true for direct child", function()
258 assert.is_true(wt.path_inside("/home/user/project", "/home/user"))
259 end)
260
261 it("returns true for deep nesting", function()
262 assert.is_true(wt.path_inside("/home/user/project/src/lib", "/home/user"))
263 end)
264
265 it("returns false for sibling", function()
266 assert.is_false(wt.path_inside("/home/alice", "/home/bob"))
267 end)
268
269 it("returns false for parent", function()
270 assert.is_false(wt.path_inside("/home", "/home/user"))
271 end)
272
273 it("returns false for unrelated paths", function()
274 assert.is_false(wt.path_inside("/var/log", "/home/user"))
275 end)
276 end)
277
278 describe("prefix edge cases", function()
279 it("does not match partial directory names", function()
280 -- /home/user-backup is NOT inside /home/user
281 assert.is_false(wt.path_inside("/home/user-backup", "/home/user"))
282 end)
283
284 it("does not match substring prefixes", function()
285 assert.is_false(wt.path_inside("/home/username", "/home/user"))
286 end)
287
288 it("correctly handles similar names", function()
289 assert.is_false(wt.path_inside("/project-old/src", "/project"))
290 assert.is_true(wt.path_inside("/project/src", "/project"))
291 end)
292 end)
293
294 describe("root paths", function()
295 it("everything is inside root", function()
296 assert.is_true(wt.path_inside("/home/user", "/"))
297 end)
298
299 it("root is inside root", function()
300 assert.is_true(wt.path_inside("/", "/"))
301 end)
302 end)
303end)
304
305describe("summarize_hooks", function()
306 describe("single hook type", function()
307 it("summarizes copy hooks", function()
308 local hooks = { copy = { ".env", "Makefile" } }
309 local summary = wt.summarize_hooks(hooks)
310 assert.are.equal("copy: .env, Makefile", summary)
311 end)
312
313 it("summarizes symlink hooks", function()
314 local hooks = { symlink = { "node_modules" } }
315 local summary = wt.summarize_hooks(hooks)
316 assert.are.equal("symlink: node_modules", summary)
317 end)
318
319 it("summarizes run hooks", function()
320 local hooks = { run = { "npm install", "make setup" } }
321 local summary = wt.summarize_hooks(hooks)
322 assert.are.equal("run: npm install, make setup", summary)
323 end)
324 end)
325
326 describe("multiple hook types", function()
327 it("combines all types with semicolons", function()
328 local hooks = {
329 copy = { ".env" },
330 symlink = { "node_modules" },
331 run = { "npm install" },
332 }
333 local summary = wt.summarize_hooks(hooks)
334 assert.are.equal("copy: .env; symlink: node_modules; run: npm install", summary)
335 end)
336 end)
337
338 describe("truncation", function()
339 it("shows first 3 items with count for copy", function()
340 local hooks = { copy = { "a", "b", "c", "d", "e" } }
341 local summary = wt.summarize_hooks(hooks)
342 assert.are.equal("copy: a, b, c (+2 more)", summary)
343 end)
344
345 it("shows first 3 items with count for symlink", function()
346 local hooks = { symlink = { "1", "2", "3", "4" } }
347 local summary = wt.summarize_hooks(hooks)
348 assert.are.equal("symlink: 1, 2, 3 (+1 more)", summary)
349 end)
350
351 it("shows first 3 items with count for run", function()
352 local hooks = { run = { "cmd1", "cmd2", "cmd3", "cmd4", "cmd5", "cmd6" } }
353 local summary = wt.summarize_hooks(hooks)
354 assert.are.equal("run: cmd1, cmd2, cmd3 (+3 more)", summary)
355 end)
356
357 it("does not add suffix for exactly 3 items", function()
358 local hooks = { copy = { "a", "b", "c" } }
359 local summary = wt.summarize_hooks(hooks)
360 assert.are.equal("copy: a, b, c", summary)
361 end)
362 end)
363
364 describe("edge cases", function()
365 it("returns empty string for empty hooks", function()
366 local summary = wt.summarize_hooks({})
367 assert.are.equal("", summary)
368 end)
369
370 it("returns empty string for nil hooks table", function()
371 local summary = wt.summarize_hooks({})
372 assert.are.equal("", summary)
373 end)
374
375 it("skips empty arrays", function()
376 local hooks = { copy = {}, run = { "npm install" } }
377 local summary = wt.summarize_hooks(hooks)
378 assert.are.equal("run: npm install", summary)
379 end)
380 end)
381end)