Skip to content

Commit 3d9d698

Browse files
committed
refactor: align wcmatch and ripgrep behavior
1 parent 3b13281 commit 3d9d698

13 files changed

Lines changed: 428 additions & 189 deletions

File tree

‎docs/ignore.md‎

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,6 @@ Minecraft/Website
4949
!!! note ""
5050
_This section sourced and adapted from Git's[^1] `.gitignore` [documentation](https://git-scm.com/docs/gitignore)._
5151

52-
### Internal Processes
53-
54-
When scanning your library directories, the `.ts_ignore` file is read by either the [`wcmatch`](https://facelessuser.github.io/wcmatch/glob/) library or [`ripgrep`](https://github.com/BurntSushi/ripgrep) in glob mode depending if you have the later installed on your system and it's detected by TagStudio. Ripgrep is the preferred method for scanning directories due to its improved performance and identical pattern matching to `.gitignore`. This mixture of tools may lead to slight inconsistencies if not using `ripgrep`.
55-
5652
---
5753

5854
### Comments ( `#` )
@@ -142,10 +138,6 @@ A `!` prefix before a pattern negates the pattern, allowing any files matched ma
142138
```
143139
<!-- prettier-ignore-end -->
144140

145-
<!-- prettier-ignore -->
146-
!!! bug "Directory Exclusion Negation"
147-
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.
148-
149141
---
150142

151143
### Wildcards
@@ -275,18 +267,22 @@ Character sets and ranges are specific and powerful forms of wildcards that use
275267
```
276268
=== "Ignore all files EXCEPT .jpg files"
277269
```toml
270+
# Ignore everything to start,
271+
# reinclude subfolders,
272+
# then reinclude .jpg files located anywhere
278273
*
274+
!*/
279275
!*.jpg
280276
```
281277
=== "Ignore all .jpg files in specific folders"
282278
```toml
283-
./Photos/Worst Vacation/*.jpg
279+
Photos/Worst Vacation/*.jpg
284280
Music/Artwork Art/*.jpg
285281
```
286282

287283
<!-- prettier-ignore -->
288284
!!! tip "Ensuring Complete Extension Matches"
289-
For some filetypes, it may be nessisary to specify different casing and alternative spellings in order to match with all possible variations of an extension in your library.
285+
For some filetypes, it may be necessary to specify different casing and alternative spellings in order to match with all possible variations of an extension in your library.
290286

291287
```toml title="Ignore (Most) Possible JPEG File Extensions"
292288
# The JPEG Cinematic Universe
@@ -305,7 +301,8 @@ Character sets and ranges are specific and powerful forms of wildcards that use
305301
<!-- prettier-ignore -->
306302
=== "Ignore all "Cache" folders"
307303
```toml
308-
# Matches any folder called "Cache" no matter where it is in your library.
304+
# Matches any folder called "cache"/"Cache" no matter where it is in your library.
305+
Cache/
309306
cache/
310307
```
311308
=== "Ignore a "Downloads" folder"

‎src/tagstudio/core/library/alchemy/migrations.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,3 +662,27 @@ def run(cls, conn: Connection, library_dir: Path, fmt_log: LoggingMethod):
662662
"suffix = :suffix WHERE id = :id",
663663
updates,
664664
)
665+
666+
logger.info(fmt_log("Repairing 'reinclude folders' pattern in the .ts_ignore file..."))
667+
cls._repair_reinclude_folders_pattern(library_dir)
668+
669+
@classmethod
670+
def _repair_reinclude_folders_pattern(cls, library_dir: Path):
671+
"""Add "!*/" following "*" lines in the `.ts_ignore` if one isn't already present.
672+
673+
Under wcmatch's rules paths could be reincluded after a "*" without requiring a
674+
"!*/" after it, unlike the desired `.gitignore`-type behavior.
675+
"""
676+
ts_ignore = library_dir / TS_FOLDER_NAME / IGNORE_NAME
677+
if not ts_ignore.exists():
678+
return
679+
680+
# If "!*/" already follows "*", do nothing and return
681+
lines = ts_ignore.read_text(encoding="utf8").splitlines()
682+
patterns = [line.rstrip() for line in lines]
683+
if "*" not in patterns or "!*/" in patterns:
684+
return
685+
686+
# Add "!*/" after the first "*" found, if any
687+
lines.insert(patterns.index("*") + 1, "!*/")
688+
ts_ignore.write_text("\n".join(lines) + "\n", encoding="utf8")

‎src/tagstudio/core/library/alchemy/registries/ignored_registry.py‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,7 @@ def refresh_ignored_entries(self) -> Iterator[int]:
3636

3737
for i, entry in enumerate(self.lib.all_entries()):
3838
yield i
39-
if not Ignore.compiled_patterns:
40-
# If the compiled_patterns has malfunctioned, don't consider that a false positive
41-
yield i
42-
elif Ignore.compiled_patterns.match(entry.path):
39+
if Ignore.matcher.is_ignored(entry.path):
4340
self.ignored_entries.append(entry)
4441

4542
def remove_ignored_entries(self) -> None:

‎src/tagstudio/core/library/ignore.py‎

Lines changed: 74 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44

55
from pathlib import Path
6+
from typing import NamedTuple
67

78
import structlog
89
from wcmatch import glob
@@ -12,12 +13,10 @@
1213

1314
logger = structlog.get_logger()
1415

15-
PATH_GLOB_FLAGS: int = glob.GLOBSTARLONG | glob.DOTGLOB | glob.NEGATE
16+
_RULE_FLAGS: int = glob.GLOBSTAR | glob.DOTGLOB
1617

1718

1819
GLOBAL_IGNORE = [
19-
# TagStudio -------------------
20-
f"{TS_FOLDER_NAME}",
2120
# Trash -----------------------
2221
".Trash-*",
2322
".Trash",
@@ -35,65 +34,52 @@
3534
]
3635

3736

38-
def ignore_to_glob(ignore_patterns: list[str]) -> list[str]:
39-
"""Convert .gitignore-like patterns to Unix-like glob syntax.
40-
41-
Args:
42-
ignore_patterns (list[str]): The .gitignore-like patterns to convert.
43-
"""
44-
glob_patterns: list[str] = list(ignore_patterns)
45-
glob_patterns_remove: list[str] = []
46-
additional_patterns: list[str] = []
47-
root_patterns: list[str] = []
48-
49-
# Expand .gitignore patterns to mimic the same behavior with unix-like glob patterns.
50-
for pattern in glob_patterns:
51-
# Temporarily remove any exclusion character before processing
52-
exclusion_char = ""
53-
gp = pattern
54-
if pattern.startswith("!"):
55-
gp = pattern[1:]
56-
exclusion_char = "!"
57-
58-
if not gp.startswith("**/") and not gp.startswith("*/") and not gp.startswith("/"):
59-
# Create a version of a prefix-less pattern that starts with "**/"
60-
gp = "**/" + gp
61-
additional_patterns.append(exclusion_char + gp)
62-
63-
gp = gp.removeprefix("**/").removeprefix("*/")
64-
additional_patterns.append(exclusion_char + gp)
65-
66-
elif gp.startswith("/"):
67-
# Matches "/file" case for .gitignore behavior where it should only match
68-
# a file or folder in the root directory and nowhere else.
69-
glob_patterns_remove.append(pattern)
70-
gp = gp.lstrip("/")
71-
root_patterns.append(exclusion_char + gp)
72-
73-
remove_set = set(glob_patterns_remove)
74-
glob_patterns = [p for p in glob_patterns if p not in remove_set]
75-
# root_patterns must be merged in before the "/**" suffix pass below, otherwise a rooted
76-
# directory pattern (e.g. "/Downloads/") never gets a "/**" variant and matches nothing.
77-
glob_patterns = glob_patterns + additional_patterns + root_patterns
78-
79-
# Add "/**" suffix to suffix-less patterns to match implicit .gitignore behavior.
80-
for pattern in list(glob_patterns):
81-
if pattern.endswith("/**"):
82-
continue
83-
84-
glob_patterns.append(pattern.removesuffix("/*").removesuffix("/") + "/**")
85-
86-
# Fix wcmatch interpreting "**" as "one or more" to be a .gitignore style "zero or more".
87-
# Otherwise "**/foo" won't match a root "foo" and "a/**/b" won't match match "a/b".
88-
for pattern in list(glob_patterns):
89-
collapsed = pattern.removeprefix("**/").replace("/**/", "/")
90-
if collapsed != pattern:
91-
glob_patterns.append(collapsed)
92-
93-
glob_patterns = list(dict.fromkeys(glob_patterns)) # Ordered deduplication
94-
95-
logger.info("[Ignore]", glob_patterns=glob_patterns)
96-
return glob_patterns
37+
class _Rule(NamedTuple):
38+
matcher: glob.WcMatcher
39+
negated: bool
40+
dir_only: bool
41+
name_only: bool
42+
43+
44+
def _parse_rule(pattern: str) -> _Rule | None:
45+
negated = pattern.startswith("!")
46+
pattern = pattern.removeprefix("!")
47+
dir_only = pattern.endswith("/")
48+
pattern = pattern.rstrip("/")
49+
if not pattern:
50+
return None
51+
# A slashed pattern is relative to the root, otherwise it matches any name
52+
name_only = "/" not in pattern
53+
pattern = pattern.removeprefix("/")
54+
return _Rule(glob.compile(pattern, flags=_RULE_FLAGS), negated, dir_only, name_only)
55+
56+
57+
class IgnoreMatcher:
58+
"""Matches paths relative to the library against .gitignore-style patterns."""
59+
60+
def __init__(self, patterns: list[str]) -> None:
61+
self._rules = [rule for pattern in patterns if (rule := _parse_rule(pattern))]
62+
self._folder_verdicts: dict[str, bool] = {}
63+
64+
def match(self, path: str, is_dir: bool) -> bool:
65+
"""Whether `path` is ignored, without checking its parent folders."""
66+
name = path.rpartition("/")[2]
67+
for rule in reversed(self._rules):
68+
target = name if rule.name_only else path
69+
if (is_dir or not rule.dir_only) and rule.matcher.match(target):
70+
return not rule.negated
71+
return False
72+
73+
def is_ignored(self, path: Path | str) -> bool:
74+
"""Whether the file at `path` is ignored, including by any ignored parent folder."""
75+
parts = Path(path).as_posix().split("/")
76+
for depth in range(1, len(parts)):
77+
folder = "/".join(parts[:depth])
78+
if folder not in self._folder_verdicts:
79+
self._folder_verdicts[folder] = self.match(folder, is_dir=True)
80+
if self._folder_verdicts[folder]:
81+
return True
82+
return self.match("/".join(parts), is_dir=False)
9783

9884

9985
def migrate_ext_list(exts: list[str], is_exclude_list: bool) -> str:
@@ -108,7 +94,7 @@ def migrate_ext_list(exts: list[str], is_exclude_list: bool) -> str:
10894
prefix = ""
10995
if not is_exclude_list:
11096
prefix = "!"
111-
out += "*\n"
97+
out += "*\n!*/\n"
11298
out += "\n".join([f"{prefix}*.{x.lstrip('.')}\n" for x in exts])
11399
return out
114100

@@ -129,8 +115,8 @@ class Ignore(metaclass=Singleton):
129115
"""Class for processing and managing glob-like file ignore file patterns."""
130116

131117
_last_loaded: tuple[Path, float] | None = None
132-
_patterns: list[str] = []
133-
compiled_patterns: glob.WcMatcher | None = None
118+
_patterns: list[str] = [*GLOBAL_IGNORE, TS_FOLDER_NAME]
119+
matcher: IgnoreMatcher = IgnoreMatcher(_patterns)
134120

135121
@staticmethod
136122
def read_ignore_file(library_dir: Path) -> list[str]:
@@ -165,46 +151,57 @@ def write_ignore_file(library_dir: Path, lines: list[str]) -> None:
165151
f.writelines(lines)
166152

167153
@staticmethod
168-
def get_patterns(library_dir: Path, include_global: bool = True) -> list[str]:
154+
def get_patterns(
155+
library_dir: Path,
156+
include_global: bool = True,
157+
update_state: bool = True,
158+
) -> list[str]:
169159
"""Get the ignore patterns for the given library directory.
170160
161+
The library's .TagStudio folder always comes last so it doesn't get reincluded.
162+
171163
Args:
172164
library_dir (Path): The path of the library to load patterns from.
173-
include_global (bool): Flag for including the global ignore set.
174-
In most scenarios, this should be True.
165+
include_global (bool): Flag for including the global ignore list.
166+
update_state (bool): Flag for also loading the patterns into the class's state.
167+
Should be True outside of exceptions that may include tests, migrations, etc.
175168
"""
176-
patterns = GLOBAL_IGNORE if include_global else []
169+
global_patterns = GLOBAL_IGNORE if include_global else []
177170
ts_ignore_path = Path(library_dir / TS_FOLDER_NAME / IGNORE_NAME)
178171

172+
# Return computed patterns if the state of the Ignore singleton shouldn't be updated.
173+
if not update_state:
174+
return [*global_patterns, *Ignore._load_ignore_file(ts_ignore_path), TS_FOLDER_NAME]
175+
176+
# Return default internal patterns if no .ts_ignore exists.
179177
if not ts_ignore_path.exists():
180178
logger.info(
181179
"[Ignore] No .ts_ignore file found",
182180
path=ts_ignore_path,
183181
)
184182
Ignore._last_loaded = None
185-
Ignore._patterns = patterns
183+
Ignore._patterns = [*global_patterns, TS_FOLDER_NAME]
184+
Ignore.matcher = IgnoreMatcher(Ignore._patterns)
186185

187186
return Ignore._patterns
188187

189188
# Process the .ts_ignore file if the previous result is non-existent or outdated.
190189
loaded = (ts_ignore_path, ts_ignore_path.stat().st_mtime)
191-
if not Ignore._last_loaded or (Ignore._last_loaded and Ignore._last_loaded != loaded):
190+
if Ignore._last_loaded != loaded:
192191
logger.info(
193192
"[Ignore] Processing the .ts_ignore file...",
194193
library=library_dir,
195194
last_mtime=Ignore._last_loaded[1] if Ignore._last_loaded else None,
196195
new_mtime=loaded[1],
197196
)
198-
Ignore._patterns = patterns + Ignore._load_ignore_file(ts_ignore_path)
199-
Ignore.compiled_patterns = glob.compile(
200-
patterns=ignore_to_glob(Ignore._patterns),
201-
flags=PATH_GLOB_FLAGS,
202-
)
197+
user_patterns = Ignore._load_ignore_file(ts_ignore_path)
198+
Ignore._patterns = [*global_patterns, *user_patterns, TS_FOLDER_NAME]
199+
Ignore.matcher = IgnoreMatcher(Ignore._patterns)
203200
else:
204201
logger.info(
205202
"[Ignore] No updates to the .ts_ignore detected",
206203
library=library_dir,
207-
last_mtime=Ignore._last_loaded[1],
204+
last_mtime=loaded[1],
208205
new_mtime=loaded[1],
209206
)
210207
Ignore._last_loaded = loaded

‎src/tagstudio/core/library/scanners.py‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,9 @@
1010
from pathlib import Path
1111

1212
import structlog
13-
from wcmatch import glob
1413

1514
from tagstudio.core.constants import TS_FOLDER_NAME
16-
from tagstudio.core.library.ignore import PATH_GLOB_FLAGS, ignore_to_glob
15+
from tagstudio.core.library.ignore import IgnoreMatcher
1716
from tagstudio.core.utils.ripgrep_status import RipgrepStatus
1817
from tagstudio.core.utils.silent_subprocess import silent_popen # pyright: ignore
1918

@@ -99,7 +98,7 @@ def _scan_with_ripgrep(scan_dir: Path, ignore_patterns: list[str]) -> Iterator[P
9998
def _scan_with_internal_scanner(scan_dir: Path, ignore_patterns: list[str]) -> Iterator[Path]:
10099
"""Scan for files with the internal scanner."""
101100
logger.info("[Scanners] Using internal scanner for scanning", path=scan_dir)
102-
matcher = glob.compile(patterns=ignore_to_glob(ignore_patterns), flags=PATH_GLOB_FLAGS)
101+
matcher = IgnoreMatcher(ignore_patterns)
103102

104103
def walk(dir_path: Path, ancestors: frozenset[str]) -> Iterator[Path]:
105104
try:
@@ -110,9 +109,9 @@ def walk(dir_path: Path, ancestors: frozenset[str]) -> Iterator[Path]:
110109

111110
for item in dir_items:
112111
rel = Path(item.path).relative_to(scan_dir)
113-
if matcher.match(rel.as_posix()):
114-
continue
115112
try:
113+
if matcher.match(rel.as_posix(), is_dir=item.is_dir()):
114+
continue
116115
item_stat = item.stat(follow_symlinks=True)
117116
except OSError:
118117
continue

‎src/tagstudio/core/library/sync.py‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,16 +105,21 @@ def sync_dir(
105105
sleep(0)
106106
start_time_loop = time()
107107

108+
# NOTE: Ignored files are skipped during the scan.
109+
if not self.cancelled:
110+
for entry in self.library.get_entries([cache[key] for key in unvisited]):
111+
if self.cancelled:
112+
break
113+
if not (library_dir / entry.path).is_file():
114+
self.unlinked_entries.append(entry)
115+
108116
if self.cancelled:
109117
yield count, len(self.new_paths)
110118
logger.info("[Sync] Directory scan cancelled", path=library_dir, files_scanned=count)
111119
return
112120

113-
unlinked_ids = {cache[key] for key in unvisited}
114121
if self.library.duplicate_path_entry_ids:
115-
unlinked_ids.update(self.library.duplicate_path_entry_ids)
116-
if unlinked_ids:
117-
self.unlinked_entries = self.library.get_entries(list(unlinked_ids))
122+
self.unlinked_entries += self.library.get_entries(self.library.duplicate_path_entry_ids)
118123

119124
if self.unlinked_entries:
120125
yield -1, -1 # Signals the UI that repair work is starting

‎src/tagstudio/previews/file_renderer.py‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -656,12 +656,8 @@ def fetch_cached_image(file_name: Path):
656656

657657
# Check if the file is supposed to be ignored and render an overlay if needed
658658
try:
659-
if (
660-
image
661-
and Ignore.compiled_patterns
662-
and Ignore.compiled_patterns.match(
663-
filepath.relative_to(unwrap(self.lib.library_dir))
664-
)
659+
if image and Ignore.matcher.is_ignored(
660+
filepath.relative_to(unwrap(self.lib.library_dir))
665661
):
666662
image = render_ignored((scaled_size, scaled_size), image)
667663
except TypeError:

0 commit comments

Comments
 (0)