From 795be0ac4e5ecb78fc2f64a2775138403f49b16f Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Mon, 14 Sep 2026 00:03:09 -0700 Subject: [PATCH] fix: fix wcmatch matching '*' to '/' --- docs/ignore.md | 13 ++++++------- src/tagstudio/core/library/ignore.py | 13 ++++++------- src/tagstudio/core/library/scanners.py | 4 ++-- src/tagstudio/qt/mixed/migration_modal.py | 6 +++--- tests/core/library/test_ignore.py | 22 ++++++++++++++++++++-- 5 files changed, 37 insertions(+), 21 deletions(-) diff --git a/docs/ignore.md b/docs/ignore.md index 869adee5..43a9f6fa 100644 --- a/docs/ignore.md +++ b/docs/ignore.md @@ -8,12 +8,6 @@ icon: material/file-document-remove # :material-file-document-remove: Ignoring Files & Directories - -!!! 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]()-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`). === "Example negation" @@ -147,6 +142,10 @@ A `!` prefix before a pattern negates the pattern, allowing any files matched ma ``` + +!!! 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 diff --git a/src/tagstudio/core/library/ignore.py b/src/tagstudio/core/library/ignore.py index c6914679..7def172a 100644 --- a/src/tagstudio/core/library/ignore.py +++ b/src/tagstudio/core/library/ignore.py @@ -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( diff --git a/src/tagstudio/core/library/scanners.py b/src/tagstudio/core/library/scanners.py index 1aae77b3..0589199d 100644 --- a/src/tagstudio/core/library/scanners.py +++ b/src/tagstudio/core/library/scanners.py @@ -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: diff --git a/src/tagstudio/qt/mixed/migration_modal.py b/src/tagstudio/qt/mixed/migration_modal.py index 37aab223..3f0829c9 100644 --- a/src/tagstudio/qt/mixed/migration_modal.py +++ b/src/tagstudio/qt/mixed/migration_modal.py @@ -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"{new_value}") 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: diff --git a/tests/core/library/test_ignore.py b/tests/core/library/test_ignore.py index 90c26ca2..d711515a 100644 --- a/tests/core/library/test_ignore.py +++ b/tests/core/library/test_ignore.py @@ -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