a73x

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")
	}
}