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,9 +63,13 @@ 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 {
|
||||||
|
if IsIgnored(change.Path) {
|
||||||
|
fmt.Printf("Skipping ignored file: %s\n", change.Path)
|
||||||
|
} else {
|
||||||
fmt.Printf("Skipping unsupported file: %s\n", change.Path)
|
fmt.Printf("Skipping unsupported file: %s\n", change.Path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if len(supportedChanges) == 0 {
|
if len(supportedChanges) == 0 {
|
||||||
fmt.Println("No supported files found to process")
|
fmt.Println("No supported files found to process")
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user