ripgrep’s -g Can Exclude Files; Ours Cannot Yet — Wide Field Part 8: Why a File Pattern Is One Quoted Argument, and the Five Things We Fixed

Technical Guide

Forget the quotes and it refuses.

Type uvf *.log ERROR and you get an error telling you to quote the pattern. ripgrep would simply have searched. We deliberately made ours harder to type.

So why does this one command insist on quoting?

The short version: a file pattern is one quoted argument, because that exact string becomes the name of the bundled index. Glob handling was corrected in five places, and the multi-file comparison agrees on 36 checks on both Mac and Linux — zero mismatches. What we still lack is ripgrep’s exclusion syntax (-g '!…')

Every multi-file pattern goes inside quotes as a whole — uvf 'logs/*.log,err/*.log' ERROR — so the shell never expands it and uvf/uvp expand it themselves. * no longer crosses a directory separator, **/sub/*.log and dir/** now match, [ ] is treated literally, and symlinked directories are not followed. Mac (Apple M4, 10 cores), 32 GB RAM, external USB SSD.

uvf and uvp are the search commands of UwView v1.7.3.6 “Wide Field” (released 28 September 2026), which I make. uvf ships in the free edition; uvp is part of the paid UwView Pro. The glob fixes landed in v1.7.2.2 and the index-naming rules in v1.7.2.4; the three-OS results here come from the v1.7.3.6 integration run of 29 September 2026.


What we still cannot do, first

  • There is no exclusion syntax. ripgrep’s -g/--glob takes gitignore-style globs, can be repeated, and excludes when you put ! in front (with later globs winning over earlier ones). Nothing like '*.log,!debug*.log' exists here yet — it is queued for a later version. Today the only way to exclude is to write it in .ignore or .gitignore.
  • No {a,b} expansion. Commas are separators here, so brace expansion cannot coexist with that choice. Write 'a.log,b.log'.
  • No [a-z] character classes. Brackets are matched literally.
  • A bare directory name ('logs') is not a pattern. You have to write 'logs/**'. Also queued.
  • You cannot choose case sensitivity. There is no equivalent of ripgrep’s --iglob; the filesystem decides (Linux is case-sensitive, Mac and Windows are not).
  • We never measured what the fixes did to speed. Dropping the “read the whole tree, then filter” behaviour should mean reading less in a large directory, but we have no before-and-after measurement. There are no timings or ratios anywhere in this article.

The argument shape is different from ripgrep’s

A ripgrep invocation is a pattern plus the places to search. The places arrive already expanded by the shell, and you narrow them with -g:

rg ERROR logs/ -g '*.log' -g '!debug*.log'

Ours is a pattern plus exactly one file-specification string:

uvf 'logs/*.log,err/*.log' ERROR

Splitting that string is our job, not the shell’s. Both whitespace and commas work as separators — but a path containing a space, like Program Files, cannot be expressed with whitespace separation, so the rule is: use commas when a path contains a space. If whitespace separation produces zero matches for every fragment, we say so rather than silently returning nothing.

And when the pattern arrives already expanded — every argument but the last turns out to be an existing file — we refuse it:

error: several files were passed directly (the shell may have expanded them).
       Please quote the pattern:  uvf '*.log' ERROR

Accepting it quietly would look friendlier. We do not, because accepting it creates two syntaxes, and for the reason in the next section one of them cannot produce an index at all.

Why the quoting is mandatory: that string becomes the index name

This is the heart of the design.

uvp bundles several files into a single .uwvz (UwView Pro’s compression format plus index) and reads that index on the second run. Which means it needs a way to say: the same pattern must land on the same index.

We built that key out of the pattern string itself, converting the pattern (A) into a filename-safe string (B) reversibly:

  • % → %% (the escape character itself; without this the mapping is not reversible)
  • * → %a, ? → %q, , → %c, / → %s, \ → %b
  • : → %o, [ ] → %l %r, " < > | → %d %t %g %p

So '*.log' becomes uw-%a.log.uwvz, and 'logs/*.log,err/*.log' becomes uw-logs%s%a.log%cerr%s%a.log. The uw- prefix marks a bundled index, keeping it distinct from a single file’s index (app.log.uwvz).

Reversibility exists so that a stray .uwvz tells you what is inside it from the name alone.

And if the shell expands the pattern, (A) never reaches the program. Without (A) there is no (B), and the index has no name. The quoting rule is not style; it falls out of this.

Before naming, absolute paths are rewritten relative to the working directory, so a relative and an absolute spelling land on the same index — as do a.log b.log and a.log,b.log, because separators are normalised to a single comma before the name and hash are computed.

Switching the naming scheme at 24 characters

A reversible mapping has no length limit. Long patterns produce names longer than a filesystem allows.

Windows is what forced the issue. In a mixed-format test, uvp‘s second run took as long as its first — meaning the bundled index was not being reused. Our hypothesis: the name was too long, the save failed, and nothing said so.

So the rule changed:

  • if the transliterated name (without .uwvz) is 24 characters or fewer, use it as is
  • beyond that, switch to uw-<8-char heading>-<12-char hash>.uwvz (24 characters without the extension, 29 in total)

24 is uw- (3) + heading (8) + - (1) + hash (12) — precisely the point where the hashed name becomes the shorter of the two. Characters are counted as characters, so Japanese counts as one each; 24 characters is at most 72 bytes in UTF-8, comfortably inside every OS limit.

The heading comes from the first fragment: the last directory component without a wildcard in it, or failing that the filename, truncated to eight characters. 'logs/*.log,err/*.log' gives logs; '/var/log/app/*.log,/var/log/db/*.log' gives app. The hash is the SHA-256 of the normalised pattern in lowercase base32 (a–z, 2–7), 12 characters — deliberately restricted to characters that cannot collide on a case-insensitive filesystem.

Either way, the original pattern is stored in the index metadata under pattern. Short patterns are readable in the name; long ones come back out of uvp <index> --info. The pattern is never lost.

The thing we suspected was probably not the cause

Worth stating plainly: length was most likely not the problem.

The offending name was about 65 characters, about 85 with its directory — nowhere near Windows’ 260. And the second run took as long as the first, which looks like finishing the work and then failing at the end. The reason was invisible because the timing script only captured the first line of stderr (the “creating…” progress line). The error may well have been printed all along, and we were not looking.

So we did not stop at shortening the name. Three other things went in alongside:

  • A failed save must always be reported. If the index cannot be written, we rebuild it in a temporary directory, still print the search results, then emit uvp: could not save the index: <path> (<reason>) and exit 2. The temporary index is removed at the end. Previously we suspect it failed in silence.
  • longPathAware in the Windows manifest, so paths beyond 260 work when the OS allows them.
  • Temporary files named <name>.tmp-<8 digits> instead of a fixed <name>.tmp, because two uvp processes building the same index would otherwise collide.

The Windows reuse problem is resolved as of v1.7.3.6. We did not isolate which of the four changes fixed it.

The five fixes

The glob semantics themselves diverged from the shell, because the implementation was built on .NET’s simple matcher (* and ? only).

Pattern Before After (matching bash)
'*/*.log' Matched two and three levels down as well — and read the entire tree before filtering Exactly one level down; matched level by level, reading only the levels needed
'**/sub/*.log' Matched nothing (** was compared against the filename only) Matched against the tail of the relative path
'logs/**' Matched nothing Every file under logs
'app[12].log' [ counted as a wildcard, but [12] was matched literally anyway Brackets are not wildcards; matched literally
Symlinked directories Unverified (whether .NET’s recursion followed them) Not followed, to avoid loops

The first row did the real damage: '*/*.log' not only over-matched but read the whole tree before narrowing it. Matching level by level means only the needed levels are read now. Again: no before-and-after measurement exists. “Reads less” is a statement about the design.

The remaining conventions are written down too. Order follows the pattern as typed, and by name within a fragment; a file is searched once even if two fragments match it; hidden files and directories are skipped; .uwvz is never picked up by a wildcard (naming it works); a fragment matching nothing is named back to you; and going past 1,000 files produces a notice rather than a stop. The full explanation moved out of --help into uvf --help files.

The numbers: 36 checks, three operating systems

These are correctness numbers, not speed.

Stage What it checks Mac Linux Windows
mtest multi-file comparison PASS 36 / FAIL 0 PASS 36 / FAIL 0 PASS 34 / FAIL 0 / 2 skipped

The two skipped checks on Windows need a command to create a zip, which that machine does not have. A skipped test is not a passing test.

Alongside those:

  • unit tests: 438 for uvf and 780 for uvp all passing as of v1.7.2.2, when the glob fixes landed; 802 for uvp at v1.7.2.4, when the naming changed
  • 19 checks covering naming alone: the transliterated name, the 24-character boundary, counting Japanese characters, headings, relative versus absolute spellings, ordering, second-run reuse, the reverse mapping, --info, and temporary files
  • the five fixed behaviours were added to the multi-file suite’s first group ('*/*.log', '**/sub/*.log', 'logs/**', '**/*.log', [ ], --help files)

The one FAIL was the test’s fault, not the product’s

The first run after adding those checks came back PASS 34 / FAIL 1.

The failure was a counting error in the test script: 'logs/**' appears twice in the --help files text, and the script expected it once. The product was right and the test was wrong. The script is fixed.

“It was a test bug” reads like an excuse. But not writing it leaves only the pass count behind, so we write it. In the previous article, the 15 Windows failures also turned out to be a test-script bug. Twice is not coincidence; it says the harness is the weak part.

One more: --info did not exist at all through v1.7.2.3. The specification described it; nobody had implemented it. Writing the naming tests is what surfaced that. A feature written in a spec is one you can keep believing you have, right up until a test asks for it.

How this was checked

  • Counterpart: ripgrep (the -g/--glob behaviour is compared against what ripgrep’s own help documents)
  • Version: uvf and uvp both v1.7.3.6 “Wide Field”; glob fixes from v1.7.2.2, naming from v1.7.2.4
  • Comparison: the mtest stage of the three-OS integration run of 29 September 2026
  • Machines: Mac (Apple M4, 10 cores, 32 GB RAM), external USB SSD; Linux on an aarch64 VM; Windows on a separate machine
  • No timings or ratios appear in this article, so nothing is compared across operating systems

What generalises

(1) The shape of your arguments can be dictated by a later stage. “Please quote it” is friction for the user. We could not give it up because that string becomes the index name. If a cache key is derived from the input itself, the input has to arrive intact. Letting the shell expand it destroys the material the key is made of.

(2) A key forces a choice between reversible and short. A reversible name is readable but grows; a hash is short but opaque. We computed the crossover and switch there, and always store the original in metadata. Once the original is recoverable either way, the length problem stays confined to the name.

(3) A wrong hypothesis can still leave a useful fix behind. We suspected length, shortened the name, and it probably was not length. But “always report a failed save,” added in the same pass, means the reason will be visible next time something goes wrong. Removing a silent-failure path is worth doing before you know the cause.

(4) If you claim “same as the shell,” enumerate the lines that differ. Whether * crosses a separator slips easily as an implementation detail. Ship the slip under the banner “wildcards supported” and your users will keep suspecting their own patterns. A countable list of what does not work gets people productive faster than a claim that everything does.

Being honest about it

  • There are no speed numbers in this article. File specification is a correctness and usability matter; beyond PASS 36 / FAIL 0 we have nothing to claim
  • The “read only the levels needed” change was never measured. Large directories should be reading less, but no isolated measurement exists
  • ripgrep’s -g exclusion (!) has no equivalent here. Neither do {a,b}, [a-z], bare directory names, or explicit case sensitivity
  • We did not isolate why the Windows index stopped being reused. Four changes went in together
  • A test failure turned out to be the test’s own bug twice in a row. That is a report in the product’s favour, written by the person who owns the product
  • Windows and Linux results appear here only as correctness evidence. Seconds are never compared across operating systems
  • Name collisions are prevented by comparing the stored pattern and erroring on a mismatch. We have not actually produced a collision to watch it work
  • I am one of the two developers being compared. Discount accordingly

The tools behind this

UwView is a CLI and GUI for investigating very large text files and logs. In v1.7.3.6 “Wide Field” it searches many files and seven compression formats in one pass, without calling an external command.

How you would write it with uvp

# .log in the current directory (always quote the pattern)
uvf '*.log' ERROR

# exactly one level down / every level
uvf '*/*.log' ERROR
uvf '**/*.log' ERROR

# directly inside any 2026-09 directory, wherever it is
uvf '**/2026-09/*.log' ERROR

# every file under a directory
uvf 'logs/**' ERROR

# comma-separate paths that contain a space
uvf 'C:/Program Files/app/*.log,D:/logs/*.log' ERROR

# see what the pattern covers first (free)
uvf '**/*.log' --files

# the full explanation of file patterns
uvf --help files

# bundle and search; the second run reads the index (UwView Pro)
uvp 'logs/**' ERROR

# what did this index bundle? (free)
uvp 'logs/**' --info

# rebuild it (the old one is kept as .bak-<timestamp>)
uvp 'logs/**' ERROR --rebuild

Exit codes follow grep: 0 = found, 1 = not found, 2 = error or incomplete result.

The free edition is on GitHub Releases. On Mac, brew install --cask amru195704/uwview/uwview; on Windows, Scoop (scoop bucket add uwview https://github.com/amru195704/scoop-uwview, then scoop install uwview).

  • Matching ripgrep’s ignore rules (.ignore, .gitignore): https://uvp.y42u.net/en/blog/uwview-wf07-ignore-rules-en/
  • Reusing a bundled index, and not rebuilding when a file vanishes: https://uvp.y42u.net/en/blog/uwview-wf04-bundle-keep-en/
  • Seven formats without calling an external command: https://uvp.y42u.net/en/blog/uwview-wf02-seven-formats-no-external-en/
  • Building a .uwvz, searching inside it, extracting from it: https://uvp.y42u.net/en/blog/uwvz-compressed-archive-search-extract-en/
  • Searching compressed logs compared with ugrep, ripgrep and zgrep: https://uvp.y42u.net/en/blog/uwview-ugrep-ripgrep-zgrep-compressed-logs-en/
  • Thirteen rounds of reading the file fewer times: https://uvp.y42u.net/en/blog/uwview-fl13-first-light-recap-en/
  • Every measurement we publish: https://uvp.y42u.net/en/benchmarks-en/
  • Source code (GitHub): https://github.com/amru195704/UwView

And if huge logs are eating your storage and you want them compressed and fast to search, take a look at UwView Pro: a persistent index, search inside the compressed cache, roughly 1/9 the storage, and a faster reopen every time (all operating systems; one-time purchase or monthly plan).


From the developer: my apps, Kindle books and public projects are listed at GitHub: amru195704.


Copied title and URL