Skip to content

Compute depth for broken symlinks so --min-depth keeps them - #2039

Open
hexbinoct wants to merge 4 commits into
sharkdp:masterfrom
hexbinoct:fix/min-depth-broken-symlink
Open

Compute depth for broken symlinks so --min-depth keeps them#2039
hexbinoct wants to merge 4 commits into
sharkdp:masterfrom
hexbinoct:fix/min-depth-broken-symlink

Conversation

@hexbinoct

@hexbinoct hexbinoct commented Jun 27, 2026

Copy link
Copy Markdown

Fixes #1017.

Problem

When following links, a broken symlink is reported by the walker as an error instead of a normal entry, so fd wraps it in DirEntry::broken_symlink. That variant carried no depth, and DirEntry::depth() returned None for it. The --min-depth filter in walk.rs treats a missing depth as below the minimum, so a broken symlink was filtered out for any --min-depth value, including --min-depth 0. The original report shows it:

fd link foo -L                 # prints foo/bar/baz/link
fd link foo -L --min-depth=1   # prints nothing (bug)
fd link foo -L --min-depth=0   # prints nothing (bug)

Fix

The depth now comes from the walker itself. ignore records a depth on the errors that carry broken symlinks, so walk.rs reads err.depth() before taking the error apart and passes it to DirEntry::broken_symlink. DirEntry::depth() returns it, and broken symlinks are filtered by depth like every other entry.

Earlier revisions of this PR derived the depth from the path by stripping the matching search root, because the walker did not report one at the time. @tmccombs then added it upstream in BurntSushi/ripgrep#3458 and #3464, which is what this now uses. The path arithmetic is deleted, along with the current_dir() call it made for every broken symlink.

That upstream depth also fixes a case the path version could not express. When two search roots overlap, the walker visits the same broken symlink once per root at a different depth each time, and both visits carry the same path, so one path derived number had to answer for both. Searching roots a and a/b with --min-depth 2 dropped a/b/blink while a real file in the same place was kept.

ignore is bumped to 0.4.28, which is the first release where the depth actually arrives on these errors. Error::depth() and WithDepth already existed in 0.4.26, so I bisected 0.4.26 through 0.4.33 to find the real floor: 0.4.26 and 0.4.27 return None, 0.4.28 onward return the right depth.

Tests

Five tests cover --min-depth, --min-depth with --absolute-path, --max-depth, --exact-depth, and the overlapping roots case. Each was checked to fail on the code before it and pass after. The full cargo test suite passes, fmt and clippy are clean, and the tests were run pinned to exactly 0.4.28 as well as to the newest ignore, since a caret requirement resolving to 0.4.33 would not have proved the floor.

Verified on Linux, including a matrix over --min-depth, --max-depth and --exact-depth from 0 to 4 comparing a broken symlink against a real file at the same depth, in both relative and --absolute-path modes. There is a CHANGELOG entry.

Note on tooling

I used an AI assistant (Claude) to help write this change. I have reviewed it, I understand how it works, and I am happy to answer questions or make changes.

@tmccombs tmccombs left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately, this doesn't work properly if the --absolute-path option is used. Probably because we are comparing the absolute path to a relative path, and not getting a match.

In particular, my test case looked like:

❯ tree
.
├── a
│   ├── b
│   │   ├── c
│   │   │   ├── d
│   │   │   │   └── foo -> /noexist
│   │   │   └── foo -> /noexist
│   │   ├── foo -> /noexist
│   │   └── two -> /tmp/tmp.od15gRvlEL/a/two
│   ├── foo -> /noexist
│   └── two
│       └── c
│           └── foo -> /noexist
├── foof -> /noexist
└── l -> a/b

9 directories, 6 files
❯ fd -L foo l -a
/tmp/tmp.od15gRvlEL/a/b/c/d/foo
/tmp/tmp.od15gRvlEL/a/b/c/foo
/tmp/tmp.od15gRvlEL/a/b/foo
/tmp/tmp.od15gRvlEL/a/b/two/c/foo

