325329b2
feat(ignore): gitignore + hard-excludes with live reload
a73x 2026-04-29 05:52
diff --git a/go.mod b/go.mod index 0ac0f68..385a388 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/xanderle/claudealong go 1.26.2 require github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1a4b4fc --- /dev/null +++ b/go.sum @@ -0,0 +1,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI= github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/ignore/ignore.go b/internal/ignore/ignore.go new file mode 100644 index 0000000..31d0bf5 --- /dev/null +++ b/internal/ignore/ignore.go @@ -0,0 +1,75 @@ package ignore import ( "os" "path/filepath" "strings" "sync" gitignore "github.com/sabhiram/go-gitignore" ) var hardExcludeDirs = []string{ ".git", "node_modules", "dist", "build", "target", ".venv", } var hardExcludeSuffixes = []string{ ".swp", ".tmp", ".lock", } type Matcher struct { root string mu sync.RWMutex gi *gitignore.GitIgnore } func New(root string) (*Matcher, error) { m := &Matcher{root: root} if err := m.Reload(); err != nil { return nil, err } return m, nil } func (m *Matcher) Reload() error { path := filepath.Join(m.root, ".gitignore") var gi *gitignore.GitIgnore if _, err := os.Stat(path); err == nil { loaded, err := gitignore.CompileIgnoreFile(path) if err != nil { return err } gi = loaded } m.mu.Lock() m.gi = gi m.mu.Unlock() return nil } func (m *Matcher) ShouldIgnore(path string) bool { rel, err := filepath.Rel(m.root, path) if err != nil || strings.HasPrefix(rel, "..") { return true } parts := strings.Split(rel, string(filepath.Separator)) for _, p := range parts { for _, ex := range hardExcludeDirs { if p == ex { return true } } } base := filepath.Base(rel) for _, sfx := range hardExcludeSuffixes { if strings.HasSuffix(base, sfx) { return true } } m.mu.RLock() gi := m.gi m.mu.RUnlock() if gi != nil && gi.MatchesPath(rel) { return true } return false } diff --git a/internal/ignore/ignore_test.go b/internal/ignore/ignore_test.go new file mode 100644 index 0000000..5b88ff5 --- /dev/null +++ b/internal/ignore/ignore_test.go @@ -0,0 +1,80 @@ package ignore import ( "os" "path/filepath" "testing" ) func writeGitignore(t *testing.T, dir, content string) { t.Helper() if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(content), 0o644); err != nil { t.Fatal(err) } } func TestHardExcludes(t *testing.T) { dir := t.TempDir() m, err := New(dir) if err != nil { t.Fatal(err) } cases := []struct { path string want bool }{ {filepath.Join(dir, ".git", "HEAD"), true}, {filepath.Join(dir, "node_modules", "x", "y.js"), true}, {filepath.Join(dir, "dist", "out.js"), true}, {filepath.Join(dir, "build", "out.o"), true}, {filepath.Join(dir, "target", "x"), true}, {filepath.Join(dir, ".venv", "bin", "python"), true}, {filepath.Join(dir, "src", "foo.go"), false}, {filepath.Join(dir, "foo.swp"), true}, {filepath.Join(dir, "lock.lock"), true}, {filepath.Join(dir, "a.tmp"), true}, } for _, c := range cases { if got := m.ShouldIgnore(c.path); got != c.want { t.Errorf("ShouldIgnore(%q) = %v, want %v", c.path, got, c.want) } } } func TestGitignorePatterns(t *testing.T) { dir := t.TempDir() writeGitignore(t, dir, "secret.txt\n*.bak\n") m, err := New(dir) if err != nil { t.Fatal(err) } if !m.ShouldIgnore(filepath.Join(dir, "secret.txt")) { t.Error("secret.txt should be ignored") } if !m.ShouldIgnore(filepath.Join(dir, "old.bak")) { t.Error("old.bak should be ignored") } if m.ShouldIgnore(filepath.Join(dir, "ok.txt")) { t.Error("ok.txt should NOT be ignored") } } func TestReloadPicksUpNewPatterns(t *testing.T) { dir := t.TempDir() writeGitignore(t, dir, "") m, err := New(dir) if err != nil { t.Fatal(err) } p := filepath.Join(dir, "fresh.txt") if m.ShouldIgnore(p) { t.Fatal("fresh.txt should not be ignored before reload") } writeGitignore(t, dir, "fresh.txt\n") if err := m.Reload(); err != nil { t.Fatal(err) } if !m.ShouldIgnore(p) { t.Error("fresh.txt should be ignored after reload") } }