|
1 | | -"""macOS Glob tool: ``git ls-files`` inside git repos, ``pathlib`` outside. |
| 1 | +"""macOS Glob tool: ``git ls-files`` inside git repos, ``find`` outside. |
2 | 2 |
|
3 | 3 | Apple ships neither GNU ``tree`` nor a compatible equivalent, so the |
4 | 4 | non-git fallback in :class:`GlobTool` (which shells out to ``tree``) |
5 | 5 | fails on a stock macOS install. ``GlobMac`` replaces that fallback |
6 | | -with :meth:`pathlib.Path.rglob`, which is pure-Python, requires no |
7 | | -external binary, and produces identical results. |
| 6 | +with a hybrid approach: ``find`` (C binary, pre-installed on macOS) |
| 7 | +handles directory traversal and pattern matching, while Python handles |
| 8 | +mtime sorting — which ``find`` does not support. |
| 9 | +
|
| 10 | +This is significantly faster than the previous pure-Python |
| 11 | +:meth:`pathlib.Path.rglob` approach on large directory trees, while |
| 12 | +producing identical results. |
8 | 13 |
|
9 | 14 | Only the non-git fallback differs; the git path, the tool name, and |
10 | 15 | the result format are inherited unchanged so callers, the tool |
|
13 | 18 |
|
14 | 19 | from __future__ import annotations |
15 | 20 |
|
16 | | -import fnmatch |
17 | 21 | import os |
18 | | -from pathlib import Path |
| 22 | +import subprocess |
19 | 23 |
|
20 | 24 | from .base import ToolContext |
21 | 25 | from .filesystem import _natnump, _spool |
22 | 26 | from .glob import GlobTool |
23 | 27 |
|
24 | 28 |
|
25 | 29 | class GlobMac(GlobTool): |
26 | | - """Glob with a pure-Python non-git fallback for macOS.""" |
| 30 | + """Glob with a hybrid ``find`` + Python-sort non-git fallback for macOS.""" |
27 | 31 |
|
28 | 32 | def _tree_fallback(self, pattern: str, base: str, depth: object) -> str: |
29 | | - """Walk *base* with :func:`pathlib.Path.rglob` instead of ``tree``. |
| 33 | + """Use ``find`` for traversal + matching, Python for mtime sort. |
30 | 34 |
|
31 | | - Results are absolute paths sorted by modification time (newest |
32 | | - first), matching the ``tree --sort=mtime`` order of the Linux |
33 | | - fallback. Hidden directories (``.git``, etc.) are skipped. |
| 35 | + ``find`` (C binary) walks the directory tree and applies pattern |
| 36 | + matching natively, which is significantly faster than |
| 37 | + :meth:`pathlib.Path.rglob` on large trees. Results are then |
| 38 | + sorted by modification time (newest first) in Python, matching |
| 39 | + the ``tree --sort=mtime`` order of the Linux fallback. Hidden |
| 40 | + directories (``.git``, etc.) are skipped via ``-path */.* -prune``. |
| 41 | + Symlinks are followed (``-L``), matching ``tree -l`` and the old |
| 42 | + :meth:`pathlib.Path.rglob` behavior. |
34 | 43 | """ |
35 | | - root = Path(base) |
36 | | - matches: list[tuple[float, str]] = [] |
| 44 | + cmd = [ |
| 45 | + "find", |
| 46 | + "-L", |
| 47 | + base, |
| 48 | + "-path", |
| 49 | + "*/.*", |
| 50 | + "-prune", |
| 51 | + "-o", |
| 52 | + "-type", |
| 53 | + "f", |
| 54 | + "-iname", |
| 55 | + pattern, |
| 56 | + "-print", |
| 57 | + ] |
37 | 58 | if _natnump(depth): |
38 | | - # Depth-limited: walk manually so we can count levels. |
39 | | - for dirpath, dirnames, filenames in os.walk(base): |
40 | | - # Skip hidden directories (mirrors tree's -I .git and |
41 | | - # the general expectation that dotfiles are excluded). |
42 | | - dirnames[:] = [d for d in dirnames if not d.startswith(".")] |
43 | | - rel = os.path.relpath(dirpath, base) |
44 | | - level = 0 if rel == "." else rel.count(os.sep) + 1 |
45 | | - if level >= depth: |
46 | | - dirnames.clear() |
47 | | - continue |
48 | | - for name in filenames: |
49 | | - if fnmatch.fnmatch(name.lower(), pattern.lower()): |
50 | | - full = os.path.join(dirpath, name) |
51 | | - try: |
52 | | - mtime = os.path.getmtime(full) |
53 | | - except OSError: |
54 | | - mtime = 0.0 |
55 | | - matches.append((mtime, full)) |
56 | | - else: |
57 | | - # Unlimited depth: rglob is simpler. |
58 | | - for p in root.rglob("*"): |
59 | | - if any(part.startswith(".") for part in p.relative_to(root).parts): |
60 | | - continue |
61 | | - if p.is_file() and fnmatch.fnmatch(p.name.lower(), pattern.lower()): |
62 | | - try: |
63 | | - mtime = p.stat().st_mtime |
64 | | - except OSError: |
65 | | - mtime = 0.0 |
66 | | - matches.append((mtime, str(p))) |
67 | | - |
68 | | - # Sort newest-first (like tree --sort=mtime). |
| 59 | + cmd[3:3] = ["-maxdepth", str(depth)] |
| 60 | + try: |
| 61 | + proc = subprocess.run( |
| 62 | + cmd, |
| 63 | + capture_output=True, |
| 64 | + text=True, |
| 65 | + encoding="utf-8", |
| 66 | + errors="replace", |
| 67 | + timeout=60, |
| 68 | + ) |
| 69 | + except (OSError, subprocess.TimeoutExpired) as e: |
| 70 | + return f"Error: {e}" |
| 71 | + lines = [line for line in proc.stdout.splitlines() if line] |
| 72 | + if not lines: |
| 73 | + if proc.returncode != 0: |
| 74 | + out = f"Glob failed with exit code {proc.returncode}\n" |
| 75 | + out += proc.stderr or "" |
| 76 | + return _spool(out, "glob") |
| 77 | + return "" |
| 78 | + |
| 79 | + matches: list[tuple[float, str]] = [] |
| 80 | + for path in lines: |
| 81 | + try: |
| 82 | + mtime = os.path.getmtime(path) |
| 83 | + except OSError: |
| 84 | + mtime = 0.0 |
| 85 | + matches.append((mtime, path)) |
| 86 | + |
69 | 87 | matches.sort(key=lambda t: t[0], reverse=True) |
70 | 88 | out = "\n".join(path for _, path in matches) |
71 | 89 | if not out: |
@@ -93,5 +111,5 @@ def run(self, args: dict, ctx: ToolContext) -> str: |
93 | 111 | # The git path is identical to the parent — delegate. |
94 | 112 | return super().run(args, ctx) |
95 | 113 |
|
96 | | - # Non-git: pure-Python fallback instead of `tree`. |
| 114 | + # Non-git: hybrid find + Python sort fallback instead of `tree`. |
97 | 115 | return self._tree_fallback(pattern, base, depth) |
0 commit comments