Feedback changes

This commit is contained in:
Matt Stone 2026-01-08 19:22:33 +10:00
parent 152bdb22cd
commit cc3431dd04
5 changed files with 162 additions and 84 deletions

View file

@ -6,6 +6,7 @@ package manager
import (
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
@ -42,6 +43,17 @@ func (m *mockProgressReporter) Increment() {}
func (m *mockProgressReporter) Definite() {}
func (m *mockProgressReporter) ExecuteTask(description string, fn func()) { fn() }
// stashIgnorePathFilter wraps StashIgnoreFilter to implement PathFilter for testing.
// It provides a fixed library root for the filter.
type stashIgnorePathFilter struct {
filter *file.StashIgnoreFilter
libraryRoot string
}
func (f *stashIgnorePathFilter) Accept(ctx context.Context, path string, info fs.FileInfo) bool {
return f.filter.Accept(ctx, path, info, f.libraryRoot)
}
// createTestFileOnDisk creates a file with some content.
func createTestFileOnDisk(t *testing.T, dir, name string) string {
t.Helper()
@ -134,8 +146,11 @@ temp/
FingerprintCalculator: &mockFingerprintCalculator{},
}
// Create stashignore filter.
stashIgnoreFilter := file.NewStashIgnoreFilter(tmpDir)
// Create stashignore filter with library root.
stashIgnoreFilter := &stashIgnorePathFilter{
filter: file.NewStashIgnoreFilter(),
libraryRoot: tmpDir,
}
// Run scan.
ctx := context.Background()
@ -250,8 +265,11 @@ func TestScannerWithNestedStashIgnore(t *testing.T) {
FingerprintCalculator: &mockFingerprintCalculator{},
}
// Create stashignore filter.
stashIgnoreFilter := file.NewStashIgnoreFilter(tmpDir)
// Create stashignore filter with library root.
stashIgnoreFilter := &stashIgnorePathFilter{
filter: file.NewStashIgnoreFilter(),
libraryRoot: tmpDir,
}
// Run scan.
ctx := context.Background()
@ -335,8 +353,11 @@ func TestScannerWithoutStashIgnore(t *testing.T) {
FingerprintCalculator: &mockFingerprintCalculator{},
}
// Create stashignore filter (but no .stashignore file exists).
stashIgnoreFilter := file.NewStashIgnoreFilter(tmpDir)
// Create stashignore filter with library root (but no .stashignore file exists).
stashIgnoreFilter := &stashIgnorePathFilter{
filter: file.NewStashIgnoreFilter(),
libraryRoot: tmpDir,
}
// Run scan.
ctx := context.Background()
@ -425,8 +446,11 @@ func TestScannerWithNegationPattern(t *testing.T) {
FingerprintCalculator: &mockFingerprintCalculator{},
}
// Create stashignore filter.
stashIgnoreFilter := file.NewStashIgnoreFilter(tmpDir)
// Create stashignore filter with library root.
stashIgnoreFilter := &stashIgnorePathFilter{
filter: file.NewStashIgnoreFilter(),
libraryRoot: tmpDir,
}
// Run scan.
ctx := context.Background()

View file

@ -254,6 +254,7 @@ type scanFilter struct {
videoExcludeRegex []*regexp.Regexp
imageExcludeRegex []*regexp.Regexp
minModTime time.Time
stashIgnoreFilter *file.StashIgnoreFilter
}
func newScanFilter(c *config.Config, repo models.Repository, minModTime time.Time) *scanFilter {
@ -267,6 +268,7 @@ func newScanFilter(c *config.Config, repo models.Repository, minModTime time.Tim
videoExcludeRegex: generateRegexps(c.GetExcludes()),
imageExcludeRegex: generateRegexps(c.GetImageExcludes()),
minModTime: minModTime,
stashIgnoreFilter: file.NewStashIgnoreFilter(),
}
}
@ -287,6 +289,12 @@ func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo)
return false
}
// Check .stashignore files, bounded to the library root.
if !f.stashIgnoreFilter.Accept(ctx, path, info, s.Path) {
logger.Debugf("Skipping %s due to .stashignore", path)
return false
}
isVideoFile := useAsVideo(path)
isImageFile := useAsImage(path)
isZipFile := fsutil.MatchExtension(path, f.zipExt)

View file

