From 3ae9dd39a39d81e65035f65d9745599ed6372938 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Mon, 14 Sep 2026 00:23:20 -0700 Subject: [PATCH] fix: fix whitespace stripping inconsistencies in wcmatch --- src/tagstudio/core/library/ignore.py | 14 +++++++++++++- tests/core/library/test_ignore.py | 27 ++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/tagstudio/core/library/ignore.py b/src/tagstudio/core/library/ignore.py index 7def172a..8ff8ce17 100644 --- a/src/tagstudio/core/library/ignore.py +++ b/src/tagstudio/core/library/ignore.py @@ -113,6 +113,18 @@ def migrate_ext_list(exts: list[str], is_exclude_list: bool) -> str: return out +def _strip(line: str) -> str: + """Strip a line ending and unescaped trailing whitespace from an ignore file line. + + Leading whitespace and a backslash-escaped trailing space are left intact, matching + .gitignore's rule that trailing spaces are ignored unless escaped. + """ + line = line.rstrip("\r\n") + while line and line[-1].isspace() and line[-2:-1] != "\\": + line = line[:-1] + return line + + class Ignore(metaclass=Singleton): """Class for processing and managing glob-like file ignore file patterns.""" @@ -210,7 +222,7 @@ class Ignore(metaclass=Singleton): if path.exists(): with open(path, encoding="utf8") as f: for line_raw in f.readlines(): - line = line_raw.strip() + line = _strip(line_raw) # Ignore blank lines and comments if not line or line.startswith("#"): continue diff --git a/tests/core/library/test_ignore.py b/tests/core/library/test_ignore.py index d711515a..fcd939bc 100644 --- a/tests/core/library/test_ignore.py +++ b/tests/core/library/test_ignore.py @@ -1,9 +1,13 @@ # SPDX-FileCopyrightText: (c) TagStudio Contributors # SPDX-License-Identifier: GPL-3.0-only +# pyright: reportPrivateUsage=false + +from pathlib import Path + from wcmatch import glob -from tagstudio.core.library.ignore import PATH_GLOB_FLAGS, ignore_to_glob +from tagstudio.core.library.ignore import PATH_GLOB_FLAGS, Ignore, ignore_to_glob def matches(patterns: list[str], path: str) -> bool: @@ -102,3 +106,24 @@ def test_negation_does_not_extend_to_deeper_subfolder(): assert matches(patterns, "a.jpg") is True assert matches(patterns, "Photos/a.jpg") is False assert matches(patterns, "Photos/Private/a.jpg") is True + + +def test_ignore_file_preserves_escaped_trailing_space(tmp_path: Path): + """An escaped trailing space must not be stripped.""" + ts_ignore = tmp_path / ".ts_ignore" + ts_ignore.write_bytes(b"foo\\ \nbar \n") + assert Ignore._load_ignore_file(ts_ignore) == ["foo\\ ", "bar"] + + +def test_ignore_file_preserves_leading_whitespace(tmp_path: Path): + """Leading whitespace must not be stripped.""" + ts_ignore = tmp_path / ".ts_ignore" + ts_ignore.write_bytes(b" baz\n") + assert Ignore._load_ignore_file(ts_ignore) == [" baz"] + + +def test_ignore_file_strips_crlf_line_ending(tmp_path: Path): + """A Windows CRLF line ending must not become part of the pattern.""" + ts_ignore = tmp_path / ".ts_ignore" + ts_ignore.write_bytes(b"qux\r\n") + assert Ignore._load_ignore_file(ts_ignore) == ["qux"]