all repos — erl @ d29d0816680a343ee47c40fa9ace8d80862ac412

Execute Reload Loop

ignore/ignore_test.go (view raw)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
package ignore

import (
	"context"
	"errors"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"

	"alin.ovh/erl/repository"
)

func TestNew(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name     string
		wd       string
		paths    []string
		expected *Filter
	}{
		{
			name: "create with working directory only",
			wd:   "/tmp/test",
			expected: &Filter{
				wd:    "/tmp/test",
				paths: nil,
			},
		},
		{
			name:  "create with working directory and paths",
			wd:    "/tmp/test",
			paths: []string{"/path/to/.gitignore", "/path/to/.ignore"},
			expected: &Filter{
				wd:    "/tmp/test",
				paths: []string{"/path/to/.gitignore", "/path/to/.ignore"},
			},
		},
		{
			name: "create with empty working directory",
			wd:   "",
			expected: &Filter{
				wd:    "",
				paths: nil,
			},
		},
		{
			name: "create with no paths",
			wd:   "/some/path",
			expected: &Filter{
				wd:    "/some/path",
				paths: nil,
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			result := New(tt.wd, tt.paths...)

			if result.wd != tt.expected.wd {
				t.Errorf("expected wd %q, got %q", tt.expected.wd, result.wd)
			}

			// Constructor paths are now stored in givenPaths, not paths
			// paths is only populated after ReadIgnoreFiles() is called
			if len(result.givenPaths) != len(tt.expected.paths) {
				t.Errorf(
					"expected %d given paths, got %d",
					len(tt.expected.paths),
					len(result.givenPaths),
				)

				return
			}

			for i, path := range result.givenPaths {
				if path != tt.expected.paths[i] {
					t.Errorf("expected givenPath[%d] %q, got %q", i, tt.expected.paths[i], path)
				}
			}

			// Verify other fields are initialized correctly
			if result.repo != nil {
				t.Error("expected repo to be nil for new filter")
			}
			if result.projectRoot != "" {
				t.Error("expected projectRoot to be empty for new filter")
			}
			if result.paths != nil {
				t.Error("expected paths to be nil for new filter")
			}
			if result.ignores != nil {
				t.Error("expected ignores to be nil for new filter")
			}
		})
	}
}

func TestFilter_ReadIgnoreFiles(t *testing.T) {
	t.Parallel()

	projectRoot, err := filepath.Abs("../")
	if err != nil {
		t.Fatalf("failed to get project root: %v", err)
	}

	tests := []struct {
		name        string
		wd          string
		expectError bool
		minFiles    int // minimum number of ignore files expected
	}{
		{
			name:     "simple case",
			wd:       filepath.Join(projectRoot, "testcases", "ignores", "simple"),
			minFiles: 1, // should find at least .gitignore
		},
		{
			name:     "nested case with both .gitignore and .ignore",
			wd:       filepath.Join(projectRoot, "testcases", "ignores", "nested", "subdir"),
			minFiles: 2, // should find both .gitignore and .ignore files
		},
		{
			name:     "multiple ignore files in same directory",
			wd:       filepath.Join(projectRoot, "testcases", "ignores", "multiple"),
			minFiles: 2, // should find both .gitignore and .ignore
		},
		{
			name:     "working directory same as repo root",
			wd:       projectRoot,
			minFiles: 1, // should find root .gitignore
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			filter := New(tt.wd)
			ctx := context.Background()

			err := filter.ReadIgnoreFiles(ctx)

			if tt.expectError {
				if err == nil {
					t.Error("expected error, got nil")
				}

				return
			}

			if err != nil {
				t.Fatalf("ReadIgnoreFiles failed: %v", err)
			}

			// Verify filter state after successful ReadIgnoreFiles
			if filter.repo == nil {
				t.Error("expected repo to be set after ReadIgnoreFiles")
			}

			if filter.projectRoot == "" {
				t.Error("expected projectRoot to be set after ReadIgnoreFiles")
			}

			if len(filter.paths) < tt.minFiles {
				t.Errorf(
					"expected at least %d ignore files, got %d",
					tt.minFiles,
					len(filter.paths),
				)
			}

			if len(filter.ignores) == 0 {
				t.Error("should have compiled some ignore objects")
			}

			if len(filter.ignores) != len(filter.paths) {
				t.Errorf("number of compiled ignores (%d) should match number of paths (%d)",
					len(filter.ignores), len(filter.paths))
			}

			t.Logf("Found %d ignore files: %v", len(filter.paths), filter.paths)
			t.Logf("Compiled %d ignore objects", len(filter.ignores))
			t.Logf("Project root: %s", filter.projectRoot)
			t.Logf("Repository type: %v", filter.repo.GetKind())

			// Verify all paths exist and are files
			for _, path := range filter.paths {
				stat, err := os.Stat(path)
				if err != nil {
					t.Errorf("ignore file does not exist: %s", path)

					continue
				}
				if stat.IsDir() {
					t.Errorf("ignore path is a directory, not a file: %s", path)
				}
			}

			// Verify paths are within project root or its parents
			for _, path := range filter.paths {
				absPath, err := filepath.Abs(path)
				if err != nil {
					t.Errorf("failed to get absolute path for %s: %v", path, err)

					continue
				}

				// Path should either be within project root or be a parent directory's ignore file
				rel, err := filepath.Rel(filter.projectRoot, absPath)
				if err != nil {
					// If Rel fails, check if it's a parent directory
					parentRel, parentErr := filepath.Rel(absPath, filter.projectRoot)
					if parentErr != nil || filepath.IsAbs(parentRel) {
						t.Logf("Ignore file is in parent directory: %s", absPath)
					}
				} else if strings.HasPrefix(rel, "..") {
					t.Logf("Ignore file is in parent directory: %s", absPath)
				}
			}
		})
	}
}

