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