8

Say I have two possible paths I want to list directories and files under on a Linux machine:

/some/path1/
/some/path2/

If I do the following in tcsh, I get 0 exit code, if at least one of path1 or path2 exists:

ls -d /some/{path1,path2}/*

But if I do the exact same thing in bash, I get 2 exit code, with a stderr message reporting path1 does not exist (if path1 is the one that doesn't exist).

How can I make bash behave like tcsh in this case? Is there a switch to ls that I can ask it to give back 0 if at least one path exists? If neither one exists, I do expect non-zero code, which is what tcsh gives back.

ilkkachu
  • 138,973
shikhanshu
  • 245
  • 1
  • 7

2 Answers2

25

Most of your questions are already answered at Why is nullglob not default?.

One thing to bear in mind is that:

ls -d /some/{path1,path2}/*

In csh/tcsh/zsh/bash/ksh (but not fish, see below) is the same as:

ls -d /some/path1/* /some/path2/*

As the brace expansion is performed before (not as part of) the glob expansion, it's the shell that expands those /some/pathx/* patterns into the list of matching files to pass as separate arguments to ls.

bash, like ksh which it mostly copied has inherited a misfeature introduced by the Bourne shell in that, when a glob pattern doesn't match any file, it is passed as-is, literally as an argument to the command.

So, in those shells, if /some/path1/* matches at least one file and /some/path2/* matches none, ls will be called with -d, /some/path1/file1, /some/path1/file2 and a literal /some/path2/* as arguments. As the /some/path2/* file doesn't exist, ls will report an error.

csh behaves like in the early Unix versions, where globs were not performed by the shell but by a /etc/glob helper utility (which gave their name to globs). That helper would perform the glob expansions before invoking the command and report a No match error without running the command if all the glob patterns failed to match any file. Otherwise, as long as there was at least one glob with matches, all the non-matching ones would simply be removed.

So in our example above, with csh / tcsh or the ancient Thompson shell and its /etc/glob helper, ls would be called with -d, /some/path1/file1 and /some/path1/file2 only, and would likely succeed (as long as /some/path1 is searchable).

zsh is both a Korn-like and csh-like shell. It does not have that misfeature of the Bourne shell whereby unmatched globs are passed as is¹, but by default is stricter than csh in that, all failing globs are considered as a fatal error. So in zsh, by default, if either /some/path1/* or /some/path2/* (or both) fails to match, the command is aborted. A similar behaviour can be enabled in the bash shell with the failglob option².

That makes for more predictable / consistent behaviours but means that you can run into that problem when you want to pass more than one glob expansion to a command and would not like it to fail as long as one of the globs succeeds. You can however set the cshnullglob option to get a csh-like behaviour (or emulate csh).

That can be done locally by using an anonymous function:

() { set -o localoptions -o cshnullglob; ls -d /some/{path1,path2}/*; }

Or just using a subshell:

(set -o cshnullglob; ls -d /some/{path1,path2}/*)

However here, instead of using two globs, you could use one that matches all of them using the alternation glob operator:

ls -d /some/(path1|path2)/*

Here, you could even do:

ls -d /some/path[12]/*

In bash, you can enable the extglob option for bash to support a subset of ksh's extended glob operator, including alternation:

(shopt -s extglob; ls -d /some/@(path1|path2)/*)

Now, because of that misfeature inherited from the Bourne shell, if that glob doesn't match any file, /some/@(path1|path2)/* would be passed as-is to ls and ls could end up listing a file called literally /some/@(path1|path2)/*, so you'd also want to enable the failglob option to guard against that:

(shopt -s extglob failglob; ls -d /some/@(path1|path2)/*)

Alternatively, you can use the nullglob option (which bash copied from zsh) for all non-matching globs to expand to nothing. But:

(shopt -s nullglob; ls -d /some/path1/* /some/path2/*)

Would be wrong in the special case of the ls command, which, if not passed any argument lists .. You could however use nullglob to store the glob expansion into an array, and only call ls with the member of the arrays as argument if it is non-empty:

(
  shopt -s nullglob
  files=( /some/path1/* /some/path2/* )
  if (( ${#files[@]} > 0 )); then
    ls -d -- "${files[@]}"
  else
    echo >&2 No match
    exit 2
  fi
)

In zsh, instead of enabling nullglob globally, you can enable it on a per-glob basis with the (N) glob qualifier (which inspired ksh's ~(N), not copied by bash yet), and use an anonymous function again instead of an array:

() {
  (( $# > 0 )) && ls -d -- "$@"
} /some/path1/*(N) /some/path2/*(N)

The fish shell now behaves similarly to zsh where failing globs cause an error, except when the glob is used with for, set (which is used to assign arrays) or count where it behaves in a nullglob fashion instead.

Also, in fish, the brace expansion though not a glob operator in itself is done as part of globbing, or at least a command is not aborted when brace expansion is combined with globbing and at least one element can be returned.

So, in fish:

ls -d /some/{path1,path2}/*

Would end up in effect behaving like in csh.

Even:

{ls,-d,/xx*}

Would result in ls being called with -d alone if /xx* was not matched instead of failing (behaving differently from csh in this instance).

In any case, if it's just to print the matching file paths, you don't need ls. In zsh, you could use its print builtin to print in columns:

print -rC3 /some/{path1,path2}/*(N)

Would print the paths raw on 3 columns (and print nothing if there's no match with the Nullglob glob qualifier).

If instead you want to check if there's at least one non-hidden file in any of those two directories, you can do:

# bash
if (shopt -s nullglob; set -- /some/path1/* /some/path2/*; (($#))); then
   echo yes
else
   echo no
fi

Or using a function:

has_non_hidden_files() (
  shopt -s nullglob
  set -- "$1"/*
  (($#))
)
if has_non_hidden_files /some/path1 || has_non_hidden_files /some/path2
then
  echo yes
else
  echo no
fi
# zsh
if ()(($#)) /some/path1/*(N) /some/path2/*(N); then
  echo yes
else
  echo no
fi

Or with a function:

has_non_hidden_files() ()(($#)) $1/*(NY1)
if has_non_hidden_files /some/path1 || has_non_hidden_files /some/path2
then
  echo yes
else
  echo no
fi

(Y1 as an optimisation to stop after finding the first file)

Beware those has_non_hidden_files would (silently) return false for directories that are not readable by the user (whether they have files or not). In zsh, you could detect this kind of situation with its $ERRNO special variable.


¹ The Bourne behaviour (which was specified by POSIX) can be enabled though in zsh by doing emulate sh or emulate ksh or with set +o nomatch

² beware there are significant differences in behaviour as to what exactly is cancelled when the glob doesn't match, the fish behaviour being generally the more sensible, and the bash -O failglob probably the worst

  • 2
    The quality of this response is staggering! Thanks for taking the time to write such a beautifully detailed answer. Enjoyed reading it! – shikhanshu Mar 05 '22 at 15:07
  • 1
    Although not everyone might like the POSIX/Bash default of passing unmatched globs through literally, I think calling it a "misfeature" is a bit strong. Of course general scripts meant to be portable should not rely on it and always quote everything not meant to be expanded, but interactively it's nice to save some keystrokes in cases like dnf upgrade kernel*. – TooTea Mar 06 '22 at 13:53
  • 2
    @TooTea, and then you do that in a directory where someone (maybe last year's you) made a file called kernel-versions.txt, and start wondering why it doesn't work. As long as we don't have meta-information about what the program is expecting as arguments (filenames or something else), all we can do is to do it manually. (It's just one backslash there, too) Leaving the glob as-is lets you have error messages like "ls: cannot access '*.foobar': No such file or directory" which looks nice, but even that's misleading, since it hides the fact that the shell does the globbing, not ls. – ilkkachu Mar 06 '22 at 18:48
  • @ilkkachu Of course that does happen,but it only takes you by surprise the first few times and then you get used to it. It's less work to just go back and add quotes/backslashes in the 1% of situations where the wildcard happens to match something. – TooTea Mar 06 '22 at 19:28
4

Just read Stéphane's answer.


This happens because of how bash treats globs that do not match anything. To get the same behavior you observe in tcsh, you need to enable bash's nulglob option. From man bash:

nullglob

If set, bash allows patterns which match no files (see Pathname Expansion above) to expand to a null string, rather than themselves.

Enable nullglob with shopt -s nullglob and you will get the expected behavior:

$ ls
bar
$ ls {bar,foo}/*
ls: cannot access 'foo/*': No such file or directory
 bar/ff
$ echo $?
2

$ shopt -s nullglob $ ls {bar,foo}/* bar/ff $ echo $? 0

terdon
  • 242,166
  • 1
    You don't want nullglob here. If neither pattern matches, you'll end up listing the current working directory. See Why is nullglob not default?. You'd rather want shopt -s failglob extglob and ls -d @(foo|bar)/* (with the shell reporting an error when there's no match like in tcsh, instead of passing the @(foo|bar)/* literally (which could very well be the name of an existing file) to ls as bash does by default without failglob. – Stéphane Chazelas Mar 05 '22 at 12:16
  • With nullglob, you could do: files=( {bar,foo}/* ); (( ${#files[@]} )) && ls -d -- "${files[@]}" – Stéphane Chazelas Mar 05 '22 at 12:19
  • @StéphaneChazelas sounds like a great answer. – terdon Mar 05 '22 at 12:33