@ -9,6 +9,7 @@ import (
"sync"
ignore "github.com/sabhiram/go-gitignore"
"github.com/stashapp/stash/pkg/logger"
)
const stashIgnoreFilename = ".stashignore"
@ -16,9 +17,6 @@ const stashIgnoreFilename = ".stashignore"
// StashIgnoreFilter implements PathFilter to exclude files/directories
// based on .stashignore files with gitignore-style patterns.
type StashIgnoreFilter struct {
// root is the root directory being scanned.
root string
// cache stores compiled ignore patterns per directory.
cache sync.Map // map[string]*ignoreEntry
}
@ -31,46 +29,36 @@ type ignoreEntry struct {
dir string
}
// NewStashIgnoreFilter creates a new StashIgnoreFilter for the given root directory.
func NewStashIgnoreFilter(root string) *StashIgnoreFilter {
return &StashIgnoreFilter{
root: root,
}
// NewStashIgnoreFilter creates a new StashIgnoreFilter.
func NewStashIgnoreFilter() *StashIgnoreFilter {
return &StashIgnoreFilter{}
}
// Accept returns true if the path should be included in the scan.
// It checks for .stashignore files in the directory hierarchy and
// applies gitignore-style pattern matching.
func (f *StashIgnoreFilter) Accept(ctx context.Context, path string, info fs.FileInfo) bool {
// The libraryRoot parameter bounds the search for .stashignore files -
// only directories within the library root are checked.
func (f *StashIgnoreFilter) Accept(ctx context.Context, path string, info fs.FileInfo, libraryRoot string) bool {
// Always accept .stashignore files themselves so they can be read.
if filepath.Base(path) == stashIgnoreFilename {
return true
}
// Get the directory containing this path.
var dir string
if info.IsDir() {
dir = filepath.Dir(path)
} else {
dir = filepath.Dir(path)
}
// Collect all applicable ignore entries from root to this directory.
entries := f.collectIgnoreEntries(dir)
// Check if any pattern matches (and isn't negated).
relPath, err := filepath.Rel(f.root, path)
if err != nil {
// If we can't get relative path, accept the file.
// If no library root provided, accept the file (safety fallback).
if libraryRoot == "" {
return true
}
// Normalise to forward slashes for consistent matching.
relPath = filepath.ToSlash(relPath)
// Get the directory containing this path.
dir := filepath.Dir(path)
// For directories, also check with trailing slash.
if info.IsDir() {
relPath = relPath + "/"
// Collect all applicable ignore entries from library root to this directory.
entries := f.collectIgnoreEntries(dir, libraryRoot)
// If no .stashignore files found, accept the file.
if len(entries) == 0 {
return true
}
// Check each ignore entry in order (from root to most specific).
@ -90,43 +78,65 @@ func (f *StashIgnoreFilter) Accept(ctx context.Context, path string, info fs.Fil
if entry.patterns.MatchesPath(entryRelPath) {
ignored = true
}
// Check negation by testing without the directory suffix.
// The library handles negation internally.
}
return !ignored
}
// collectIgnoreEntries gathers all ignore entries from root to the given directory.
func (f *StashIgnoreFilter) collectIgnoreEntries(dir string) []*ignoreEntry {
// collectIgnoreEntries gathers all ignore entries from library root to the given directory.
// It walks up the directory tree from dir to libraryRoot and returns entries in order
// from root to most specific.
func (f *StashIgnoreFilter) collectIgnoreEntries(dir string, libraryRoot string) []*ignoreEntry {
// Collect directories from library root down to current dir.
var dirs []string
// Clean paths for consistent comparison.
dir = filepath.Clean(dir)
libraryRoot = filepath.Clean(libraryRoot)
// Walk up from dir to library root, collecting directories.
current := dir
for {
// Check if we're still within the library root.
if !isPathInOrEqual(libraryRoot, current) {
break
}
dirs = append([]string{current}, dirs...) // Prepend to maintain root-to-leaf order.
// Stop if we've reached the library root.
if current == libraryRoot {
break
}
parent := filepath.Dir(current)
if parent == current {
// Reached filesystem root without finding library root.
break
}
current = parent
}
// Check each directory for .stashignore files.
var entries []*ignoreEntry
// Walk from root to current directory.
current := f.root
relDir, err := filepath.Rel(f.root, dir)
if err != nil {
return entries
}
// Check root directory first.
if entry := f.getOrLoadIgnoreEntry(current); entry != nil {
entries = append(entries, entry)
}
// Then check each subdirectory.
if relDir != "." {
parts := strings.Split(filepath.ToSlash(relDir), "/")
for _, part := range parts {
current = filepath.Join(current, part)
if entry := f.getOrLoadIgnoreEntry(current); entry != nil {
entries = append(entries, entry)
}
for _, d := range dirs {
if entry := f.getOrLoadIgnoreEntry(d); entry != nil {
entries = append(entries, entry)
}
}
return entries
}
// isPathInOrEqual checks if path is equal to or inside root.
func isPathInOrEqual(root, path string) bool {
if path == root {
return true
}
// Check if path starts with root + separator.
return strings.HasPrefix(path, root+string(filepath.Separator))
}
// getOrLoadIgnoreEntry returns the cached ignore entry for a directory, or loads it.
func (f *StashIgnoreFilter) getOrLoadIgnoreEntry(dir string) *ignoreEntry {
// Check cache first.
@ -140,13 +150,15 @@ func (f *StashIgnoreFilter) getOrLoadIgnoreEntry(dir string) *ignoreEntry {
// Try to load .stashignore from this directory.
stashIgnorePath := filepath.Join(dir, stashIgnoreFilename)
patterns, err := f.loadIgnoreFile(stashIgnorePath, dir)
if err != nil {
// Cache negative result.
patterns, err := f.loadIgnoreFile(stashIgnorePath)
if err != nil || patterns == nil {
// Cache negative result (file doesn't exist or has no patterns).
f.cache.Store(dir, &ignoreEntry{patterns: nil, dir: dir})
return nil
}
logger.Debugf("Loaded .stashignore from %s", dir)
entry := &ignoreEntry{
patterns: patterns,
dir: dir,
@ -156,7 +168,7 @@ func (f *StashIgnoreFilter) getOrLoadIgnoreEntry(dir string) *ignoreEntry {
}
// loadIgnoreFile loads and compiles a .stashignore file.
func (f *StashIgnoreFilter) loadIgnoreFile(path string, dir string) (*ignore.GitIgnore, error) {
func (f *StashIgnoreFilter) loadIgnoreFile(path string) (*ignore.GitIgnore, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
@ -183,7 +195,8 @@ func (f *StashIgnoreFilter) loadIgnoreFile(path string, dir string) (*ignore.Git
}
if len(patterns) == 0 {
return nil, os.ErrNotExist
// File exists but has no patterns (e.g., only comments).
return nil, nil
}
return ignore.CompileIgnoreLines(patterns...), nil

View file

@ -64,7 +64,7 @@ func walkAndFilter(t *testing.T, root string, filter *StashIgnoreFilter) []strin
return err
}
if filter.Accept(ctx, path, info) {
if filter.Accept(ctx, path, info, root) {
relPath, _ := filepath.Rel(root, path)
accepted = append(accepted, relPath)
} else if info.IsDir() {
@ -111,7 +111,7 @@ func TestStashIgnore_ExactFilename(t *testing.T) {
// Create .stashignore that excludes exact filename.
createTestFileWithContent(t, tmpDir, ".stashignore", "ignore_me.mp4\n")
filter := NewStashIgnoreFilter(tmpDir)
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
expected := []string{
@ -136,7 +136,7 @@ func TestStashIgnore_WildcardPattern(t *testing.T) {
// Create .stashignore that excludes by extension.
createTestFileWithContent(t, tmpDir, ".stashignore", "*.tmp\n*.log\n")
filter := NewStashIgnoreFilter(tmpDir)
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
expected := []string{
@ -162,7 +162,7 @@ func TestStashIgnore_DirectoryExclusion(t *testing.T) {
// Create .stashignore that excludes a directory.
createTestFileWithContent(t, tmpDir, ".stashignore", "excluded_dir/\n")
filter := NewStashIgnoreFilter(tmpDir)
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
expected := []string{
@ -186,7 +186,7 @@ func TestStashIgnore_NegationPattern(t *testing.T) {
// Create .stashignore that excludes *.tmp but keeps one.
createTestFileWithContent(t, tmpDir, ".stashignore", "*.tmp\n!keep_this.tmp\n")
filter := NewStashIgnoreFilter(tmpDir)
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
expected := []string{
@ -213,7 +213,7 @@ ignore_me.mp4
`
createTestFileWithContent(t, tmpDir, ".stashignore", stashignore)
filter := NewStashIgnoreFilter(tmpDir)
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
expected := []string{
@ -241,7 +241,7 @@ func TestStashIgnore_NestedStashIgnoreFiles(t *testing.T) {
// Subdir .stashignore excludes *.log.
createTestFileWithContent(t, tmpDir, "subdir/.stashignore", "*.log\n")
filter := NewStashIgnoreFilter(tmpDir)
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
// *.tmp from root should apply everywhere.
@ -269,7 +269,7 @@ func TestStashIgnore_PathPattern(t *testing.T) {
// Create .stashignore that excludes a specific path.
createTestFileWithContent(t, tmpDir, ".stashignore", "subdir/skip_this.mp4\n")
filter := NewStashIgnoreFilter(tmpDir)
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
expected := []string{
@ -298,7 +298,7 @@ func TestStashIgnore_DoubleStarPattern(t *testing.T) {
// Create .stashignore that excludes temp directories at any level.
createTestFileWithContent(t, tmpDir, ".stashignore", "**/temp/\n")
filter := NewStashIgnoreFilter(tmpDir)
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
expected := []string{
@ -323,7 +323,7 @@ func TestStashIgnore_LeadingSlashPattern(t *testing.T) {
// Create .stashignore that excludes only at root level.
createTestFileWithContent(t, tmpDir, ".stashignore", "/ignore.mp4\n")
filter := NewStashIgnoreFilter(tmpDir)
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
// Only root ignore.mp4 should be excluded.
@ -345,7 +345,7 @@ func TestStashIgnore_NoStashIgnoreFile(t *testing.T) {
createTestDir(t, tmpDir, "subdir")
createTestFile(t, tmpDir, "subdir/video3.mp4")
filter := NewStashIgnoreFilter(tmpDir)
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
// All files should be accepted.
@ -370,7 +370,7 @@ func TestStashIgnore_HiddenDirectories(t *testing.T) {
// Create .stashignore that excludes hidden directories.
createTestFileWithContent(t, tmpDir, ".stashignore", ".*\n!.stashignore\n")
filter := NewStashIgnoreFilter(tmpDir)
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
expected := []string{
@ -393,7 +393,7 @@ func TestStashIgnore_MultiplePatternsSameLine(t *testing.T) {
// Each pattern should be on its own line.
createTestFileWithContent(t, tmpDir, ".stashignore", "*.tmp\n*.log\n*.bak\n")
filter := NewStashIgnoreFilter(tmpDir)
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
expected := []string{
@ -414,7 +414,7 @@ func TestStashIgnore_TrailingSpaces(t *testing.T) {
// Pattern with trailing spaces (should be trimmed).
createTestFileWithContent(t, tmpDir, ".stashignore", "ignore_me.mp4 \n")
filter := NewStashIgnoreFilter(tmpDir)
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
expected := []string{
@ -435,7 +435,7 @@ func TestStashIgnore_EscapedHash(t *testing.T) {
// Escaped hash should match literal # character.
createTestFileWithContent(t, tmpDir, ".stashignore", "\\#filename.mp4\n")
filter := NewStashIgnoreFilter(tmpDir)
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
expected := []string{
@ -457,7 +457,7 @@ func TestStashIgnore_CaseSensitiveMatching(t *testing.T) {
// Pattern should match exactly (case-sensitive).
createTestFileWithContent(t, tmpDir, ".stashignore", "video_lower.mp4\n")
filter := NewStashIgnoreFilter(tmpDir)
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
// Only exact match is excluded.
@ -505,7 +505,7 @@ backup/
`
createTestFileWithContent(t, tmpDir, ".stashignore", stashignore)
filter := NewStashIgnoreFilter(tmpDir)
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
expected := []string{

View file

@ -10,6 +10,39 @@ Stash currently identifies files by performing a quick file hash. This means tha
Stash currently ignores duplicate files. If two files contain identical content, only the first one it comes across is used.
### Ignoring Files with .stashignore
You can create `.stashignore` files to exclude specific files or directories from being scanned. These files use gitignore-style pattern matching syntax.
Place a `.stashignore` file in any directory within your library. The patterns in that file will apply to all files and subdirectories within that directory. You can have multiple `.stashignore` files at different levels of your directory hierarchy - patterns from parent directories cascade down to child directories.
**Supported patterns:**
| Pattern | Description |
|---------|-------------|
| `filename.mp4` | Ignore a specific file. |
| `*.tmp` | Ignore all files with a specific extension. |
| `temp/` | Ignore a directory and all its contents. |
| `**/cache/` | Ignore directories named "cache" at any level. |
| `!important.mp4` | Negation - do not ignore this file even if it matches a previous pattern. |
| `# comment` | Lines starting with # are comments. |
| `\#filename` | Use backslash to match a literal # character. |
**Example .stashignore file:**
```
# Ignore temporary files
*.tmp
*.log
# Ignore specific directories
temp/
.thumbnails/
# But keep this specific file
!important.tmp
```
The scan task accepts the following options:
| Option | Description |