func TestFilter_ReadIgnoreFiles_withoutPresetPaths(t *testing.T) {
	t.Parallel()

	projectRoot, err := filepath.Abs("../")
	if err != nil {
		t.Fatalf("failed to get project root: %v", err)
	}

	// Test that ReadIgnoreFiles works without preset paths
	filter := New(projectRoot)

	ctx := context.Background()
	err = filter.ReadIgnoreFiles(ctx)
	if err != nil {
		t.Fatalf("ReadIgnoreFiles failed: %v", err)
	}

	// Should have found discovered paths
	if len(filter.paths) < 1 {
		t.Error("should have found some ignore files")
	}

	if len(filter.ignores) != len(filter.paths) {
		t.Errorf("number of compiled ignores (%d) should match number of paths (%d)",
			len(filter.ignores), len(filter.paths))
	}
}

func TestFilter_ReadIgnoreFiles_multipleCalls(t *testing.T) {
	t.Parallel()

	projectRoot, err := filepath.Abs("../")
	if err != nil {
		t.Fatalf("failed to get project root: %v", err)
	}

	filter := New(filepath.Join(projectRoot, "testcases", "ignores", "simple"))
	ctx := context.Background()

	// First call
	err = filter.ReadIgnoreFiles(ctx)
	if err != nil {
		t.Fatalf("first ReadIgnoreFiles failed: %v", err)
	}

	firstPaths := make([]string, len(filter.paths))
	copy(firstPaths, filter.paths)
	firstIgnoreCount := len(filter.ignores)

	// Second call should be idempotent - produce the same results
	err = filter.ReadIgnoreFiles(ctx)
	if err != nil {
		t.Fatalf("second ReadIgnoreFiles failed: %v", err)
	}

	// Verify idempotent behavior: second call should have same results as first
	if len(filter.paths) != len(firstPaths) {
		t.Errorf("ReadIgnoreFiles() is not idempotent: path count changed from %d to %d",
			len(firstPaths), len(filter.paths))
	}

	if len(filter.ignores) != firstIgnoreCount {
		t.Errorf("ReadIgnoreFiles() is not idempotent: ignore count changed from %d to %d",
			firstIgnoreCount, len(filter.ignores))
	}

	// Verify paths are exactly the same (no duplicates, same order)
	if len(filter.paths) == len(firstPaths) {
		for i, path := range filter.paths {
			if path != firstPaths[i] {
				t.Errorf("ReadIgnoreFiles() is not idempotent: path[%d] changed from %q to %q",
					i, firstPaths[i], path)
			}
		}
	}

	// Verify no duplicate paths exist
	pathCount := make(map[string]int)
	for _, path := range filter.paths {
		pathCount[path]++
	}

	for path, count := range pathCount {
		if count > 1 {
			t.Errorf(
				"Duplicate path found after multiple calls: %s (appears %d times)",
				path,
				count,
			)
		}
	}
}

