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:
@@ -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
|
||||
}
|
||||
@@ -63,7 +63,11 @@ func (p *Processor) processGitChanges() error {
|
||||
if IsSupportedFile(change.Path) {
|
||||
supportedChanges = append(supportedChanges, change)
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/carlosarraes/shush/internal/ignore"
|
||||
"github.com/carlosarraes/shush/internal/types"
|
||||
)
|
||||
|
||||
@@ -152,5 +153,17 @@ func IsSupportedFile(filename string) bool {
|
||||
}
|
||||
ext = strings.TrimPrefix(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)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,11 @@ func (p *Processor) Process() error {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user