325329b2
feat(ignore): gitignore + hard-excludes with live reload
a73x 2026-04-29 05:52
Commit message
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 | } | ||