func TestFilter_ReadIgnoreFiles_errors(t *testing.T) {
	t.Parallel()

	t.Run("not in a repository", func(t *testing.T) {
		t.Parallel()
		// Create temp directory that's not a git repo
		tmpDir, err := os.MkdirTemp("/tmp", "not-git-*")
		if err != nil {
			t.Fatalf("failed to create temp dir: %v", err)
		}
		defer os.RemoveAll(tmpDir)

		filter := New(tmpDir)
		ctx := context.Background()

		err = filter.ReadIgnoreFiles(ctx)
		if err == nil {
			t.Error("expected error when not in a repository")
		}

		if !errors.Is(err, repository.ErrNotRepo) {
			t.Errorf("expected repository.ErrNotRepo, got %v", err)
		}
	})

	t.Run("nonexistent working directory", func(t *testing.T) {
		t.Parallel()
		nonExistentDir := "/tmp/definitely-does-not-exist-12345"
		filter := New(nonExistentDir)
		ctx := context.Background()

		err := filter.ReadIgnoreFiles(ctx)
		if err == nil {
			t.Error("expected error for nonexistent working directory")
		}
	})

	t.Run("ignore file is directory", func(t *testing.T) {
		t.Parallel()
		// Create a git repo in temp directory
		tmpDir, err := os.MkdirTemp("", "git-test-*")
		if err != nil {
			t.Fatalf("failed to create temp dir: %v", err)
		}
		defer os.RemoveAll(tmpDir)

		// Initialize git repo
		if err := runCmd(tmpDir, "git", "init"); err != nil {
			t.Fatalf("failed to init git repo: %v", err)
		}

		// Create .gitignore as a directory instead of a file
		gitignoreDir := filepath.Join(tmpDir, ".gitignore")
		if err := os.Mkdir(gitignoreDir, 0o755); err != nil {
			t.Fatalf("failed to create .gitignore directory: %v", err)
		}

		filter := New(tmpDir)
		ctx := context.Background()

		err = filter.ReadIgnoreFiles(ctx)
		if err == nil {
			t.Error("expected error when .gitignore is a directory")
		}

		if !strings.Contains(err.Error(), "directory") {
			t.Errorf("error should mention directory, got: %v", err)
		}
	})
}

func TestFilter_getPaths_integrated(t *testing.T) {
	t.Parallel()

	// Test getPaths indirectly through ReadIgnoreFiles since it requires proper setup
	projectRoot, err := filepath.Abs("../")
	if err != nil {
		t.Fatalf("failed to get project root: %v", err)
	}

	tests := []struct {
		name             string
		wd               string
		expectedContains []string // file basenames that should be found
	}{
		{
			name:             "finds both .gitignore and .ignore",
			wd:               filepath.Join(projectRoot, "testcases", "ignores", "multiple"),
			expectedContains: []string{".gitignore", ".ignore"},
		},
		{
			name: "finds nested ignore files",
			wd: filepath.Join(
				projectRoot,
				"testcases",
				"ignores",
				"nested",
				"subdir",
			),
			expectedContains: []string{".gitignore", ".ignore"},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			filter := New(tt.wd)
			ctx := context.Background()

			err := filter.ReadIgnoreFiles(ctx)
			if err != nil {
				t.Fatalf("ReadIgnoreFiles failed: %v", err)
			}

			// Check that expected file types are found
			for _, expectedBasename := range tt.expectedContains {
				found := false
				for _, path := range filter.paths {
					if filepath.Base(path) == expectedBasename {
						found = true

						break
					}
				}
				if !found {
					t.Errorf(
						"expected to find file with basename %s, paths: %v",
						expectedBasename,
						filter.paths,
					)
				}
			}

			// Verify the paths are properly ordered (should go from repo root to working directory)
			if len(filter.paths) > 1 {
				for i := 1; i < len(filter.paths); i++ {
					// Each subsequent path should be deeper in the directory tree
					prev := filter.paths[i-1]
					curr := filter.paths[i]

					// Check if current path is in same directory or subdirectory of previous
					prevDir := filepath.Dir(prev)
					currDir := filepath.Dir(curr)

					rel, err := filepath.Rel(prevDir, currDir)
					if err == nil && !strings.HasPrefix(rel, "..") {
						// Current is in same or subdirectory of previous - this is expected
						continue
					} else {
						t.Logf("Path ordering: %s -> %s (rel: %s)", prev, curr, rel)
					}
				}
			}
		})
	}
}

func TestDefaultIgnoreFileNames(t *testing.T) {
	t.Parallel()

	name := ".ignore"

	if DefaultIgnoreFilename != name {
		t.Errorf(
			"expected DefaultIgnoreFileName = %q, got %q",
			name,
			DefaultIgnoreFilename,
		)
	}
}

