SIGN IN SIGN UP

fix(ls): match GNU and BSD on operand handling, -t and type indicators (#363)

* fix(ls): resolve operands literally instead of re-matching them as globs

`ls` ran every operand containing `*`, `?` or `[` through minimatch against
the whole path table before listing it. Pathname expansion is the shell's
job, so by the time the command runs an operand is either a real filename
that happens to hold those characters or a pattern that matched nothing and
was passed through unchanged. Matching it a second time got both wrong:

  ls 'report [1].pdf'   existing file reported missing, exit 2
  ls -l /w/'q?mark.txt' listed, but as `q?mark.txt` with /w/ stripped
  ls 'nope*'            listed nothing, exit 2 (right answer, wrong route)

The bracket case is the damaging one. `[1]` reads as a character class, so
a name cannot match itself, and `ls` reports a file that `cat`, `stat`, `wc`
and `rg` all read without complaint as not existing. Names carrying `?` or
`*` did list, but `listGlob` returned paths relative to cwd regardless of
how the operand was spelled, so an absolute operand came back shortened.

Measured against GNU coreutils ls 9.2 and BSD ls (macOS 15). Both list a
bracketed name handed to them literally, both report an unmatched pattern
as a missing file, and neither rewrites the operand's spelling. They differ
only in the exit status for a missing operand (GNU 2, BSD 1); just-bash
already followed GNU there and keeps its existing diagnostic wording.

Two security tests moved rather than went away:

- `ls 'bar*'` covered a prefix-sharing sibling leak (`/foo` vs `/foobar`)
  found in #307. The leak was a property of the matching loop, which is now
  gone, so the case splits: the unexpanded pattern must find nothing, and
  the shell-expanded `ls bar*` must still list only the cwd's entry.
- The traversal-budget case drove ls's tree walk through `ls '*'`, which no
  longer walks anything. `ls -R` is now the only path that descends, so it
  takes over the assertion, with nesting deep enough to charge the budget.

* chore(deps): drop minimatch, now that ls no longer matches operands

ls held the only import. The blocked-globals entry for minimatch's testing
hook stays: it costs nothing and the global is worth refusing whether or not
the package is installed.

* fix(ls): group operands the way GNU and BSD ls do

Every operand was separated from the previous one by a blank line, whatever
it named. Real ls separates only *directory groups*: non-directory operands
print first as one block in sort order with no label and no separator, then
each directory follows under a `name:` label with a blank line before it.

The blank line between file operands is the damaging half. `ls` receives a
batch of plain filenames whenever it is driven from `find … -exec ls -l {} +`
or `xargs ls -l`, and the separator puts an empty line between every entry.
Sorting that by size floats the blanks to the top, so the common
"smallest/largest file here" pipeline returns nothing but empty lines:

  find . -type f -exec ls -l {} + | sort -k5 -n | head

Operand order was also taken as given. Real ls sorts each group by the
active sort key rather than preserving the command line, so `ls dir2 f2 f1
dir1` lists f1 and f2 before dir1 and dir2. `sortOperands` applies the same
key the directory entries use, so -S and -r reach operands too.

Three further divergences fall out of the same restructure:

- `-d` no longer separates its operands, and sorts them into one block.
- A missing operand is now diagnosed once, up front, and the operands that
  do exist still list; the status stays 2.
- A lone directory operand stays unlabeled, which the old loop got right
  only because `paths.length > 1` happened to gate the header.

Measured against GNU coreutils ls 9.2 and BSD ls (macOS 15), which agree on
every case here; the comparison fixtures record the host's real ls output.

* test(find): update the -exec ls batch expectation to the unseparated block

The case pinned the blank line ls used to put between file operands, which
is the shape a `find … -exec ls -l {} + | sort` pipeline breaks on.

* fix(ls): sort by modification time for -t

`-t` was parsed into `_sortByTime` and never read, so `ls -lt` returned the
same name-ordered listing as `ls -l`. Nothing failed and nothing was
reported, and `ls --help` has documented the flag as "sort by time, newest
first" throughout, so the answer to "what changed here most recently" was
whichever name sorted first.

-S and -t are not an error together, and GNU lets the one written last on
the command line decide, which the parsed booleans cannot express on their
own; `resolveSortKey` reads the last occurrence back off the argument list.

Both keys now run through one comparator, which sorts descending and breaks
ties by name. -S previously left ties in whatever order readdir returned, so
equal-sized entries could come back in an order nothing in the listing
explained. -r reverses the finished order, tiebreak included.

Measured against GNU coreutils ls 9.2 and BSD ls (macOS 15), which agree on
ordering, on the name tiebreak and on -St precedence. The comparison
fixtures record the host's real ls, stamping mtimes inside the compared
command since the fixture files are all created together.

* fix(ls): append type indicators only with -F

Long format suffixed every directory with `/` whether or not -F was given,
so `ls -l` and `ls` disagreed about what the same entry is called and a name
lifted out of a long listing carried a trailing slash that is not part of
it. Short format already got this right, which is what made the two
disagree.

  ls -l   drwxr-xr-x 1 user user  0 Aug 10 17:04 sub/   -> sub
  ls -lF  drwxr-xr-x 1 user user  0 Aug 10 17:04 sub/   (unchanged)

The mode column already says `d`, which is how real ls distinguishes a
directory in long format; `-F`, and only `-F`, adds the indicator on top.

Measured against GNU coreutils ls 9.2 and BSD ls (macOS 15). The comparison
fixtures pin the short-format contract against the host's real ls; the
long-format half cannot be compared, since owner, mode and size will never
agree, so unit tests cover it.

* fix(ls): order -R sections by the sort key, and charge operands to the budget

Two follow-ups from review on #363.

`-R` sorted every directory's entries by the requested key and then emitted
the sections themselves in name order, so `ls -Rt` and `ls -RS` were half
sorted: right within each listing, wrong between them. GNU coreutils ls 9.2
orders the sections too, which is checkable in one line:

  gtouch -t 202601010000 zzz; gtouch -t 202001010000 aaa
  gls -Rt        # zzz section first, then aaa

That mattered here because `-t` is new in this PR. `-S` had the same gap
before it, but shipping a flag that is only half applied is worse than not
having shipped it, so the descent now takes its order from the same
comparator the entries use, and the batched results are restored to that
order rather than re-sorted by name afterwards.

Operand partitioning stat'ed every path before the traversal budget was
charged, so a long operand list did all of its filesystem work before the
budget could refuse any of it. Each operand is a visit like any other and is
now charged as one. Bounded by argv limits either way, so this is about the
guard tripping when it says it will rather than about a reachable exhaustion.

* fix(ls): charge each operand once, and bound -d and sort metadata reads

Charging an operand both where it is resolved and again in listPath spent two
entries on one visit, so maxTraversalEntries refused work at half the capacity
it states. -d returned before the budget saw its operands at all, and the
metadata reads -t and -S need were uncharged. Sort keys now come from lstat, so
a symlink orders on its own mtime as ls does without -L.
J
Jeremy Mack committed
4de3cd6e167bb54cf239aae92c45ac15cc9e2117
Parent: 43c37ce
Committed by GitHub <noreply@github.com> on 8/29/2026, 9:50:09 PM