fix(fsext): prevent `.*` on gitignore from ignoring entire root dir (#766)

James Trew created

Fixed critical bug where gitignore patterns like `.*` would match the root
directory itself, causing `filepath.SkipDir` to abort entire file listing.
The fix prevents gitignore rules from applying to the scan root directory,
matching Git's actual behavior.

Change summary

internal/fsext/ls.go      | 12 +++++-
internal/fsext/ls_test.go | 66 +++++++++++++++++++++++++++++++++++++++++
2 files changed, 75 insertions(+), 3 deletions(-)

Detailed changes

internal/fsext/ls.go 🔗

@@ -125,18 +125,24 @@ func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bo
 		}
 	}
 
+	// Don't apply gitignore rules to the root directory itself
+	// In gitignore semantics, patterns don't apply to the repo root
+	if path == dl.rootPath {
+		return false
+	}
+
 	relPath, err := filepath.Rel(dl.rootPath, path)
 	if err != nil {
 		relPath = path
 	}
 
 	if commonIgnorePatterns().MatchesPath(relPath) {
-		slog.Debug("ingoring common pattern", "path", relPath)
+		slog.Debug("ignoring common pattern", "path", relPath)
 		return true
 	}
 
 	if dl.getIgnore(filepath.Dir(path)).MatchesPath(relPath) {
-		slog.Debug("ingoring dir pattern", "path", relPath, "dir", filepath.Dir(path))
+		slog.Debug("ignoring dir pattern", "path", relPath, "dir", filepath.Dir(path))
 		return true
 	}
 
@@ -145,7 +151,7 @@ func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bo
 	}
 
 	if homeIgnore().MatchesPath(relPath) {
-		slog.Debug("ingoring home dir pattern", "path", relPath)
+		slog.Debug("ignoring home dir pattern", "path", relPath)
 		return true
 	}
 

internal/fsext/ls_test.go 🔗

@@ -0,0 +1,66 @@
+package fsext
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func chdir(t *testing.T, dir string) {
+	original, err := os.Getwd()
+	require.NoError(t, err)
+
+	err = os.Chdir(dir)
+	require.NoError(t, err)
+
+	t.Cleanup(func() {
+		err := os.Chdir(original)
+		require.NoError(t, err)
+	})
+}
+
+func TestListDirectory(t *testing.T) {
+	tempDir := t.TempDir()
+	chdir(t, tempDir)
+
+	testFiles := map[string]string{
+		"regular.txt":     "content",
+		".hidden":         "hidden content",
+		".gitignore":      ".*\n*.log\n",
+		"subdir/file.go":  "package main",
+		"subdir/.another": "more hidden",
+		"build.log":       "build output",
+	}
+
+	for filePath, content := range testFiles {
+		dir := filepath.Dir(filePath)
+		if dir != "." {
+			require.NoError(t, os.MkdirAll(dir, 0o755))
+		}
+
+		err := os.WriteFile(filePath, []byte(content), 0o644)
+		require.NoError(t, err)
+	}
+
+	files, truncated, err := ListDirectory(".", nil, 0)
+	require.NoError(t, err)
+	assert.False(t, truncated)
+	assert.Equal(t, len(files), 4)
+
+	fileSet := make(map[string]bool)
+	for _, file := range files {
+		fileSet[filepath.ToSlash(file)] = true
+	}
+
+	assert.True(t, fileSet["./regular.txt"])
+	assert.True(t, fileSet["./subdir/"])
+	assert.True(t, fileSet["./subdir/file.go"])
+	assert.True(t, fileSet["./regular.txt"])
+
+	assert.False(t, fileSet["./.hidden"])
+	assert.False(t, fileSet["./.gitignore"])
+	assert.False(t, fileSet["./build.log"])
+}