blob: 0c86550d114ceebad6b99b6c2eea869cccb5005a [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package bashkit
2
3import (
4 "strings"
5 "testing"
banksean19a32ea2025-07-18 18:29:31 +00006
7 "mvdan.cc/sh/v3/syntax"
Earl Lee2e463fb2025-04-17 11:22:22 -07008)
9
10func TestCheck(t *testing.T) {
11 tests := []struct {
12 name string
13 script string
14 wantErr bool
15 errMatch string // string to match in error message, if wantErr is true
16 }{
17 {
18 name: "valid script",
19 script: "echo hello world",
20 wantErr: false,
21 errMatch: "",
22 },
23 {
24 name: "invalid syntax",
25 script: "echo 'unterminated string",
26 wantErr: false, // As per implementation, syntax errors are not flagged
27 errMatch: "",
28 },
29 {
30 name: "git config user.name",
31 script: "git config user.name 'John Doe'",
32 wantErr: true,
33 errMatch: "changing git config username/email is not allowed",
34 },
35 {
36 name: "git config user.email",
37 script: "git config user.email 'john@example.com'",
38 wantErr: true,
39 errMatch: "changing git config username/email is not allowed",
40 },
41 {
42 name: "git config with flag user.name",
43 script: "git config --global user.name 'John Doe'",
44 wantErr: true,
45 errMatch: "changing git config username/email is not allowed",
46 },
47 {
48 name: "git config with other setting",
49 script: "git config core.editor vim",
50 wantErr: false,
51 errMatch: "",
52 },
53 {
54 name: "git without config",
55 script: "git commit -m 'Add feature'",
56 wantErr: false,
57 errMatch: "",
58 },
59 {
60 name: "multiline script with proper escaped newlines",
61 script: "echo 'Setting up git...' && git config user.name 'John Doe' && echo 'Done!'",
62 wantErr: true,
63 errMatch: "changing git config username/email is not allowed",
64 },
65 {
66 name: "multiline script with backticks",
67 script: `echo 'Setting up git...'
68git config user.name 'John Doe'
69echo 'Done!'`,
70 wantErr: true,
71 errMatch: "changing git config username/email is not allowed",
72 },
73 {
74 name: "git config with variable",
75 script: "NAME='John Doe'\ngit config user.name $NAME",
76 wantErr: true,
77 errMatch: "changing git config username/email is not allowed",
78 },
79 {
80 name: "only git command",
81 script: "git",
82 wantErr: false,
83 errMatch: "",
84 },
85 {
86 name: "read git config",
87 script: "git config user.name",
88 wantErr: false,
89 errMatch: "",
90 },
91 {
92 name: "commented git config",
93 script: "# git config user.name 'John Doe'",
94 wantErr: false,
95 errMatch: "",
96 },
Josh Bleecher Snyderdbfd36a2025-05-23 20:57:50 +000097 // Git add validation tests
98 {
99 name: "git add with -A flag",
100 script: "git add -A",
101 wantErr: true,
102 errMatch: "blind git add commands",
103 },
104 {
105 name: "git add with --all flag",
106 script: "git add --all",
107 wantErr: true,
108 errMatch: "blind git add commands",
109 },
110 {
111 name: "git add with dot",
112 script: "git add .",
113 wantErr: true,
114 errMatch: "blind git add commands",
115 },
116 {
117 name: "git add with asterisk",
118 script: "git add *",
119 wantErr: true,
120 errMatch: "blind git add commands",
121 },
122 {
123 name: "git add with multiple flags including -A",
124 script: "git add -v -A",
125 wantErr: true,
126 errMatch: "blind git add commands",
127 },
128 {
129 name: "git add with specific file",
130 script: "git add main.go",
131 wantErr: false,
132 errMatch: "",
133 },
134 {
135 name: "git add with multiple specific files",
136 script: "git add main.go utils.go",
137 wantErr: false,
138 errMatch: "",
139 },
140 {
141 name: "git add with directory path",
142 script: "git add src/main.go",
143 wantErr: false,
144 errMatch: "",
145 },
146 {
147 name: "git add with git flags before add",
148 script: "git -C /path/to/repo add -A",
149 wantErr: true,
150 errMatch: "blind git add commands",
151 },
152 {
153 name: "git add with valid flags",
154 script: "git add -v main.go",
155 wantErr: false,
156 errMatch: "",
157 },
158 {
159 name: "git command without add",
160 script: "git status",
161 wantErr: false,
162 errMatch: "",
163 },
164 {
165 name: "multiline script with blind git add",
166 script: "echo 'Adding files' && git add -A && git commit -m 'Update'",
167 wantErr: true,
168 errMatch: "blind git add commands",
169 },
170 {
171 name: "git add with pattern that looks like blind but is specific",
172 script: "git add file.A",
173 wantErr: false,
174 errMatch: "",
175 },
176 {
177 name: "commented blind git add",
178 script: "# git add -A",
179 wantErr: false,
180 errMatch: "",
181 },
Earl Lee2e463fb2025-04-17 11:22:22 -0700182 }
183
184 for _, tc := range tests {
185 t.Run(tc.name, func(t *testing.T) {
186 err := Check(tc.script)
187 if (err != nil) != tc.wantErr {
188 t.Errorf("Check() error = %v, wantErr %v", err, tc.wantErr)
189 return
190 }
191 if tc.wantErr && err != nil && !strings.Contains(err.Error(), tc.errMatch) {
192 t.Errorf("Check() error message = %v, want containing %v", err, tc.errMatch)
193 }
194 })
195 }
196}
Josh Bleecher Snyderdae19072025-04-30 01:08:57 +0000197
198func TestWillRunGitCommit(t *testing.T) {
199 tests := []struct {
200 name string
201 script string
202 wantCommit bool
203 }{
204 {
205 name: "simple git commit",
206 script: "git commit -m 'Add feature'",
207 wantCommit: true,
208 },
209 {
210 name: "git command without commit",
211 script: "git status",
212 wantCommit: false,
213 },
214 {
215 name: "multiline script with git commit",
216 script: "echo 'Making changes' && git add . && git commit -m 'Update files'",
217 wantCommit: true,
218 },
219 {
220 name: "multiline script without git commit",
221 script: "echo 'Checking status' && git status",
222 wantCommit: false,
223 },
224 {
225 name: "script with commented git commit",
226 script: "# git commit -m 'This is commented out'",
227 wantCommit: false,
228 },
229 {
230 name: "git commit with variables",
231 script: "MSG='Fix bug' && git commit -m 'Using variable'",
232 wantCommit: true,
233 },
234 {
235 name: "only git command",
236 script: "git",
237 wantCommit: false,
238 },
239 {
240 name: "script with invalid syntax",
241 script: "git commit -m 'unterminated string",
242 wantCommit: false,
243 },
244 {
245 name: "commit used in different context",
246 script: "echo 'commit message'",
247 wantCommit: false,
248 },
249 {
250 name: "git with flags before commit",
251 script: "git -C /path/to/repo commit -m 'Update'",
252 wantCommit: true,
253 },
254 {
255 name: "git with multiple flags",
256 script: "git --git-dir=.git -C repo commit -a -m 'Update'",
257 wantCommit: true,
258 },
259 {
260 name: "git with env vars",
261 script: "GIT_AUTHOR_NAME=\"Josh Bleecher Snyder\" GIT_AUTHOR_EMAIL=\"josharian@gmail.com\" git commit -am \"Updated code\"",
262 wantCommit: true,
263 },
264 {
265 name: "git with redirections",
266 script: "git commit -m 'Fix issue' > output.log 2>&1",
267 wantCommit: true,
268 },
269 {
270 name: "git with piped commands",
271 script: "echo 'Committing' | git commit -F -",
272 wantCommit: true,
273 },
274 }
275
276 for _, tc := range tests {
277 t.Run(tc.name, func(t *testing.T) {
278 gotCommit, err := WillRunGitCommit(tc.script)
279 if err != nil {
280 t.Errorf("WillRunGitCommit() error = %v", err)
281 return
282 }
283 if gotCommit != tc.wantCommit {
284 t.Errorf("WillRunGitCommit() = %v, want %v", gotCommit, tc.wantCommit)
285 }
286 })
287 }
288}
banksean19a32ea2025-07-18 18:29:31 +0000289
290func TestSketchWipBranchProtection(t *testing.T) {
291 tests := []struct {
292 name string
293 script string
294 wantErr bool
295 errMatch string
296 resetBefore bool // if true, reset warning state before test
297 }{
298 {
299 name: "git branch rename sketch-wip",
300 script: "git branch -m sketch-wip new-branch",
301 wantErr: true,
Josh Bleecher Snyder106f6f52025-07-21 15:22:00 -0700302 errMatch: "cannot leave 'sketch-wip' branch",
banksean19a32ea2025-07-18 18:29:31 +0000303 resetBefore: true,
304 },
305 {
306 name: "git branch force rename sketch-wip",
307 script: "git branch -M sketch-wip new-branch",
308 wantErr: false, // second call should not error (already warned)
309 errMatch: "",
310 resetBefore: false,
311 },
312 {
313 name: "git checkout to other branch",
314 script: "git checkout main",
315 wantErr: false, // third call should not error (already warned)
316 errMatch: "",
317 resetBefore: false,
318 },
319 {
320 name: "git switch to other branch",
321 script: "git switch main",
322 wantErr: false, // fourth call should not error (already warned)
323 errMatch: "",
324 resetBefore: false,
325 },
326 {
327 name: "git checkout file (should be allowed)",
328 script: "git checkout -- file.txt",
329 wantErr: false,
330 errMatch: "",
331 resetBefore: false,
332 },
333 {
334 name: "git checkout path (should be allowed)",
335 script: "git checkout -- src/main.go",
336 wantErr: false,
337 errMatch: "",
338 resetBefore: false,
339 },
340 {
341 name: "git commit (should be allowed)",
342 script: "git commit -m 'test'",
343 wantErr: false,
344 errMatch: "",
345 resetBefore: false,
346 },
347 {
348 name: "git status (should be allowed)",
349 script: "git status",
350 wantErr: false,
351 errMatch: "",
352 resetBefore: false,
353 },
354 {
355 name: "git branch rename other branch (should be allowed)",
356 script: "git branch -m old-branch new-branch",
357 wantErr: false,
358 errMatch: "",
359 resetBefore: false,
360 },
361 }
362
363 for _, tc := range tests {
364 t.Run(tc.name, func(t *testing.T) {
365 if tc.resetBefore {
366 ResetSketchWipWarning()
367 }
368 err := Check(tc.script)
369 if (err != nil) != tc.wantErr {
370 t.Errorf("Check() error = %v, wantErr %v", err, tc.wantErr)
371 return
372 }
373 if tc.wantErr && err != nil && !strings.Contains(err.Error(), tc.errMatch) {
374 t.Errorf("Check() error message = %v, want containing %v", err, tc.errMatch)
375 }
376 })
377 }
378}
379
380func TestHasSketchWipBranchChanges(t *testing.T) {
381 tests := []struct {
382 name string
383 script string
384 wantHas bool
385 }{
386 {
387 name: "git branch rename sketch-wip",
388 script: "git branch -m sketch-wip new-branch",
389 wantHas: true,
390 },
391 {
392 name: "git branch force rename sketch-wip",
393 script: "git branch -M sketch-wip new-branch",
394 wantHas: true,
395 },
396 {
397 name: "git checkout to branch",
398 script: "git checkout main",
399 wantHas: true,
400 },
401 {
402 name: "git switch to branch",
403 script: "git switch main",
404 wantHas: true,
405 },
406 {
407 name: "git checkout file",
408 script: "git checkout -- file.txt",
409 wantHas: false,
410 },
411 {
412 name: "git checkout path",
413 script: "git checkout src/main.go",
414 wantHas: false,
415 },
416 {
417 name: "git checkout with .extension",
418 script: "git checkout file.go",
419 wantHas: false,
420 },
421 {
422 name: "git status",
423 script: "git status",
424 wantHas: false,
425 },
426 {
427 name: "git commit",
428 script: "git commit -m 'test'",
429 wantHas: false,
430 },
431 {
432 name: "git branch rename other",
433 script: "git branch -m old-branch new-branch",
434 wantHas: false,
435 },
436 {
437 name: "git switch with flag",
438 script: "git switch -c new-branch",
439 wantHas: false,
440 },
441 {
442 name: "git checkout with flag",
443 script: "git checkout -b new-branch",
444 wantHas: false,
445 },
446 {
447 name: "not a git command",
448 script: "echo hello",
449 wantHas: false,
450 },
451 {
452 name: "empty command",
453 script: "",
454 wantHas: false,
455 },
456 }
457
458 for _, tc := range tests {
459 t.Run(tc.name, func(t *testing.T) {
460 r := strings.NewReader(tc.script)
461 parser := syntax.NewParser()
462 file, err := parser.Parse(r, "")
463 if err != nil {
464 if tc.wantHas {
465 t.Errorf("Parse error: %v", err)
466 }
467 return
468 }
469
470 found := false
471 syntax.Walk(file, func(node syntax.Node) bool {
472 callExpr, ok := node.(*syntax.CallExpr)
473 if !ok {
474 return true
475 }
476 if hasSketchWipBranchChanges(callExpr) {
477 found = true
478 return false
479 }
480 return true
481 })
482
483 if found != tc.wantHas {
484 t.Errorf("hasSketchWipBranchChanges() = %v, want %v", found, tc.wantHas)
485 }
486 })
487 }
488}
489
490func TestEdgeCases(t *testing.T) {
491 tests := []struct {
492 name string
493 script string
494 wantErr bool
495 resetBefore bool // if true, reset warning state before test
496 }{
497 {
498 name: "git branch -m with current branch to sketch-wip (should be allowed)",
499 script: "git branch -m current-branch sketch-wip",
500 wantErr: false,
501 resetBefore: true,
502 },
503 {
504 name: "git branch -m sketch-wip with no destination (should be blocked)",
505 script: "git branch -m sketch-wip",
506 wantErr: true,
507 resetBefore: true,
508 },
509 {
510 name: "git branch -M with current branch to sketch-wip (should be allowed)",
511 script: "git branch -M current-branch sketch-wip",
512 wantErr: false,
513 resetBefore: true,
514 },
515 {
516 name: "git checkout with -- flags (should be allowed)",
517 script: "git checkout -- --weird-filename",
518 wantErr: false,
519 resetBefore: true,
520 },
521 {
522 name: "git switch with create flag (should be allowed)",
523 script: "git switch --create new-branch",
524 wantErr: false,
525 resetBefore: true,
526 },
527 {
528 name: "complex git command with sketch-wip rename",
529 script: "git add . && git commit -m \"test\" && git branch -m sketch-wip production",
530 wantErr: true,
531 resetBefore: true,
532 },
533 {
534 name: "git switch with -c short form (should be allowed)",
535 script: "git switch -c feature-branch",
536 wantErr: false,
537 resetBefore: true,
538 },
539 }
540
541 for _, tc := range tests {
542 t.Run(tc.name, func(t *testing.T) {
543 if tc.resetBefore {
544 ResetSketchWipWarning()
545 }
546 err := Check(tc.script)
547 if (err != nil) != tc.wantErr {
548 t.Errorf("Check() error = %v, wantErr %v", err, tc.wantErr)
549 }
550 })
551 }
552}