Notice that /tmp/tmp.od15gRvlEL/a/b/foo is printed even though it's relative path would be l/foo which isn't deep enough for the --min-path condition.

Comment thread src/dir_entry.rs Outdated
BrokenSymlink(PathBuf),
// Broken symlinks are surfaced by the walker as errors that carry no depth,
// so we record the depth (relative to the search root) at creation time.
BrokenSymlink(PathBuf, usize),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would probably be best to change this to be a struct-like variant, to make it more clear what the usize value is.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. It is now a struct-like variant with named path and depth fields, so the recorded depth is clear at every match and construction site.

Comment thread src/walk.rs Outdated
Comment on lines +673 to +674
/// fall back to the full component count, which keeps the entry visible rather
/// than silently dropping it.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will show it for --min-depth, but will probably hide it if --max-depth or --exact-depth is used. Which, I'm not sure what we should do in that case.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked this and it actually behaves the same as a normal entry. --max-depth (and the upper bound of --exact-depth) is enforced by the walker itself through its own internal depth, and --min-depth (and the lower bound of --exact-depth) is the only one applied here using the computed depth. So broken symlinks are kept and dropped at the same depths as real files for all three flags.

To confirm, I placed a real file next to each broken symlink and verified that --min-depth, --max-depth and --exact-depth select the same set for both, in relative and --absolute-path modes, for depths 0 to 4. I also added regression tests test_max_depth_broken_symlink and test_exact_depth_broken_symlink that exercise both the real directory route and the followed symlink route.

Comment thread src/walk.rs Outdated
@hexbinoct

Copy link
Copy Markdown
Author

Thanks for catching this. You're right that comparing an absolute entry path against a relative root would miss, so I changed the depth computation to put both the path and the roots into absolute form before stripping, and to pick the deepest matching root. That removes the relative vs absolute mismatch.

I also tried to reproduce the original symptom on the current code, to be sure I was fixing the right thing. With your tree the depths come out correct even before the change, because --absolute-path runs the root through normpath::normalize (which is canonicalize on Unix) before the walk, so the root and the entry paths are both absolute and share a prefix:

$ fd -L foo l -a --min-depth 2
/tmp/.../a/b/c/d/foo
/tmp/.../a/b/c/foo
/tmp/.../a/b/two/c/foo      # a/b/foo at depth 1 is correctly dropped

To double check, I put a real file next to each broken symlink and confirmed --min-depth 0..4 and --exact-depth select the same set for both, in relative and -a modes. The new version keeps that property and also handles the case where a root could still be relative while the path is absolute.

If you have a setup where the root stays relative under -a, could you share the exact command and tree? I'd like to pin it down with a test. I added one for --absolute-path with --min-depth in the meantime.

@bugprone

bugprone commented Jul 6, 2026

Copy link
Copy Markdown

I tested the current PR branch locally on macOS to check the original #1017 case and the --absolute-path / symlink-root case discussed in review.

Checks run:

  • cargo test depth --test tests
  • cargo test symlink --test tests
  • cargo test --all
  • cargo clippy --all-targets --all-features -- -D warnings
  • cargo fmt --all -- --check
  • git diff --check origin/master...HEAD

For the manual -L + --absolute-path + symlink root check, I used the same relevant shape as the review case:

  • a/b/c/d/foo -> /noexist
  • a/b/c/foo -> /noexist
  • a/b/foo -> /noexist
  • a/foo -> /noexist
  • a/two/c/foo -> /noexist
  • a/b/two -> <tmp>/a/two
  • foof -> /noexist
  • l -> a/b