func TestFilter_ReadIgnoreFiles_edgeCases(t *testing.T) {
	t.Parallel()

	projectRoot, err := filepath.Abs("../")
	if err != nil {
		t.Fatalf("failed to get project root: %v", err)
	}

	t.Run("relative working directory", func(t *testing.T) {
		t.Parallel()
		// Test with relative path from current test directory
		relativeWd := "../testcases/ignores/simple"
		filter := New(relativeWd)
		ctx := context.Background()

		err := filter.ReadIgnoreFiles(ctx)
		// Note: Relative working directories may not work properly with repository detection
		// since filepath.Rel() may fail when trying to make relative paths relative to repo root
		if err != nil {
			t.Logf("ReadIgnoreFiles failed with relative wd (this may be expected): %v", err)
			// This is acceptable behavior - relative paths may not be fully supported
			return
		}

		// If it succeeds, verify it found files
		if len(filter.paths) == 0 {
			t.Error("should have found ignore files with relative working directory")
		} else {
			t.Logf("Successfully processed relative working directory, found %d paths", len(filter.paths))
		}
	})

	t.Run("constructor paths are preserved and cause failure if invalid", func(t *testing.T) {
		t.Parallel()
		nonExistentPath := "/definitely/does/not/exist/.gitignore"
		existentPath := filepath.Join(projectRoot, ".gitignore")

		// Constructor paths are now preserved and processed
		filter := New(projectRoot, nonExistentPath, existentPath)
		ctx := context.Background()

		err := filter.ReadIgnoreFiles(ctx)
		// Should fail because nonExistentPath cannot be opened
		if err == nil {
			t.Error("ReadIgnoreFiles should fail when constructor path doesn't exist")
		} else if !strings.Contains(err.Error(), "does/not/exist") {
			t.Errorf("expected error about non-existent path, got: %v", err)
		}
	})

	t.Run("valid constructor paths are processed successfully", func(t *testing.T) {
		t.Parallel()
		validPath1 := filepath.Join(projectRoot, ".gitignore")
		validPath2 := filepath.Join(projectRoot, "testcases", "ignores", "simple", ".gitignore")

		// Both constructor paths should exist and be valid
		filter := New(projectRoot, validPath1, validPath2)
		ctx := context.Background()

		err := filter.ReadIgnoreFiles(ctx)
		if err != nil {
			t.Fatalf("ReadIgnoreFiles should succeed with valid constructor paths: %v", err)
		}

		// Should have constructor paths plus any discovered paths
		if len(filter.paths) < 2 {
			t.Errorf("should have at least 2 paths (constructor paths), got %d", len(filter.paths))
		}

		// Verify constructor paths are included
		foundPath1 := false
		foundPath2 := false
		for _, path := range filter.paths {
			if path == validPath1 {
				foundPath1 = true
			}
			if path == validPath2 {
				foundPath2 = true
			}
		}

		if !foundPath1 {
			t.Errorf("constructor path %s not found in results: %v", validPath1, filter.paths)
		}
		if !foundPath2 {
			t.Errorf("constructor path %s not found in results: %v", validPath2, filter.paths)
		}

		// Verify ignores were compiled for all paths
		if len(filter.ignores) != len(filter.paths) {
			t.Errorf("number of compiled ignores (%d) should match number of paths (%d)",
				len(filter.ignores), len(filter.paths))
		}
	})

	t.Run("empty working directory", func(t *testing.T) {
		t.Parallel()
		filter := New("")
		ctx := context.Background()

		err := filter.ReadIgnoreFiles(ctx)
		// This might succeed or fail depending on whether current directory is a repo
		t.Logf("ReadIgnoreFiles with empty wd: error=%v", err)
		if err == nil {
			t.Logf("Succeeded with paths: %v", filter.paths)
		}
	})

	t.Run("very deep nested directory", func(t *testing.T) {
		t.Parallel()
		deepPath := filepath.Join(projectRoot, "testcases", "ignores", "nested", "subdir")
		filter := New(deepPath)
		ctx := context.Background()

		err := filter.ReadIgnoreFiles(ctx)
		if err != nil {
			t.Fatalf("ReadIgnoreFiles failed with deep path: %v", err)
		}

		// Should find ignore files from multiple levels
		if len(filter.paths) < 2 {
			t.Errorf(
				"expected multiple ignore files from different levels, got %d",
				len(filter.paths),
			)
		}

		// Verify we get files from different directory levels
		levels := make(map[string]bool)
		for _, path := range filter.paths {
			dir := filepath.Dir(path)
			levels[dir] = true
		}

		if len(levels) < 2 {
			t.Errorf(
				"expected ignore files from multiple directory levels, got %d levels",
				len(levels),
			)
		}
	})
}

