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
22 changes: 19 additions & 3 deletions file.go
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ func (f *FileWalker) walkDirectoryRecursive(iteration int,
return err
}

gitIgnore := gitignore.New(bytes.NewReader(c), abs, nil)
gitIgnore := gitignore.New(bytes.NewReader(c), filepath.ToSlash(abs), nil)
gitignores = append(gitignores, gitIgnore)
Comment on lines +356 to 357

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only the .gitignore ignore instance is created with a slash-normalized base (filepath.ToSlash(abs)), but other ignore instances in this function (.ignore, .gitmodules, CustomIgnore, CustomIgnorePatterns, and the new info/exclude ignore) still use abs directly. With MatchIsDir now normalizing paths to slash form, any ignore with a non-normalized base will fail the HasPrefix check on Windows and effectively stop matching. Normalize the base consistently for all gitignore.New(...) calls (or normalize inside the gitignore library constructors).

Copilot uses AI. Check for mistakes.
}
}
Expand Down Expand Up @@ -435,6 +435,22 @@ func (f *FileWalker) walkDirectoryRecursive(iteration int,
}
}
}
if !f.IgnoreGitIgnore {
gitdir := os.Getenv("GIT_DIR")
if gitdir == "" {
gitdir = filepath.Join(directory, ".git")
}
file := filepath.Join(gitdir, "info", "exclude")
if content, err := os.ReadFile(file); err == nil {
abs, err := filepath.Abs(directory)
if err == nil {
gitExclude := gitignore.New(bytes.NewReader(content), abs, nil)
if gitExclude != nil {
gitignores = append(gitignores, gitExclude)
}
Comment on lines +440 to +450

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The $GIT_DIR/info/exclude file is being probed on every recursive directory walk, which adds an extra filesystem read per directory and can become expensive on large trees. Since the exclude file is repo-global, consider loading it once (e.g. only at iteration == 0, or only when a .git directory is present) and passing/caching the resulting GitIgnore down to child calls. Also, for consistency with the other ignore loaders, use the injected f.osReadFile/f.errorsHandler, and make the base path slash-normalized (to match the MatchIsDir normalization).

Suggested change
if gitdir == "" {
gitdir = filepath.Join(directory, ".git")
}
file := filepath.Join(gitdir, "info", "exclude")
if content, err := os.ReadFile(file); err == nil {
abs, err := filepath.Abs(directory)
if err == nil {
gitExclude := gitignore.New(bytes.NewReader(content), abs, nil)
if gitExclude != nil {
gitignores = append(gitignores, gitExclude)
}
shouldLoadGitExclude := gitdir != ""
if gitdir == "" {
gitdir = filepath.Join(directory, ".git")
if info, err := os.Stat(gitdir); err == nil && info.IsDir() {
shouldLoadGitExclude = true
}
}
if shouldLoadGitExclude {
file := filepath.Join(gitdir, "info", "exclude")
if content, err := f.osReadFile(file); err == nil {
abs, err := filepath.Abs(directory)
if err != nil {
if !f.errorsHandler(err) {
return err
}
} else {
abs = filepath.ToSlash(abs)
gitExclude := gitignore.New(bytes.NewReader(content), abs, nil)
if gitExclude != nil {
gitignores = append(gitignores, gitExclude)
}
}
} else if !errors.Is(err, fs.ErrNotExist) {
if !f.errorsHandler(err) {
return err
}

Copilot uses AI. Check for mistakes.
}
}
Comment on lines +438 to +452

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New behavior is introduced here to load and apply $GIT_DIR/info/exclude, but there’s no test exercising it. Since file_test.go already has extensive coverage for ignore handling, please add a unit test that creates a temp repo with .git/info/exclude containing patterns and asserts those files are skipped (including a case where IgnoreGitIgnore is true/false, and optionally when GIT_DIR is set).

Copilot uses AI. Check for mistakes.
}

// If we have custom ignore patterns defined we should concatenate them and treat them as a single gitignore file
if len(f.CustomIgnorePatterns) > 0 {
Expand All @@ -456,7 +472,7 @@ func (f *FileWalker) walkDirectoryRecursive(iteration int,
for _, file := range files {
shouldIgnore := false
var skipReason SkipReason
joined := filepath.Join(directory, file.Name())
joined := filepath.ToSlash(filepath.Join(directory, file.Name()))

for _, ignore := range gitignores {
// we have the following situations
Expand Down Expand Up @@ -634,7 +650,7 @@ func (f *FileWalker) walkDirectoryRecursive(iteration int,
for _, dir := range dirs {
var shouldIgnore bool
var skipReason SkipReason
joined := filepath.Join(directory, dir.Name())
joined := filepath.ToSlash(filepath.Join(directory, dir.Name()))

// Check against the ignore files we have if the file we are looking at
// should be ignored
Expand Down
131 changes: 131 additions & 0 deletions file_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1500,3 +1500,134 @@ func TestSkipHandlerNilIsIgnored(t *testing.T) {
t.Error("Expected 0 files")
}
}

func TestCRLFGitignore(t *testing.T) {
dir := t.TempDir()

content := "vendor/\r\n*.log\r\nbuild/\r\n"
if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(content), 0644); err != nil {
t.Fatal(err)
}

os.MkdirAll(filepath.Join(dir, "vendor", "pkg"), 0755)
os.WriteFile(filepath.Join(dir, "vendor", "pkg", "lib.go"), []byte("package p"), 0644)
os.WriteFile(filepath.Join(dir, "debug.log"), []byte("log"), 0644)
os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main"), 0644)

queue := make(chan *File, 100)
walker := NewFileWalker(dir, queue)
go walker.Start()

var found []string
for f := range queue {
rel, _ := filepath.Rel(dir, f.Location)
found = append(found, filepath.ToSlash(rel))
}

foundMain := false
for _, p := range found {
if p == "main.go" {
foundMain = true
}
if strings.HasPrefix(p, "vendor/") {
t.Errorf("vendor/ should be gitignored but got: %s", p)
}
if strings.HasSuffix(p, ".log") {
t.Errorf("*.log should be gitignored but got: %s", p)
}
}
if !foundMain {
t.Error("expected main.go to be found but it was not")
}
}

func TestWindowsPathNormalization(t *testing.T) {
dir := t.TempDir()

os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("build/\n"), 0644)
os.MkdirAll(filepath.Join(dir, "build"), 0755)
os.WriteFile(filepath.Join(dir, "build", "out.bin"), []byte("bin"), 0644)
os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main"), 0644)
Comment on lines +1544 to +1550

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TestWindowsPathNormalization is intended to prevent Windows-specific path separator regressions, but the repo’s CI workflow runs tests only on ubuntu-latest, so this test won’t exercise the backslash-vs-slash behavior it’s named for. Consider either adding a Windows job to the GitHub Actions matrix or adjusting the test to explicitly cover mixed-separator inputs in a way that’s meaningful on non-Windows runners.

Copilot uses AI. Check for mistakes.

queue := make(chan *File, 100)
walker := NewFileWalker(dir, queue)
go walker.Start()

var found []string
for f := range queue {
rel, _ := filepath.Rel(dir, f.Location)
found = append(found, filepath.ToSlash(rel))
}

foundMain := false
for _, p := range found {
if p == "main.go" {
foundMain = true
}
if strings.HasPrefix(p, "build/") {
t.Errorf("build/ should be gitignored but got: %s", p)
}
}
if !foundMain {
t.Error("expected main.go to be found but it was not")
}
}
func TestGitInfoExclude(t *testing.T) {
testDir, _ := os.MkdirTemp(os.TempDir(), randSeq(10))
_ = os.MkdirAll(filepath.Join(testDir, ".git", "info"), 0755)
_ = os.WriteFile(filepath.Join(testDir, ".git", "info", "exclude"), []byte("secret.txt\n"), 0644)
_, _ = os.Create(filepath.Join(testDir, "secret.txt"))
_, _ = os.Create(filepath.Join(testDir, "visible.txt"))

fileListQueue := make(chan *File, 10)
walker := NewFileWalker(testDir, fileListQueue)
walker.IgnoreGitIgnore = false
_ = walker.Start()

count := 0
for range fileListQueue {
count++
}

if count != 1 {
t.Errorf("expected 1 file but got %d", count)
}
}
func TestGitInfoExcludeNoGitDir(t *testing.T) {
testDir, _ := os.MkdirTemp(os.TempDir(), randSeq(10))
_, _ = os.Create(filepath.Join(testDir, "visible.txt"))

fileListQueue := make(chan *File, 10)
walker := NewFileWalker(testDir, fileListQueue)
walker.IgnoreGitIgnore = false
_ = walker.Start()

count := 0
for range fileListQueue {
count++
}

if count != 1 {
t.Errorf("expected 1 file but got %d", count)
}
}
func TestGitInfoExcludeIgnoredWhenGitIgnoreDisabled(t *testing.T) {
testDir, _ := os.MkdirTemp(os.TempDir(), randSeq(10))
_ = os.MkdirAll(filepath.Join(testDir, ".git", "info"), 0755)
_ = os.WriteFile(filepath.Join(testDir, ".git", "info", "exclude"), []byte("secret.txt\n"), 0644)
_, _ = os.Create(filepath.Join(testDir, "secret.txt"))

fileListQueue := make(chan *File, 10)
walker := NewFileWalker(testDir, fileListQueue)
walker.IgnoreGitIgnore = true
_ = walker.Start()

count := 0
for range fileListQueue {
count++
}

if count != 1 {
t.Errorf("expected 1 file but got %d", count)
}
}
2 changes: 2 additions & 0 deletions go-gitignore/gitignore.go
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ func (i *ignore) Match(path string) Match {

func (i *ignore) MatchIsDir(path string, _isdir bool) Match {
// ensure we have the absolute path for the given file
path = filepath.ToSlash(path) // normalize before cache lookup
if v, ok := matchIsDirCache.Load(path); ok {
return i.Absolute(v.(string), _isdir)
}
Expand All @@ -258,6 +259,7 @@ func (i *ignore) MatchIsDir(path string, _isdir bool) Match {
i._errors(NewError(_err, Position{}))
return nil
}
_path = filepath.ToSlash(_path) // ensure stored value is slash-form
matchIsDirCache.Store(path, _path)

// attempt to match the absolute path
Expand Down
1 change: 0 additions & 1 deletion go-gitignore/lexer.go
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,6 @@ func (l *lexer) eol() ([]rune, Error) {

// carriage return - we expect to see a newline next
case _CR:
_line = append(_line, _next)
_next, _err = l.read()
if _err != nil {
return _line, _err
Expand Down
3 changes: 2 additions & 1 deletion go-gitignore/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,8 @@ func (r *repository) Relative(path string, isdir bool) Match {
// move up the path hierarchy
var _last string
for {
_file := filepath.Join(r._base, _parent, r._file)
_file := r._base + string(os.PathSeparator) +
filepath.FromSlash(_parent) + string(os.PathSeparator) + r._file
Comment on lines +241 to +242

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This replaces filepath.Join with manual path concatenation. This is more error-prone on Windows (drive roots like C:\ or UNC paths) and can introduce redundant separators/./ segments that filepath.Join normally normalizes. Unless there’s a specific bug being worked around, prefer filepath.Join(r._base, _parent, r._file) (or at least filepath.Clean the constructed path) to preserve cross-platform path semantics.

Suggested change
_file := r._base + string(os.PathSeparator) +
filepath.FromSlash(_parent) + string(os.PathSeparator) + r._file
_file := filepath.Join(r._base, _parent, r._file)

Copilot uses AI. Check for mistakes.
_ignore := NewWithCache(_file, r._cache, r._errors)
if _ignore != nil {
_match := _ignore.Relative(_local, isdir)
Expand Down
8 changes: 8 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Loading