From d7a59450abc54a43805a664831791ab19abce84a Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Thu, 30 Apr 2026 18:26:35 +0100 Subject: [PATCH 1/7] Add WalkFrom to walk a subdirectory with correct root-level ignore rules WalkFrom separates the repository root from the walk starting directory. It loads .git/info/exclude, the root .gitignore, and any intermediate .gitignore files between root and start before walking. Closes #9 --- gitignore.go | 45 +++++++++++ gitignore_test.go | 200 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 245 insertions(+) diff --git a/gitignore.go b/gitignore.go index 35ffeeb..7cdc2b0 100644 --- a/gitignore.go +++ b/gitignore.go @@ -189,6 +189,51 @@ func Walk(root string, fn func(path string, d fs.DirEntry) error) error { return walkRecursive(root, "", m, fn) } +// WalkFrom walks the directory tree starting at a subdirectory of root, +// calling fn for each file and directory that is not ignored by gitignore +// rules. Unlike Walk, it separates the repository root (used to find +// .git/info/exclude and the root .gitignore) from the directory where the +// walk begins. Any .gitignore files between root and start are loaded +// before the walk begins, so their patterns apply correctly. +// +// The start parameter is a slash-separated path relative to root (e.g. +// "src/pkg"). Paths passed to fn are relative to root (not to start) +// and use the OS path separator. The start directory itself is passed +// to fn. +func WalkFrom(root, start string, fn func(path string, d fs.DirEntry) error) error { + if start == "" || start == "." { + return Walk(root, fn) + } + + start = filepath.ToSlash(filepath.Clean(start)) + + m := New(root) + + parts := strings.Split(start, "/") + for i := range parts { + prefix := strings.Join(parts[:i+1], "/") + igPath := filepath.Join(root, filepath.FromSlash(prefix), ".gitignore") + if _, err := os.Stat(igPath); err == nil { + m.AddFromFile(igPath, prefix) + } + } + + startAbs := filepath.Join(root, filepath.FromSlash(start)) + info, err := os.Stat(startAbs) + if err != nil { + return err + } + + startRel := filepath.FromSlash(start) + if fn != nil { + if err := fn(startRel, fs.FileInfoToDirEntry(info)); err != nil { + return err + } + } + + return walkRecursive(root, startRel, m, fn) +} + func walkRecursive(root, rel string, m *Matcher, fn func(string, fs.DirEntry) error) error { dir := root if rel != "" { diff --git a/gitignore_test.go b/gitignore_test.go index 446a540..7f84376 100644 --- a/gitignore_test.go +++ b/gitignore_test.go @@ -2523,3 +2523,203 @@ func TestNewEmptyRootSkipsFilesystem(t *testing.T) { t.Error("AddPatterns(*.tmp) should still work after New(\"\")") } } + +func TestWalkFrom(t *testing.T) { + t.Setenv("GIT_CONFIG_GLOBAL", "/dev/null") + + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".git", "info"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte("*.log\n"), 0644); err != nil { + t.Fatal(err) + } + + for _, dir := range []string{"src", "src/pkg", "src/pkg/internal", "docs"} { + if err := os.MkdirAll(filepath.Join(root, dir), 0755); err != nil { + t.Fatal(err) + } + } + for _, f := range []string{ + "README.md", + "src/main.go", + "src/debug.log", + "src/pkg/lib.go", + "src/pkg/internal/helper.go", + "src/pkg/internal/trace.log", + "docs/guide.md", + } { + if err := os.WriteFile(filepath.Join(root, f), []byte("x"), 0644); err != nil { + t.Fatal(err) + } + } + + var collected []string + err := gitignore.WalkFrom(root, "src/pkg", func(path string, d os.DirEntry) error { + collected = append(collected, filepath.ToSlash(path)) + return nil + }) + if err != nil { + t.Fatal(err) + } + + got := make(map[string]bool) + for _, p := range collected { + got[p] = true + } + + for _, want := range []string{ + "src/pkg", + "src/pkg/lib.go", + "src/pkg/internal", + "src/pkg/internal/helper.go", + } { + if !got[want] { + t.Errorf("WalkFrom missing expected path %q", want) + } + } + for _, noWant := range []string{ + "README.md", + "src/main.go", + "docs/guide.md", + "src/pkg/internal/trace.log", + "src/debug.log", + } { + if got[noWant] { + t.Errorf("WalkFrom should not yield %q", noWant) + } + } +} + +func TestWalkFromLoadsIntermediateGitignore(t *testing.T) { + t.Setenv("GIT_CONFIG_GLOBAL", "/dev/null") + + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".git", "info"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte(""), 0644); err != nil { + t.Fatal(err) + } + + for _, dir := range []string{"src", "src/pkg"} { + if err := os.MkdirAll(filepath.Join(root, dir), 0755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(root, "src", ".gitignore"), []byte("*.tmp\n"), 0644); err != nil { + t.Fatal(err) + } + for _, f := range []string{"src/pkg/lib.go", "src/pkg/cache.tmp"} { + if err := os.WriteFile(filepath.Join(root, f), []byte("x"), 0644); err != nil { + t.Fatal(err) + } + } + + var collected []string + err := gitignore.WalkFrom(root, "src/pkg", func(path string, d os.DirEntry) error { + collected = append(collected, filepath.ToSlash(path)) + return nil + }) + if err != nil { + t.Fatal(err) + } + + got := make(map[string]bool) + for _, p := range collected { + got[p] = true + } + + if !got["src/pkg/lib.go"] { + t.Error("WalkFrom should yield src/pkg/lib.go") + } + if got["src/pkg/cache.tmp"] { + t.Error("WalkFrom should not yield src/pkg/cache.tmp (ignored by src/.gitignore)") + } +} + +func TestWalkFromLoadsGitInfoExclude(t *testing.T) { + t.Setenv("GIT_CONFIG_GLOBAL", "/dev/null") + + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".git", "info"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".git", "info", "exclude"), []byte("secret\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte(""), 0644); err != nil { + t.Fatal(err) + } + + for _, dir := range []string{"sub"} { + if err := os.MkdirAll(filepath.Join(root, dir), 0755); err != nil { + t.Fatal(err) + } + } + for _, f := range []string{"sub/visible.txt", "sub/secret"} { + if err := os.WriteFile(filepath.Join(root, f), []byte("x"), 0644); err != nil { + t.Fatal(err) + } + } + + var collected []string + err := gitignore.WalkFrom(root, "sub", func(path string, d os.DirEntry) error { + collected = append(collected, filepath.ToSlash(path)) + return nil + }) + if err != nil { + t.Fatal(err) + } + + got := make(map[string]bool) + for _, p := range collected { + got[p] = true + } + + if !got["sub/visible.txt"] { + t.Error("WalkFrom should yield sub/visible.txt") + } + if got["sub/secret"] { + t.Error("WalkFrom should not yield sub/secret (ignored by .git/info/exclude)") + } +} + +func TestWalkFromEmptyStart(t *testing.T) { + t.Setenv("GIT_CONFIG_GLOBAL", "/dev/null") + + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".git", "info"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte("*.log\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "file.txt"), []byte("x"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "trace.log"), []byte("x"), 0644); err != nil { + t.Fatal(err) + } + + var collected []string + err := gitignore.WalkFrom(root, "", func(path string, d os.DirEntry) error { + collected = append(collected, filepath.ToSlash(path)) + return nil + }) + if err != nil { + t.Fatal(err) + } + + got := make(map[string]bool) + for _, p := range collected { + got[p] = true + } + + if !got["file.txt"] { + t.Error("WalkFrom with empty start should yield file.txt") + } + if got["trace.log"] { + t.Error("WalkFrom with empty start should not yield trace.log") + } +} From e41356c3a355f3c6728a15af8ca972eda888c749 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Fri, 1 May 2026 21:03:10 +0100 Subject: [PATCH 2/7] Clean up WalkFrom path handling per review feedback Build prefixes by scanning for slashes instead of Split+Join, rename startAbs to startDir, and keep start in OS-native form to avoid ToSlash/FromSlash round-trips. --- gitignore.go | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/gitignore.go b/gitignore.go index 7cdc2b0..629db77 100644 --- a/gitignore.go +++ b/gitignore.go @@ -196,35 +196,43 @@ func Walk(root string, fn func(path string, d fs.DirEntry) error) error { // walk begins. Any .gitignore files between root and start are loaded // before the walk begins, so their patterns apply correctly. // -// The start parameter is a slash-separated path relative to root (e.g. -// "src/pkg"). Paths passed to fn are relative to root (not to start) -// and use the OS path separator. The start directory itself is passed -// to fn. +// The start parameter is a path relative to root (e.g. "src/pkg"), +// using either forward slashes or the OS path separator. Paths passed +// to fn are relative to root (not to start) and use the OS path +// separator. The start directory itself is passed to fn. func WalkFrom(root, start string, fn func(path string, d fs.DirEntry) error) error { if start == "" || start == "." { return Walk(root, fn) } - start = filepath.ToSlash(filepath.Clean(start)) + start = filepath.Clean(start) m := New(root) - parts := strings.Split(start, "/") - for i := range parts { - prefix := strings.Join(parts[:i+1], "/") - igPath := filepath.Join(root, filepath.FromSlash(prefix), ".gitignore") + { + slashed := filepath.ToSlash(start) + for i := 0; i < len(slashed); i++ { + if slashed[i] == '/' { + prefix := slashed[:i] + igPath := filepath.Join(root, prefix, ".gitignore") + if _, err := os.Stat(igPath); err == nil { + m.AddFromFile(igPath, prefix) + } + } + } + igPath := filepath.Join(root, start, ".gitignore") if _, err := os.Stat(igPath); err == nil { - m.AddFromFile(igPath, prefix) + m.AddFromFile(igPath, slashed) } } - startAbs := filepath.Join(root, filepath.FromSlash(start)) - info, err := os.Stat(startAbs) + startDir := filepath.Join(root, start) + info, err := os.Stat(startDir) if err != nil { return err } - startRel := filepath.FromSlash(start) + startRel := start if fn != nil { if err := fn(startRel, fs.FileInfoToDirEntry(info)); err != nil { return err From 8a237038e45c6789d421db1fc899c874fb51c4c1 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Wed, 6 May 2026 17:14:52 +0100 Subject: [PATCH 3/7] Use strings.IndexByte for WalkFrom prefix scan and drop redundant startRel --- gitignore.go | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/gitignore.go b/gitignore.go index 629db77..2f75d52 100644 --- a/gitignore.go +++ b/gitignore.go @@ -211,18 +211,21 @@ func WalkFrom(root, start string, fn func(path string, d fs.DirEntry) error) err { slashed := filepath.ToSlash(start) - for i := 0; i < len(slashed); i++ { - if slashed[i] == '/' { - prefix := slashed[:i] - igPath := filepath.Join(root, prefix, ".gitignore") + for off := 0; ; { + i := strings.IndexByte(slashed[off:], '/') + if i == -1 { + igPath := filepath.Join(root, start, ".gitignore") if _, err := os.Stat(igPath); err == nil { - m.AddFromFile(igPath, prefix) + m.AddFromFile(igPath, slashed) } + break } - } - igPath := filepath.Join(root, start, ".gitignore") - if _, err := os.Stat(igPath); err == nil { - m.AddFromFile(igPath, slashed) + prefix := slashed[:off+i] + igPath := filepath.Join(root, prefix, ".gitignore") + if _, err := os.Stat(igPath); err == nil { + m.AddFromFile(igPath, prefix) + } + off += i + 1 } } @@ -232,14 +235,13 @@ func WalkFrom(root, start string, fn func(path string, d fs.DirEntry) error) err return err } - startRel := start if fn != nil { - if err := fn(startRel, fs.FileInfoToDirEntry(info)); err != nil { + if err := fn(start, fs.FileInfoToDirEntry(info)); err != nil { return err } } - return walkRecursive(root, startRel, m, fn) + return walkRecursive(root, start, m, fn) } func walkRecursive(root, rel string, m *Matcher, fn func(string, fs.DirEntry) error) error { From 6cc2381faaea9d03bc52a3f466ac6989e9965e12 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Wed, 6 May 2026 17:25:16 +0100 Subject: [PATCH 4/7] Avoid loading start directory .gitignore twice in WalkFrom --- gitignore.go | 6 ++---- gitignore_test.go | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/gitignore.go b/gitignore.go index 2f75d52..78984c7 100644 --- a/gitignore.go +++ b/gitignore.go @@ -209,15 +209,13 @@ func WalkFrom(root, start string, fn func(path string, d fs.DirEntry) error) err m := New(root) + // Load .gitignore from each ancestor directory between root and start + // (exclusive of start itself, which walkRecursive loads). { slashed := filepath.ToSlash(start) for off := 0; ; { i := strings.IndexByte(slashed[off:], '/') if i == -1 { - igPath := filepath.Join(root, start, ".gitignore") - if _, err := os.Stat(igPath); err == nil { - m.AddFromFile(igPath, slashed) - } break } prefix := slashed[:off+i] diff --git a/gitignore_test.go b/gitignore_test.go index 7f84376..4c30cd2 100644 --- a/gitignore_test.go +++ b/gitignore_test.go @@ -2638,6 +2638,42 @@ func TestWalkFromLoadsIntermediateGitignore(t *testing.T) { } } +func TestWalkFromLoadsStartDirGitignore(t *testing.T) { + t.Setenv("GIT_CONFIG_GLOBAL", "/dev/null") + + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".git", "info"), 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, "src", "pkg"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "src", "pkg", ".gitignore"), []byte("*.out\n"), 0644); err != nil { + t.Fatal(err) + } + for _, f := range []string{"src/pkg/lib.go", "src/pkg/build.out"} { + if err := os.WriteFile(filepath.Join(root, f), []byte("x"), 0644); err != nil { + t.Fatal(err) + } + } + + got := make(map[string]bool) + err := gitignore.WalkFrom(root, "src/pkg", func(path string, d os.DirEntry) error { + got[filepath.ToSlash(path)] = true + return nil + }) + if err != nil { + t.Fatal(err) + } + + if !got["src/pkg/lib.go"] { + t.Error("WalkFrom should yield src/pkg/lib.go") + } + if got["src/pkg/build.out"] { + t.Error("WalkFrom should not yield src/pkg/build.out (ignored by src/pkg/.gitignore)") + } +} + func TestWalkFromLoadsGitInfoExclude(t *testing.T) { t.Setenv("GIT_CONFIG_GLOBAL", "/dev/null") From 3ca0970b2b11b2da2f36f378adc2f4ad2fd78aa7 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Wed, 6 May 2026 17:57:42 +0100 Subject: [PATCH 5/7] Drop redundant Stat before AddFromFile in WalkFrom ancestor scan --- gitignore.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/gitignore.go b/gitignore.go index 78984c7..27e4b8d 100644 --- a/gitignore.go +++ b/gitignore.go @@ -219,10 +219,7 @@ func WalkFrom(root, start string, fn func(path string, d fs.DirEntry) error) err break } prefix := slashed[:off+i] - igPath := filepath.Join(root, prefix, ".gitignore") - if _, err := os.Stat(igPath); err == nil { - m.AddFromFile(igPath, prefix) - } + m.AddFromFile(filepath.Join(root, prefix, ".gitignore"), prefix) off += i + 1 } } From bdecf4d2f8be21cd739539b9f5b30a3358f66b3d Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Thu, 7 May 2026 19:39:17 +0100 Subject: [PATCH 6/7] Short-circuit WalkFrom when cleaned start resolves to root filepath.Clean("./") returns ".", which slipped past the empty/dot guard and reached walkRecursive with rel=".". The duplicate load it triggered was inert (prefix-scoped patterns with scope "." can't match real path segments), but the dead I/O is avoidable and the contract is clearer if every root-equivalent input takes the Walk fallback. --- gitignore.go | 3 +++ gitignore_test.go | 68 +++++++++++++++++++++++++---------------------- 2 files changed, 39 insertions(+), 32 deletions(-) diff --git a/gitignore.go b/gitignore.go index 27e4b8d..803ebd6 100644 --- a/gitignore.go +++ b/gitignore.go @@ -206,6 +206,9 @@ func WalkFrom(root, start string, fn func(path string, d fs.DirEntry) error) err } start = filepath.Clean(start) + if start == "." { + return Walk(root, fn) + } m := New(root) diff --git a/gitignore_test.go b/gitignore_test.go index 4c30cd2..0e5e351 100644 --- a/gitignore_test.go +++ b/gitignore_test.go @@ -2721,41 +2721,45 @@ func TestWalkFromLoadsGitInfoExclude(t *testing.T) { } } -func TestWalkFromEmptyStart(t *testing.T) { - t.Setenv("GIT_CONFIG_GLOBAL", "/dev/null") +func TestWalkFromRootEquivalentStart(t *testing.T) { + for _, start := range []string{"", ".", "./", "./."} { + t.Run("start="+start, func(t *testing.T) { + t.Setenv("GIT_CONFIG_GLOBAL", "/dev/null") - root := t.TempDir() - if err := os.MkdirAll(filepath.Join(root, ".git", "info"), 0755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte("*.log\n"), 0644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "file.txt"), []byte("x"), 0644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "trace.log"), []byte("x"), 0644); err != nil { - t.Fatal(err) - } + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".git", "info"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte("*.log\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "file.txt"), []byte("x"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "trace.log"), []byte("x"), 0644); err != nil { + t.Fatal(err) + } - var collected []string - err := gitignore.WalkFrom(root, "", func(path string, d os.DirEntry) error { - collected = append(collected, filepath.ToSlash(path)) - return nil - }) - if err != nil { - t.Fatal(err) - } + var collected []string + err := gitignore.WalkFrom(root, start, func(path string, d os.DirEntry) error { + collected = append(collected, filepath.ToSlash(path)) + return nil + }) + if err != nil { + t.Fatal(err) + } - got := make(map[string]bool) - for _, p := range collected { - got[p] = true - } + got := make(map[string]bool) + for _, p := range collected { + got[p] = true + } - if !got["file.txt"] { - t.Error("WalkFrom with empty start should yield file.txt") - } - if got["trace.log"] { - t.Error("WalkFrom with empty start should not yield trace.log") + if !got["file.txt"] { + t.Errorf("WalkFrom(start=%q) should yield file.txt", start) + } + if got["trace.log"] { + t.Errorf("WalkFrom(start=%q) should not yield trace.log", start) + } + }) } } From ba7225bebd904da0a46ffda9915ccd1e728ee932 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Thu, 7 May 2026 19:39:29 +0100 Subject: [PATCH 7/7] Document WalkFrom in README --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 2fb8bab..44e8642 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,15 @@ gitignore.Walk("/path/to/repo", func(path string, d fs.DirEntry) error { }) ``` +`WalkFrom` walks a subdirectory while still respecting root-level rules. It loads global excludes, `.git/info/exclude`, the root `.gitignore`, and every `.gitignore` between the root and the start directory before walking, so patterns scoped above the start still apply. Paths passed to `fn` are relative to the root. + +```go +gitignore.WalkFrom("/path/to/repo", "src/pkg", func(path string, d fs.DirEntry) error { + fmt.Println(path) // e.g. "src/pkg/lib.go" + return nil +}) +``` + ## Error handling Invalid patterns (like unknown POSIX character classes) are silently skipped during matching. To inspect them: