feat: add .shushignore support for flexible file exclusion

- Add .shushignore file support (project root + global ~/.config/.shushignore)
- Support gitignore-style patterns: wildcards (*), directories (dir/), negation (\!pattern)
- Universal integration: works with all processing modes (files, directories, git-aware)
- Smart messaging: explicit ignore messages for direct file access
- Silent skipping: directory traversal automatically skips ignored files
- Enhanced verbose output: distinguishes between ignored vs unsupported files
- Pattern examples: *.tmp, build/, test*.js, \!important.js
This commit is contained in:
carraes
2025-07-07 15:39:20 -03:00
parent c50bd8f203
commit 6e78bc9700
4 changed files with 254 additions and 2 deletions
+230
View File
@@ -0,0 +1,230 @@
package ignore
import (
"bufio"
"os"
"path/filepath"
"strings"
)
type Ignore struct {
patterns []pattern
}
type pattern struct {
original string
normalized string
isNegation bool
isDirectory bool
}
func New() *Ignore {
return &Ignore{
patterns: make([]pattern, 0),
}
}
func Load() (*Ignore, error) {
ignore := New()
if globalPath := getGlobalIgnorePath(); globalPath != "" {
if err := ignore.loadFromFile(globalPath); err != nil && !os.IsNotExist(err) {
return nil, err
}
}
if projectPath := getProjectIgnorePath(); projectPath != "" {
if err := ignore.loadFromFile(projectPath); err != nil && !os.IsNotExist(err) {
return nil, err
}
}
return ignore, nil
}
func (i *Ignore) loadFromFile(path string) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
i.addPattern(line)
}
return scanner.Err()
}
func (i *Ignore) addPattern(patternStr string) {
p := pattern{
original: patternStr,
}
if strings.HasPrefix(patternStr, "!") {
p.isNegation = true
patternStr = patternStr[1:]
}
if strings.HasSuffix(patternStr, "/") {
p.isDirectory = true
patternStr = patternStr[:len(patternStr)-1]
}
p.normalized = patternStr
i.patterns = append(i.patterns, p)
}
func (i *Ignore) IsIgnored(filePath string) bool {
relPath := filepath.Clean(filePath)
if strings.HasPrefix(relPath, "./") {
relPath = relPath[2:]
}
matched := false
for _, p := range i.patterns {
if i.matchesPattern(relPath, p) {
matched = !p.isNegation
}
}
return matched
}
func (i *Ignore) matchesPattern(filePath string, p pattern) bool {
if p.isDirectory {
if info, err := os.Stat(filePath); err != nil || !info.IsDir() {
return false
}
}
return i.globMatch(p.normalized, filePath)
}
func (i *Ignore) globMatch(pattern, path string) bool {
if pattern == path {
return true
}
if strings.Contains(pattern, "*") {
return i.wildcardMatch(pattern, path)
}
if strings.HasSuffix(pattern, "/") {
dirPattern := pattern[:len(pattern)-1]
return strings.HasPrefix(path, dirPattern+"/") || path == dirPattern
}
if strings.HasPrefix(path, pattern+"/") {
return true
}
if filepath.Base(path) == pattern {
return true
}
return false
}
func (i *Ignore) wildcardMatch(pattern, path string) bool {
parts := strings.Split(pattern, "*")
if len(parts) == 1 {
return pattern == path
}
if len(parts[0]) > 0 && !strings.HasPrefix(path, parts[0]) {
return false
}
if len(parts[len(parts)-1]) > 0 && !strings.HasSuffix(path, parts[len(parts)-1]) {
return false
}
currentPath := path
for i := 0; i < len(parts); i++ {
part := parts[i]
if part == "" {
continue
}
if i == 0 {
currentPath = currentPath[len(part):]
continue
}
if i == len(parts)-1 {
break
}
index := strings.Index(currentPath, part)
if index == -1 {
return false
}
currentPath = currentPath[index+len(part):]
}
return true
}
func getGlobalIgnorePath() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".config", ".shushignore")
}
func getProjectIgnorePath() string {
cwd, err := os.Getwd()
if err != nil {
return ""
}
localIgnore := filepath.Join(cwd, ".shushignore")
if fileExists(localIgnore) {
return localIgnore
}
if gitRoot := findGitRoot(cwd); gitRoot != "" {
gitIgnore := filepath.Join(gitRoot, ".shushignore")
if fileExists(gitIgnore) && gitIgnore != localIgnore {
return gitIgnore
}
}
return ""
}
func findGitRoot(startDir string) string {
dir := startDir
for {
gitDir := filepath.Join(dir, ".git")
if fileExists(gitDir) {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
return ""
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
+5 -1
View File
@@ -63,7 +63,11 @@ func (p *Processor) processGitChanges() error {
if IsSupportedFile(change.Path) { if IsSupportedFile(change.Path) {
supportedChanges = append(supportedChanges, change) supportedChanges = append(supportedChanges, change)
} else if p.cli.Verbose { } else if p.cli.Verbose {
fmt.Printf("Skipping unsupported file: %s\n", change.Path) if IsIgnored(change.Path) {
fmt.Printf("Skipping ignored file: %s\n", change.Path)
} else {
fmt.Printf("Skipping unsupported file: %s\n", change.Path)
}
} }
} }
+14 -1
View File
@@ -5,6 +5,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/carlosarraes/shush/internal/ignore"
"github.com/carlosarraes/shush/internal/types" "github.com/carlosarraes/shush/internal/types"
) )
@@ -152,5 +153,17 @@ func IsSupportedFile(filename string) bool {
} }
ext = strings.TrimPrefix(ext, ".") ext = strings.TrimPrefix(ext, ".")
_, ok := languageMap[ext] _, ok := languageMap[ext]
return ok if !ok {
return false
}
return !IsIgnored(filename)
}
func IsIgnored(filename string) bool {
ignoreChecker, err := ignore.Load()
if err != nil {
return false
}
return ignoreChecker.IsIgnored(filename)
} }
+5
View File
@@ -40,6 +40,11 @@ func (p *Processor) Process() error {
return p.processDirectory(p.cli.Path) return p.processDirectory(p.cli.Path)
} }
if IsIgnored(p.cli.Path) {
fmt.Printf("File %s is in .shushignore, ignoring\n", p.cli.Path)
return nil
}
return p.processFile(p.cli.Path) return p.processFile(p.cli.Path)
} }