a73x

325329b2

feat(ignore): gitignore + hard-excludes with live reload

a73x   2026-04-29 05:52

Commit message
feat(ignore): gitignore + hard-excludes with live reload

go.mod
Old New
@@ -1,3 +1,5 @@
1 module github.com/xanderle/claudealong 1 module github.com/xanderle/claudealong
2 2
3 go 1.26.2 3 go 1.26.2
4
5 require github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect
go.sum
Old New
@@ -0,0 +1,8 @@
1 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
2 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
3 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
4 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
5 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
6 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
7 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
8 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
internal/ignore/ignore.go
Old New
@@ -0,0 +1,75 @@
1 package ignore
2
3 import (
4 "os"
5 "path/filepath"
6 "strings"
7 "sync"
8
9 gitignore "github.com/sabhiram/go-gitignore"
10 )
11
12 var hardExcludeDirs = []string{
13 ".git", "node_modules", "dist", "build", "target", ".venv",
14 }
15 var hardExcludeSuffixes = []string{
16 ".swp", ".tmp", ".lock",
17 }
18
19 type Matcher struct {
20 root string
21 mu sync.RWMutex
22 gi *gitignore.GitIgnore
23 }
24
25 func New(root string) (*Matcher, error) {
26 m := &Matcher{root: root}
27 if err := m.Reload(); err != nil {
28 return nil, err
29 }
30 return m, nil
31 }
32
33 func (m *Matcher) Reload() error {
34 path := filepath.Join(m.root, ".gitignore")
35 var gi *gitignore.GitIgnore
36 if _, err := os.Stat(path); err == nil {
37 loaded, err := gitignore.CompileIgnoreFile(path)
38 if err != nil {
39 return err
40 }
41 gi = loaded
42 }
43 m.mu.Lock()
44 m.gi = gi
45 m.mu.Unlock()
46 return nil
47 }
48
49 func (m *Matcher) ShouldIgnore(path string) bool {
50 rel, err := filepath.Rel(m.root, path)
51 if err != nil || strings.HasPrefix(rel, "..") {
52 return true
53 }
54 parts := strings.Split(rel, string(filepath.Separator))
55 for _, p := range parts {
56 for _, ex := range hardExcludeDirs {
57 if p == ex {
58 return true
59 }
60 }
61 }
62 base := filepath.Base(rel)
63 for _, sfx := range hardExcludeSuffixes {
64 if strings.HasSuffix(base, sfx) {
65 return true
66 }
67 }
68 m.mu.RLock()
69 gi := m.gi
70 m.mu.RUnlock()
71 if gi != nil && gi.MatchesPath(rel) {
72 return true
73 }
74 return false
75 }
internal/ignore/ignore_test.go
Old New
@@ -0,0 +1,80 @@
1 package ignore
2
3 import (
4 "os"
5 "path/filepath"
6 "testing"
7 )
8
9 func writeGitignore(t *testing.T, dir, content string) {
10 t.Helper()
11 if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(content), 0o644); err != nil {
12 t.Fatal(err)
13 }
14 }
15
16 func TestHardExcludes(t *testing.T) {
17 dir := t.TempDir()
18 m, err := New(dir)
19 if err != nil {
20 t.Fatal(err)
21 }
22 cases := []struct {
23 path string
24 want bool
25 }{
26 {filepath.Join(dir, ".git", "HEAD"), true},
27 {filepath.Join(dir, "node_modules", "x", "y.js"), true},
28 {filepath.Join(dir, "dist", "out.js"), true},
29 {filepath.Join(dir, "build", "out.o"), true},
30 {filepath.Join(dir, "target", "x"), true},
31 {filepath.Join(dir, ".venv", "bin", "python"), true},
32 {filepath.Join(dir, "src", "foo.go"), false},
33 {filepath.Join(dir, "foo.swp"), true},
34 {filepath.Join(dir, "lock.lock"), true},
35 {filepath.Join(dir, "a.tmp"), true},
36 }
37 for _, c := range cases {
38 if got := m.ShouldIgnore(c.path); got != c.want {
39 t.Errorf("ShouldIgnore(%q) = %v, want %v", c.path, got, c.want)
40 }
41 }
42 }
43
44 func TestGitignorePatterns(t *testing.T) {
45 dir := t.TempDir()
46 writeGitignore(t, dir, "secret.txt\n*.bak\n")
47 m, err := New(dir)
48 if err != nil {
49 t.Fatal(err)
50 }
51 if !m.ShouldIgnore(filepath.Join(dir, "secret.txt")) {
52 t.Error("secret.txt should be ignored")
53 }
54 if !m.ShouldIgnore(filepath.Join(dir, "old.bak")) {
55 t.Error("old.bak should be ignored")
56 }
57 if m.ShouldIgnore(filepath.Join(dir, "ok.txt")) {
58 t.Error("ok.txt should NOT be ignored")
59 }
60 }
61
62 func TestReloadPicksUpNewPatterns(t *testing.T) {
63 dir := t.TempDir()
64 writeGitignore(t, dir, "")
65 m, err := New(dir)
66 if err != nil {
67 t.Fatal(err)
68 }
69 p := filepath.Join(dir, "fresh.txt")
70 if m.ShouldIgnore(p) {
71 t.Fatal("fresh.txt should not be ignored before reload")
72 }
73 writeGitignore(t, dir, "fresh.txt\n")
74 if err := m.Reload(); err != nil {
75 t.Fatal(err)
76 }
77 if !m.ShouldIgnore(p) {
78 t.Error("fresh.txt should be ignored after reload")
79 }
80 }