The commands used -a, so the actual output paths were absolute. Path suffixes below are shown relative to the temp root:

  • fd -L foo l -a printed a/b/c/d/foo, a/b/c/foo, a/b/foo, and a/b/two/c/foo
  • fd -L foo l -a --min-depth 2 printed a/b/c/d/foo, a/b/c/foo, and a/b/two/c/foo
  • fd -L foo l -a --exact-depth 1 printed a/b/foo
  • fd -L foo l -a --exact-depth 2 printed a/b/c/foo

@hexbinoct

Copy link
Copy Markdown
Author

Thanks a lot for taking the time to build the tree and run this independently on macOS, @bugprone. Good to have the -a plus symlink-root case confirmed on another platform, especially the --exact-depth results, since those exercise the depth computed relative to the search root. Appreciated.

@hexbinoct
hexbinoct force-pushed the fix/min-depth-broken-symlink branch from 6998ab6 to df27ff6 Compare July 24, 2026 05:09
@hexbinoct

Copy link
Copy Markdown
Author

Rebased onto current master to clear a merge conflict. The only adaptation was in the broken symlink arm in src/walk.rs: master now extracts the io error through inner_err.io_error() (the io-error-handling refactor), so I kept that form and just added the depth computation on top, instead of the older match inner_err.as_ref() shape my branch had. The fix itself is unchanged. I verified on Linux (build, the five broken symlink depth tests, cargo clippy --all-targets --all-features -- -D warnings, and cargo fmt --check) all green.

On sequencing: whenever the ignore crate change that exposes the depth on these errors lands in a release, I am happy to switch this over to read the depth straight from the walker and drop the interim depth_relative_to_roots computation. If you would rather land this version first and do that as a follow up, that works too. Just let me know which you prefer.

@hexbinoct
hexbinoct force-pushed the fix/min-depth-broken-symlink branch 2 times, most recently from 93361ce to 70c4d0f Compare August 7, 2026 04:29
@hexbinoct

Copy link
Copy Markdown
Author

Rebased onto master to pick up #2068. The clippy failure here was the new redundant reference in format! argument lint firing on tests/tests.rs, which #2068 fixed, so the red was never from this change. The rebase is a pure base move: the diff against the new base is byte identical to the diff against the old one apart from blob hashes. Green on Linux with clippy at -Dwarnings, cargo fmt --check, and the full suite (156 + 116 tests).

When following links, a broken symlink is surfaced by the walker as an
error that carries no depth, so fd stored it with an unknown depth and
the --min-depth filter dropped it for any minimum value. Record the
depth when the entry is created by counting the path components relative
to the matching search root, which is what the issue discussion
suggested. Broken symlinks are now filtered by depth like any other
entry.

Fixes sharkdp#1017
The depth for a broken symlink was derived by stripping the search root
from its path and counting the remaining components. Compare the path
and the roots in absolute form before stripping, so the match holds
whether or not --absolute-path has already made the roots absolute, and
pick the deepest matching root. This avoids an absolute path failing to
match a relative root and falling back to an inflated depth.

Add a regression test that exercises the broken symlink case together
with --absolute-path.
Address review feedback. Make DirEntryInner::BrokenSymlink a struct-like
variant with named path and depth fields, so the recorded depth is clear
at every use site. Add regression tests that a broken symlink is filtered
by --max-depth and --exact-depth the same way a normal entry is, both
through the real directory tree and through a followed symlink.
The depth of a broken symlink was derived from its path by stripping the
matching search root, because the walker did not report a depth on the
errors that carry broken symlinks. ignore 0.4.28 fills that depth in, so
read it from the error instead and drop the path arithmetic.

The path based version could not express one case. When two search roots
overlap, the walker visits the same broken symlink once per root at a
different depth each time, and both visits carry the same path, so a
single path derived answer had to serve both. Searching roots a and a/b
with --min-depth 2 dropped a/b/blink even though a real file in its place
was kept.
@hexbinoct
hexbinoct force-pushed the fix/min-depth-broken-symlink branch from 70c4d0f to b3a8ff6 Compare August 10, 2026 09:40
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.

--min-depth is broken for broken symlinks

3 participants