git_parsing_spec.lua

  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)