fix: fix wcmatch matching '*' to '/'

This commit is contained in:
Travis Abendshien
2026-09-14 00:03:09 -07:00
parent fbba5dd6a8
commit 795be0ac4e
5 changed files with 37 additions and 21 deletions
+6 -7
View File
@@ -8,12 +8,6 @@ icon: material/file-document-remove
# :material-file-document-remove: Ignoring Files & Directories
<!-- prettier-ignore -->
!!! warning "Legacy File Extension Ignoring"
TagStudio versions prior to v9.5.4 use a different, more limited method to exclude or include file extensions from your library and subsequent searches. Opening a pre-exiting library in v9.5.4 or later will non-destructively convert this to the newer, more extensive `.ts_ignore` format.
If you're still running an older version of TagStudio in the meantime, you can access the legacy system by going to "Edit -> Manage File Extensions" in the menubar.
TagStudio offers the ability to ignore specific files and directories via a `.ts_ignore` file located inside your [library's](libraries.md) `.TagStudio` folder. This file is designed to use very similar [glob](<https://en.wikipedia.org/wiki/Glob_(programming)>)-style pattern matching as the [`.gitignore`](https://git-scm.com/docs/gitignore) file used by Git™[^1]. It can be edited within TagStudio or opened to edit with an external program by going to the "Edit -> Ignore Files" option in the menubar.
This file is only referenced when scanning directories for new files to add to your library, and does not apply to files that have already been added to your library.
@@ -131,7 +125,8 @@ The forward slash "`/`" is used as the directory separator. Separators may occur
A `!` prefix before a pattern negates the pattern, allowing any files matched matched by previous patterns to be un-matched.
- Any matching file excluded by a previous pattern will become included again.
- **It is not possible to re-include a file if a parent directory of that file is excluded.**
- **An excluded parent directory can not have files within it reincluded!** (e.g. `Photos/`)
- Alternatively, if one or more files within a directory is excluded (e.g. `!Photos/*.jpg`) then files within can be reincluded from there (e.g. `Photos/a.jpg`).
<!-- prettier-ignore-start -->
=== "Example negation"
@@ -147,6 +142,10 @@ A `!` prefix before a pattern negates the pattern, allowing any files matched ma
```
<!-- prettier-ignore-end -->
<!-- prettier-ignore -->
!!! bug "Directory Exclusion Negation"
TagStudio attempts to match the behavior of a `.gitignore` file 1:1, however if you don't have `ripgrep` installed on your system and TagStudio falls back to its internal pattern matcher, excluded directories can be overwritten by further negations, unlike `.gitignore` behavior. Be wary that this is **not officially supported**, and this behavior may be removed at any time.
---
### Wildcards
+6 -7
View File
@@ -5,15 +5,14 @@
from pathlib import Path
import structlog
import wcmatch.fnmatch as fnmatch
from wcmatch import glob, pathlib
from wcmatch import glob
from tagstudio.core.constants import IGNORE_NAME, TS_FOLDER_NAME
from tagstudio.core.utils.singleton import Singleton
logger = structlog.get_logger()
PATH_GLOB_FLAGS: int = glob.GLOBSTARLONG | glob.DOTGLOB | glob.NEGATE | pathlib.MATCHBASE
PATH_GLOB_FLAGS: int = glob.GLOBSTARLONG | glob.DOTGLOB | glob.NEGATE
GLOBAL_IGNORE = [
@@ -119,7 +118,7 @@ class Ignore(metaclass=Singleton):
_last_loaded: tuple[Path, float] | None = None
_patterns: list[str] = []
compiled_patterns: fnmatch.WcMatcher | None = None
compiled_patterns: glob.WcMatcher | None = None
@staticmethod
def read_ignore_file(library_dir: Path) -> list[str]:
@@ -185,9 +184,9 @@ class Ignore(metaclass=Singleton):
new_mtime=loaded[1],
)
Ignore._patterns = patterns + Ignore._load_ignore_file(ts_ignore_path)
Ignore.compiled_patterns = fnmatch.compile(
ignore_to_glob(Ignore._patterns),
PATH_GLOB_FLAGS,
Ignore.compiled_patterns = glob.compile(
patterns=ignore_to_glob(Ignore._patterns),
flags=PATH_GLOB_FLAGS,
)
else:
logger.info(
+2 -2
View File
@@ -9,7 +9,7 @@ from collections.abc import Iterator
from pathlib import Path
import structlog
import wcmatch.fnmatch as fnmatch
from wcmatch import glob
from tagstudio.core.constants import TS_FOLDER_NAME
from tagstudio.core.library.ignore import PATH_GLOB_FLAGS, ignore_to_glob
@@ -98,7 +98,7 @@ def _scan_with_ripgrep(scan_dir: Path, ignore_patterns: list[str]) -> Iterator[P
def _scan_with_internal_scanner(scan_dir: Path, ignore_patterns: list[str]) -> Iterator[Path]:
"""Scan for files with the internal scanner."""
logger.info("[Scanners] Using internal scanner for scanning", path=scan_dir)
matcher = fnmatch.compile(ignore_to_glob(ignore_patterns), PATH_GLOB_FLAGS)
matcher = glob.compile(patterns=ignore_to_glob(ignore_patterns), flags=PATH_GLOB_FLAGS)
def walk(dir_path: Path, ancestors: frozenset[tuple[int, int]]) -> Iterator[Path]:
try:
+3 -3
View File
@@ -8,7 +8,6 @@ from typing import cast
from warnings import deprecated
import structlog
import wcmatch.fnmatch as fnmatch
from PySide6.QtCore import QObject, Qt, QThreadPool, Signal
from PySide6.QtWidgets import (
QApplication,
@@ -24,6 +23,7 @@ from PySide6.QtWidgets import (
)
from sqlalchemy import select
from sqlalchemy.orm import Session
from wcmatch import glob
from tagstudio.core.constants import (
IGNORE_NAME,
@@ -512,13 +512,13 @@ class JsonMigrationModal(QObject):
return str(f"<b><a style='color: {color}'>{new_value}</a></b>")
def assert_ignore_parity(self) -> None:
compiled_pats = fnmatch.compile(
compiled_pats = glob.compile(
ignore_to_glob(
Ignore._load_ignore_file( # pyright: ignore[reportPrivateUsage]
unwrap(self.json_lib.library_dir) / TS_FOLDER_NAME / IGNORE_NAME
)
),
PATH_GLOB_FLAGS,
flags=PATH_GLOB_FLAGS,
) # copied from Ignore.get_patterns since that method modifies singleton state
path = self.json_lib.library_dir / "filename"
for ext in self.json_lib.ext_list:
+20 -2
View File
@@ -1,13 +1,13 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import wcmatch.fnmatch as fnmatch
from wcmatch import glob
from tagstudio.core.library.ignore import PATH_GLOB_FLAGS, ignore_to_glob
def matches(patterns: list[str], path: str) -> bool:
return fnmatch.compile(ignore_to_glob(patterns), PATH_GLOB_FLAGS).match(path)
return glob.compile(ignore_to_glob(patterns), flags=PATH_GLOB_FLAGS).match(path)
def test_ignore_to_glob_does_not_crash_on_negated_root_anchored_pattern():
@@ -84,3 +84,21 @@ def test_ignore_to_glob_output_has_no_duplicates():
"""Output must be deduplicated."""
glob_patterns = ignore_to_glob(["*.jpg", "Photos/", "**/foo"])
assert len(glob_patterns) == len(set(glob_patterns))
def test_single_asterisk_does_not_match_slash():
"""A single "*" must not match a single "/".
fnmatch will still match "*" to a "/", when gitignore and wcmatch.glob will not.
"""
patterns = ["Images/*.png"]
assert matches(patterns, "Images/mario.png") is True
assert matches(patterns, "Images/Mario/cat.png") is False
def test_negation_does_not_extend_to_deeper_subfolder():
"""A negation must not extend into a deeper subfolder its pattern doesn't match."""
patterns = ["*.jpg", "!Photos/*.jpg", "Photos/Private/*.jpg"]
assert matches(patterns, "a.jpg") is True
assert matches(patterns, "Photos/a.jpg") is False
assert matches(patterns, "Photos/Private/a.jpg") is True