Files
bytecii 88ef99016e fix(ls): list a nested mount's root in its parent's -R listing
`ls -R` on a directory holding a nested mount omitted the mount's own
row while still printing its group, so `ls -R /base` reported `top.txt`
and then `/base/nested:` for a mountpoint it had never named. GNU
(coreutils 9.7, pinned over a real tmpfs) lists a mountpoint as an
ordinary directory entry of its parent, `-R` or not.

The generic conflated two different things: listing a mount root as a
row, and descending into it. `-R` withheld the whole `child_mounts`
merge on a backend-served listing, on the theory that the cross-mount
fan-out contributed the nested mount whole. It contributes the group,
not the parent's row, so the row survived only by accident -- whenever
the parent's own backend happened to hold a key of that name. Splitting
the two: the merge is unconditional, and descent stops at a mount root,
read from the mount table (`MountView.is_root`) rather than inferred
from `child_mounts` being empty. A namespace-only directory *above* a
mount root is still descended, because nothing else renders it.

Same cause, three more shapes. `ls -R /` dropped `ghost` and both the
`/ghost:` and `/ghost/very:` groups whenever a backend served `/`;
`/base/sub`'s group dropped a mount at `/base/sub/deep`; and a relative
operand leaked shadowed content, printing `base/inner:` twice -- once
with the parent's hidden `leftover.txt`, once with the mount's own
listing -- because `_drop_shadowed_ls_groups` only recognizes an
absolute header. That group is now never produced rather than produced
and filtered.

So `ls` takes `mounts` beside `links` and `child_mounts`, and it names
the boundaries *this walk's readdir cannot cross*, not the boundaries
that exist. The distinction is the whole contract, because the two
entry points differ: `ls_generic` is bound to one backend and stops
there, while the cross-mount relay's readdir routes per path, crosses
a boundary itself, and must -- nothing runs behind a relay to
contribute that group the way the fan-out does. Python said this by
omission already, calling the generic with explicit keywords; the
TypeScript relay hands the generic a whole `CommandOpts`, so it now
builds one carrying every name-plane fact except the boundaries.
Without that, `ls -R /base /other` dropped `/base/inner:` entirely.

Two unit tests that passed a bare `child_mounts` now supply a mount
table, since the fact moved to its real source, and both relay suites
gain a case that hands the relay a full namespace.

`integ/crossmount/nested/basic.json` seq 950006 pinned the bug; it now
pins GNU's answer.

A mountpoint is not always a directory, so the row is stat'd, not
synthesized. Every workspace mounts `/.bash_history` as a whole mount
serving one file, and a hardcoded `FileType.DIRECTORY` made `ls -aF /`
print `.bash_history/` and `ls -l` render it `drwxr-xr-x`. No backend
can answer that question -- the parent's cannot see into the child, and
the child's own names its root `/` -- so it goes to the dispatcher via
`opts.stat_path`, which routes to whichever mount owns the path, and
the row is renamed the way the relay renames one. Directory stays the
fallback for a caller with no dispatcher, which is the only thing that
absence can mean.

The same fact settles what `-R` descends: the ls fan-out no longer
emits a block for a mount that is not a directory, which otherwise
printed `.bash_history` a second time as its own group. Only a
*confirmed* non-directory is dropped, so a root the dispatcher cannot
stat keeps its block rather than vanishing on a failed probe. GNU is
pinned the same way, over a `mount --bind` of one file onto another:
one ordinary row in the parent, no group of its own.
2026-08-18 05:10:42 -07:00
..