Preventing Wildcard Expansion / Globbing in Shell Scripts

I wanted to pass a string containing a wildcard to a shell script and not worry about the shell automatically globbing it for me.

For example, lets consider a simple script, globme.sh

#!/bin/bash

echo "1[${1}]"
echo "@[${@}]"
echo

which is in a directory containing:

globme.sh
notes.txt
phonebook.csv
todo.txt

Let’s see how the script reacts to different inputs. First, lets look at what it does without any wildcards:

$ ./globme.sh Not Wildcards
1[Not]
2[Wildcards]
@[Not Wildcards]

It works as one might expect, the 2nd space-separated argument passed to the script is placed into $2. If you expect a certain value to always be at $2, no matter what, you might be surprised when you call the script with something like:

$ ./globme.sh *.txt red

and find out that $2 is not “red”, but in fact the name of a file in your current directory:

$ ./globme.sh *.txt socket
1[notes.txt]
2[todo.txt]
@[notes.txt todo.txt socket]

But note the fact that, if your wildcard does not match set of files in your current directory, it will be passed to the script as-is:

$ ./globme.sh *.py socket
1[*.py]
2[socket]
@[*.py socket]

Well this is not good. The same file and same input acts two different ways based what directory you’re in. Of course, one solution would be to always remember to enclose your arguments in quotes (single or double):

$ ./globme.sh '*.txt' socket
1[*.txt]
2[socket]
@[*.txt socket]

$ ./globme.sh "*.txt" socket
1[*.txt]
2[socket]
@[*.txt socket]

But what if you didn’t want to require this? What if there were no circumstances under which you wanted the shell to expand wildcards for you? I’ll bring you slowly through the steps you might have taken had you not found this tutorial, but if you’re in a rush, you can skip to the end to my favorite solution.

After some searching, you may have found out that there is a shell option (f) that you can set that will disable this behavior. To do this, simply call set -f, as in:

$ ./globme.sh * *
1[globme.sh]
2[notes.txt]
@[globme.sh notes.txt phonebook.csv todo.txt globme.sh notes.txt phonebook.csv todo.txt]

$ set -f

$ ./globme.sh * *
1[*]
2[*]
@[* *]

So you could always call set -f before every globme.sh, but that gets tedious.

Okay, so then you could alias it by adding the following to your .bashrc / .bash_profile file:

alias globme='set -f; /path/to/globme.sh'

Which is better, since you don’t have to remember to call another program before globme, and even if you remember to call another program, you don’t have to remember the program or syntax.

But now you run into the problem where your shell option persists after your command. So while you might expect that cat * prints the contents of each file in your current directory, you’ll get an error:

$ cat *
cat: *: No such file or directory

This problem is exacerbated by the fact that subshells that spawn from your current shell inherit those options. So shell scripts, etc. that expect and rely a certain behavior will not function correctly.

Okay, so one solution would be to remember to clear the shell option after each time using set +f. This would work, but it’s terribly annoying to have to remember.

If you’re wondering whether you can modify your alias to also clear the option, perhaps like:

alias globme='set -f; /path/to/globme.sh; set+f;'

You’ll be disappointed to learn that you cannot. Your arguments are added after the alias, so you’d get something like

$ set -f
$ /path/to/globme.sh
$ set+f
$ YourFirstArgument YourSecondArgument ...

So maybe you’ll drop the alias and define a function in your .bashrc / .bash_profile to handle this for you. Maybe something like

globme()
{
    set -f
    /path/to/globme.sh
    set+f
}

You’d be further disappointed when you found out that the shell expanded your wildcards before they were passed to the function, so you’d be setting the -f option too late!

Working (but not optimal solutions)

My first solution was to edit my script to respawn the shell upon exiting. Simply put, I would use the shell’s exec command to exit into a new instance of the shell. Specifically, I would add

exec /bin/bash

or to generalize it (perhaps unnecessarily),

exec ${SHELL}

at the end of the program.

The problem with this solution is that you would lose any environment variables you had set. For example:

$ TEST="Testing"
$ echo $TEST
Testing
$ exec ${SHELL}
$ echo $TEST
(blank line)

This is not expected behavior unless you explicitly respawn your shell instance. Also, consider that your script may have multiple exit points (perhaps erroring out at various points, etc) — in that case you’d need to add the line at every exit point. Further complicating the problem is that by doing this, you’re losing the exit-code of your program. Whereas before you could have used return 1 to indicate an error (which can be viewed by echo $? as the next command after your program), now your exec will have to be in it’s place.

So next, my almost-done solution integrated both aliases and functions:

alias globme='set -f; g'
g(){ /path/to/globme.sh "$@"; set +f; }

Here, the alias allows us to set the shell option first, so the wildcards are not expanded. Then, the function g() is called, which is simply globme, with “$@” as the argument (will get to that in a second), followed by the command to clear the shell option.

This works. Perfectly. To check, you could type

echo $-

and if you see an ‘f’, the option is set (remember, it’s set with -f and cleared with +f) — if you don’t, it’s cleared.

The “$@” expands to the arguments that were passed to the function g — which in this case, are the arguments you passed at the prompt!

Cut to the chase!

Lastly, I realized that it might be a little annoying to have a function and an alias for each script that you wished behave like this. To generalize our lines, I changed them to the following:

reset_expansion(){ CMD="$1"; shift; $CMD "$@"; set +f; }
alias globme='set -f; reset_expansion /path/to/globme.sh'

which basically does the same thing, but it allows you to reuse the reset_expansion() function. For example if you had a second script that you wanted to behave the same way named newscript, you would only need to add the following to your .bashrc / .bash_profile:

alias newscript='set -f; reset_expansion /path/to/newscript'

And that’s all she wrote. Now, the bash philosophers out there will will say something along the lines of “Just enclose wildcard arguments in quotes”, which has a lot of merit, but I’d rather add these lines and not worry about it. Enjoy!

3 comments

  1. Dario Alcocer says:

    Nice article on preventing wildcard expansion. I was having a similar problem with my script, and I found your suggestion to use ‘set -f’ useful.

    There’s an easier way to include a “set” option in your script: just add it to the “shebang” line, like so:

    #!/bin/bash -f

    This method worked for me, and avoided having to use an alias.

  2. Stabledog says:

    Very helpful, thanks!

  3. Mark T. Kennedy says:

    coolest shell trick i’ve seen in *decades*. thanks!

Leave a Reply