func TestFilter_ReadIgnoreFiles_errorRecovery(t *testing.T) {
	t.Parallel()

	t.Run("failure with invalid preset paths", func(t *testing.T) {
		t.Parallel()
		projectRoot, err := filepath.Abs("../")
		if err != nil {
			t.Fatalf("failed to get project root: %v", err)
		}

		validPath := filepath.Join(projectRoot, ".gitignore")
		invalidPath := "/tmp/non-existent-ignore-file"

		filter := New(projectRoot, validPath, invalidPath)
		ctx := context.Background()

		err = filter.ReadIgnoreFiles(ctx)
		// Should fail because invalidPath cannot be opened
		if err == nil {
			t.Error("ReadIgnoreFiles should fail when constructor path doesn't exist")
		} else if !strings.Contains(err.Error(), "non-existent") {
			t.Errorf("expected error about non-existent file, got: %v", err)
		}
	})
}

func TestFilter_ReadIgnoreFiles_stateConsistency(t *testing.T) {
	t.Parallel()

	projectRoot, err := filepath.Abs("../")
	if err != nil {
		t.Fatalf("failed to get project root: %v", err)
	}

	t.Run("filter state after successful operation", func(t *testing.T) {
		t.Parallel()
		filter := New(filepath.Join(projectRoot, "testcases", "ignores", "simple"))
		ctx := context.Background()

		err := filter.ReadIgnoreFiles(ctx)
		if err != nil {
			t.Fatalf("ReadIgnoreFiles failed: %v", err)
		}

		// Verify all state is consistent
		if filter.repo == nil {
			t.Error("repo should not be nil after successful ReadIgnoreFiles")
		}

		if filter.projectRoot == "" {
			t.Error("projectRoot should not be empty after successful ReadIgnoreFiles")
		}

		if filter.wd == "" {
			t.Error("wd should not be empty (was set in constructor)")
		}

		if len(filter.paths) == 0 {
			t.Error("paths should not be empty after successful ReadIgnoreFiles")
		}

		if len(filter.ignores) == 0 {
			t.Error("ignores should not be empty after successful ReadIgnoreFiles")
		}

		// Verify consistency between paths and ignores
		if len(filter.paths) != len(filter.ignores) {
			t.Errorf("paths (%d) and ignores (%d) should have same length",
				len(filter.paths), len(filter.ignores))
		}

		// Verify repo root is absolute path
		if !filepath.IsAbs(filter.projectRoot) {
			t.Errorf("projectRoot should be absolute path, got: %s", filter.projectRoot)
		}

		// Verify all paths are absolute
		for i, path := range filter.paths {
			if !filepath.IsAbs(path) {
				t.Errorf("path[%d] should be absolute, got: %s", i, path)
			}
		}
	})

	t.Run("working directory validation", func(t *testing.T) {
		t.Parallel()
		wd := filepath.Join(projectRoot, "testcases", "ignores", "simple")

		// Verify the working directory actually exists before testing
		if _, err := os.Stat(wd); err != nil {
			t.Fatalf("test working directory does not exist: %s", wd)
		}

		filter := New(wd)
		ctx := context.Background()

		err := filter.ReadIgnoreFiles(ctx)
		if err != nil {
			t.Fatalf("ReadIgnoreFiles failed: %v", err)
		}

		// Working directory should be preserved
		if filter.wd != wd {
			t.Errorf("working directory changed from %s to %s", wd, filter.wd)
		}

		// Project root should be parent of or equal to working directory
		relPath, err := filepath.Rel(filter.projectRoot, wd)
		if err != nil {
			t.Errorf("working directory should be within project root: %v", err)
		} else if strings.HasPrefix(relPath, "..") {
			t.Errorf("working directory should not be outside project root: %s", relPath)
		}
	})
}

// Helper function to run shell commands in tests
func runCmd(dir string, name string, args ...string) error {
	cmd := append([]string{name}, args...)

	return runCmdSlice(dir, cmd)
}

func runCmdSlice(dir string, cmdSlice []string) error {
	if len(cmdSlice) == 0 {
		return errors.New("empty command")
	}

	cmd := exec.Command(cmdSlice[0], cmdSlice[1:]...)
	cmd.Dir = dir

	return cmd.Run()
}