Skip to content

fix(rust): match managed rg globs under ./-prefixed search paths - #229

Open
JayOfTheKeyboard wants to merge 1 commit into
zvec-ai:mainfrom
JayOfTheKeyboard:fix/rg-dot-path-globs
Open

JayOfTheKeyboard wants to merge 1 commit into
zvec-ai:mainfrom
JayOfTheKeyboard:fix/rg-dot-path-globs

Conversation

@JayOfTheKeyboard

Copy link
Copy Markdown

Problem

In the Rust zg, managed --rg (and the MCP zvec_grep_rg tool, which calls the same engine method) ignores every glob or --ignore-file rule that contains a / when the search path starts with .. Agents append . to rg commands all the time. Include globs then return nothing, and exclusions are silently dropped.

Measured on a 207-file Go repository, with zg built from main (28ef200) and compared against ripgrep 14.1.1. Each row counts matching path:line pairs.

Command zg on main rg zg with this PR
--rg -g 'internal/**' budget (no path) 167 167 167
--rg -g 'internal/**' budget . 0 167 167
--rg -g 'internal/**' budget ./ 0 167 167
--rg -g 'internal/**' budget ./internal 0 167 167
--rg -g 'internal/policy/*.go' budget . 0 35 35
--rg -g '!internal/**' budget . 252 (nothing excluded) 85 85
--rg -g '!/internal/**' budget . 252 85 85
--rg --ignore-file x.ignore budget . (file holds internal/policy/**) 252 217 217
--rg --ignore-file x.ignore budget (no path) 217 217 217

Over MCP (zg --server --stdio --mcp-toolset full):

zvec_grep_rg {"command": "rg -g 'internal/**' budget"}    -> internal/e2e/counter_split_test.go ...
zvec_grep_rg {"command": "rg -g 'internal/**' budget ."}  -> No matches.

With this PR, the second call returns the same results as the first.

Globs without a slash (*.go, !*.md) are unaffected. So are paths written without ./ (internal, internal/) and the workspace's own .gitignore files.

Cause

build_walker resolves each search path with resolve_path, that is root.join(path), so . becomes <root>/. and walk entries look like <root>/./internal/a.go. The override matcher is built with OverrideBuilder::new(root), and ignore's Gitignore::strip removes the root as a byte prefix and then one leading /. What reaches the glob is therefore ./internal/a.go, which internal/** does not match. Rules from --ignore-file fail the same way. ripgrep never hits this, because it walks ./internal from the current directory and strip drops a leading ./. The TypeScript implementation passes the paths to rg unchanged, with cwd: root, so it matches ripgrep too.

Change

Search paths given to the walker go through a new resolve_match_path, which drops . segments from relative paths (root.join(path).components().collect()). resolve_path itself is unchanged, so check_paths, is_single_file_search, and the loading of pattern and ignore files behave as before, including how missing paths and trailing slashes are reported.

Not changed

  • Absolute paths are kept as given. rg -g 'internal/**' budget "$PWD/." also matches nothing in ripgrep 14.1.1 (and !internal/** excludes nothing), so normalising absolute paths would make zg differ from rg. Happy to normalise those too if you would rather fix it than match it.
  • The ignore-file path itself is still resolved with resolve_path. A ./ prefix on the ignore file (--ignore-file ./x.ignore) was never the problem, and the unit test passes with or without it. Only the search path matters.
  • Indexed search globs (zg <query> -g ...) do not take search paths and are unaffected. I checked -g 'internal/**' and -g '!internal/**' against an index of the same repository.

Test

path_rules_match_search_paths_with_a_current_directory_prefix in zg-engine/src/lexical/mod.rs runs eight combinations of search path and glob or --ignore-file over src/keep.rs and docs/drop.rs. It collects every mismatch and expects only src/keep.rs each time. The two cases with no path are controls that pass on main.

On the parent commit, the other six cases fail:

"paths [\".\"] src/**: []",
"paths [\"./\"] src/**: []",
"paths [\"./src\"] src/*.rs: []",
"paths [\".\"] !docs/**: [\"docs/drop.rs\", \"src/keep.rs\"]",
"paths [\"./docs\", \"./src\"] !docs/**: [\"docs/drop.rs\", \"src/keep.rs\"]",
"paths [\".\"] --ignore-file custom.ignore: [\"docs/drop.rs\", \"src/keep.rs\"]",

Mutation check: removing .components().collect() fails the same six cases, and so does pointing build_walker back at resolve_path.

Validation

  • cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, and RUSTDOCFLAGS="-D warnings" cargo doc -p zg-engine --no-deps pass.
  • cargo test -p zg-engine --lib lexical:: passes (16 tests, including the new one), and so does cargo test -p zg-engine --lib (461 passed, 8 ignored).
  • cargo test --workspace --no-fail-fast (final state): 801 passed, 20 failed, 8 ignored. Every failure is in a test that runs a local mock HTTP server or the daemon. The same group fails on main on this machine (795 passed, 26 failed), and which of those tests fail changes from run to run. Another process here probes newly opened loopback ports with a Go HTTP client, and the mocks reject its request (user-agent: go-http-client/1.1, no authorization header). The daemon test that exercises managed rg, full_toolset_exposes_lifecycle_tools_and_runs_managed_rg, passed 3 of 3 runs on its own on this branch, and 2 of 3 on main. I'm relying on CI for this group.
  • Real-tool run: the table above was produced with a debug build before and after this change, against ripgrep 14.1.1 on the same tree, comparing the full path:line sets, not only the counts.

Managed rg resolved relative search paths with root.join(path), so `.`,
`./` and `./src` walked `<root>/./...`. The ignore crate strips the root
from candidates as a byte prefix and matched `./src/a.rs`, so rules with
a slash never matched: `-g 'src/**' needle .` returned nothing, and
`-g '!docs/**'` or an --ignore-file pattern such as `docs/*.rs` excluded
nothing. Drop `.` segments from relative walk roots, matching what
ripgrep sees from the workspace root.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant