Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion internal/ignore/ignore.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bufio"
"os"
"path/filepath"
"runtime"
"strings"

"github.com/go-git/go-git/v5/plumbing/format/gitignore"
Expand All @@ -14,6 +15,15 @@ type Matcher interface {
IsIgnored(path string, isDir bool) bool
}

// IsCaseInsensitiveFS reports whether the OS resolves filenames case-insensitively by default.
// In GoCode, Windows and macOS are treated as case-insensitive filesystems per project
// platform policy (NTFS and default APFS/HFS+ are case-insensitive).
// Note: While macOS supports case-sensitive APFS volumes as an opt-in format, standard
// installations are case-insensitive, matching Git's core.ignoreCase default.
func IsCaseInsensitiveFS() bool {
return runtime.GOOS == "windows" || runtime.GOOS == "darwin"
}

type gitIgnoreMatcher struct {
matcher gitignore.Matcher
root string
Expand Down Expand Up @@ -77,11 +87,15 @@ func readIgnorePatterns(ignoreFilePath, rootDir string) ([]gitignore.Pattern, er
var patterns []gitignore.Pattern

scanner := bufio.NewScanner(f)
caseFold := IsCaseInsensitiveFS()
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if caseFold {
line = strings.ToLower(line)
}
patterns = append(patterns, gitignore.ParsePattern(line, nil))
}

Expand All @@ -106,7 +120,9 @@ func (m *gitIgnoreMatcher) IsIgnored(path string, isDir bool) bool {
slashPath := filepath.ToSlash(relPath)
slashPath = strings.TrimPrefix(slashPath, "./")
slashPath = strings.TrimPrefix(slashPath, "/")

if IsCaseInsensitiveFS() {
slashPath = strings.ToLower(slashPath)
}
parts := strings.Split(slashPath, "/")
return m.matcher.Match(parts, isDir)
}
Expand Down
89 changes: 89 additions & 0 deletions internal/ignore/ignore_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package ignore
import (
"os"
"path/filepath"
"runtime"
"testing"
)

Expand Down Expand Up @@ -55,3 +56,91 @@ func TestIgnoreMatcher_NoIgnoreFile(t *testing.T) {
t.Errorf("expected no files to be ignored when no ignore file is present")
}
}

func TestIgnoreMatcher_CaseInsensitiveOnSupportedOS(t *testing.T) {
if !IsCaseInsensitiveFS() {
t.Skip("skipping case-insensitive tests on case-sensitive filesystem")
}

tmpDir := t.TempDir()
ignoreContent := `
secrets/
custom_secret.txt
*.confidential
notes/*
!notes/public.txt
`
_ = os.WriteFile(filepath.Join(tmpDir, ".gocodeignore"), []byte(ignoreContent), 0o644)
matcher := LoadMatcher(tmpDir)

tests := []struct {
path string
isDir bool
want bool
desc string
}{
// Filename variants
{"custom_secret.txt", false, true, "exact filename"},
{"CUSTOM_SECRET.TXT", false, true, "uppercase filename"},
{"Custom_Secret.txt", false, true, "mixed case filename"},

// Directory variants
{"secrets", true, true, "exact directory"},
{"SECRETS", true, true, "uppercase directory"},
{"Secrets", true, true, "mixed case directory"},
{"secrets/key.txt", false, true, "file in exact directory"},
{"SECRETS/key.txt", false, true, "file in uppercase directory"},
{"Secrets/Key.txt", false, true, "file with mixed case in mixed directory"},

// Wildcard variants
{"report.confidential", false, true, "exact wildcard extension"},
{"REPORT.CONFIDENTIAL", false, true, "uppercase wildcard extension"},
{"Report.Confidential", false, true, "mixed case wildcard extension"},

// Negation variants
{"notes/private.txt", false, true, "ignored under notes/*"},
{"NOTES/Private.txt", false, true, "ignored under notes/* with cased directory"},
{"notes/public.txt", false, false, "un-ignored via !notes/public.txt"},
{"Notes/Public.txt", false, false, "un-ignored via !notes/public.txt with cased path"},
{"NOTES/PUBLIC.TXT", false, false, "un-ignored via !notes/public.txt all caps"},

// Non-ignored files
{"main.go", false, false, "ordinary file"},
{"MAIN.GO", false, false, "ordinary file cased"},
}

for _, tt := range tests {
targetPath := filepath.Join(tmpDir, filepath.FromSlash(tt.path))
got := matcher.IsIgnored(targetPath, tt.isDir)
if got != tt.want {
t.Errorf("[%s] path %q (isDir=%v): got %v, want %v", tt.desc, tt.path, tt.isDir, got, tt.want)
}
}
}

func TestIgnoreMatcher_LinuxCaseSensitive(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("skipping Linux-only case-sensitive test")
}

tmpDir := t.TempDir()
ignoreContent := "secret.txt\nprivate/\n"
_ = os.WriteFile(filepath.Join(tmpDir, ".gocodeignore"), []byte(ignoreContent), 0o644)
matcher := LoadMatcher(tmpDir)

// On Linux, exact case matches
if !matcher.IsIgnored(filepath.Join(tmpDir, "secret.txt"), false) {
t.Error("expected secret.txt to be ignored on Linux")
}
if !matcher.IsIgnored(filepath.Join(tmpDir, "private", "data.txt"), false) {
t.Error("expected private/data.txt to be ignored on Linux")
}

// On Linux, altered case does NOT match
if matcher.IsIgnored(filepath.Join(tmpDir, "SECRET.TXT"), false) {
t.Error("expected SECRET.TXT NOT to be ignored on Linux")
}
if matcher.IsIgnored(filepath.Join(tmpDir, "Private", "data.txt"), false) {
t.Error("expected Private/data.txt NOT to be ignored on Linux")
}
}
72 changes: 72 additions & 0 deletions internal/tools/tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,78 @@ func TestTools_IgnoreRules(t *testing.T) {
}
}

func TestTools_IgnoreRules_CaseInsensitiveBypassPrevented(t *testing.T) {
if !isCaseInsensitiveFS() {
t.Skip("skipping case-insensitive ignore tool tests on case-sensitive filesystem")
}

ws := t.TempDir()

ignoreRules := "ignored_file.txt\nignored_dir/\n*.confidential\n"
_ = os.WriteFile(filepath.Join(ws, ".gocodeignore"), []byte(ignoreRules), 0o644)

filePath := filepath.Join(ws, "ignored_file.txt")
_ = os.WriteFile(filePath, []byte("harmless fixture content"), 0o644)

dirPath := filepath.Join(ws, "ignored_dir")
_ = os.MkdirAll(dirPath, 0o755)
subFile := filepath.Join(dirPath, "sub.txt")
_ = os.WriteFile(subFile, []byte("sub fixture content"), 0o644)

confFile := filepath.Join(ws, "report.confidential")
_ = os.WriteFile(confFile, []byte("report fixture content"), 0o644)

realIgnoreMatcher := ignore.LoadMatcher(ws)
sensitiveMatcher := ignore.NewSensitiveMatcher(realIgnoreMatcher, nil)

// 1. Verify FileReadTool blocks alternate-cased paths
readTool := &FileReadTool{WorkspaceRoot: ws, SensitiveMatcher: sensitiveMatcher}
for _, target := range []string{"IGNORED_FILE.TXT", "Ignored_File.txt", "Ignored_Dir/sub.txt", "IGNORED_DIR/sub.txt", "report.CONFIDENTIAL"} {
args, _ := json.Marshal(fileReadArgs{Path: target})
res, err := readTool.Execute(context.Background(), args)
if err != nil {
t.Fatalf("unexpected error for %q: %v", target, err)
}
if !strings.Contains(res.Error, "ignored by ignore rules") {
t.Errorf("expected FileReadTool to block case-variant %q, got error: %q, output: %q", target, res.Error, res.Output)
}
}

// 2. Verify FileWriteTool blocks alternate-cased paths
writeTool := &FileWriteTool{WorkspaceRoot: ws, SensitiveMatcher: sensitiveMatcher}
for _, target := range []string{"IGNORED_FILE.TXT", "Ignored_Dir/new.txt"} {
args, _ := json.Marshal(fileWriteArgs{Path: target, Content: "overwritten"})
res, err := writeTool.Execute(context.Background(), args)
if err != nil {
t.Fatalf("unexpected error for %q: %v", target, err)
}
if !strings.Contains(res.Error, "ignored by ignore rules") {
t.Errorf("expected FileWriteTool to block case-variant %q, got error: %q", target, res.Error)
}
}
data, _ := os.ReadFile(filePath)
if string(data) != "harmless fixture content" {
t.Errorf("file was modified despite ignore rule: %q", string(data))
}

// 3. Verify FilePatchTool blocks alternate-cased paths
patchTool := &FilePatchTool{WorkspaceRoot: ws, SensitiveMatcher: sensitiveMatcher}
for _, target := range []string{"IGNORED_FILE.TXT", "Ignored_Dir/sub.txt"} {
args, _ := json.Marshal(filePatchArgs{Path: target, Find: "fixture", Replace: "patched"})
res, err := patchTool.Execute(context.Background(), args)
if err != nil {
t.Fatalf("unexpected error for %q: %v", target, err)
}
if !strings.Contains(res.Error, "ignored by ignore rules") {
t.Errorf("expected FilePatchTool to block case-variant %q, got error: %q", target, res.Error)
}
}
data, _ = os.ReadFile(filePath)
if string(data) != "harmless fixture content" {
t.Errorf("file was patched despite ignore rule: %q", string(data))
}
}

func TestTools_OnModified(t *testing.T) {
tmpDir := t.TempDir()
filePath := filepath.Join(tmpDir, "tracked.txt")
Expand Down
3 changes: 1 addition & 2 deletions internal/tools/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,14 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"

"github.com/mevarx/GoCode/internal/ignore"
)

// isCaseInsensitiveFS reports whether the OS resolves filenames case-insensitively.
func isCaseInsensitiveFS() bool {
return runtime.GOOS == "windows" || runtime.GOOS == "darwin"
return ignore.IsCaseInsensitiveFS()
}

// rejectNormalisationForms refuses spellings the OS opens differently than named (ADS, trailing space/dot).
Expand Down
Loading