32

Is it possible to make a function like

function doStuffAt {
    cd $1
    # do stuff
}

but make it so invoking that function doesn't actually change my pwd, it just changes it for duration of the function? I know I can save the pwd and set it back at the end, but I'm hoping there's a way to just make it happen locally and not have to worry about that.

Mason
  • 479
  • 3
    Can't you just use cd - at the end of the function to switch back to the cached $OLDPWD? You don't need to explicitly save it yourself – steeldriver Oct 02 '20 at 17:23
  • @steeldriver cd - is a good idea, but if the script was sourced and terminated before cd -, the shell would be left in the path that was provided as an argument to the function. – Quasímodo Oct 02 '20 at 17:30
  • You can use trap if you want to restore the start directory (but sourcing such scripts is a general bad idea) – eckes Oct 03 '20 at 15:58

5 Answers5

53

Yes. Just make the function run its commands in a ( ) subshell instead of a { } group command:

doStuffAt() (
        cd -- "$1" || exit # the subshell if cd failed.
        # do stuff
)

The parentheses (( )) open a new subshell that will inherit the environment of its parent. The subshell will exit as soon as the commands running it it are done, returning you to the parent shell and the cd will only affect the subshell, therefore your PWD will remain unchanged.

Note that the subshell will also copy all shell variables, so you cannot pass information back from the subshell function to the main script via global variables.

For more on subshells, have a look at man bash:

(list)

list is executed in a subshell environment (see COMMAND EXECUTION ENVIRONMENT below). Variable assignments and builtin commands that affect the shell's environment do not remain in effect after the command completes. The return status is the exit status of list.

Compare to:

{ list; }

list is simply executed in the current shell environment. list must be terminated with a newline or semicolon. This is known as a group command. The return status is the exit status of list. Note that unlike the metacharacters ( and ), { and } are reserved words and must occur where a reserved word is permitted to be recognized. Since they do not cause a word break, they must be separated from list by whitespace or another shell metacharacter.

terdon
  • 242,166
  • @user414777 I stand very much corrected. I just tested and you're absolutely right, there's no need for the second ( ). Sorry and thanks for teaching me! – terdon Oct 02 '20 at 18:01
  • 9
    Only (…) opens a subshell, {…} doesn't. What they have in common is that they're compound commands. Our reference question on the topic is https://unix.stackexchange.com/questions/306111/what-is-the-difference-between-the-bash-operators-vs-vs-vs/306141#306141 – Gilles 'SO- stop being evil' Oct 02 '20 at 18:11
  • This works great, but with the caveat (maybe desired) that changes to variables will also be local. – R.. GitHub STOP HELPING ICE Oct 05 '20 at 16:18
  • 2
    +1, though I think your original version was more clear than the current version. Using ( ... ) instead of { ... ; } for the function body is pretty subtle IMHO, and its impact is likely to be missed; whereas adding ( ... ) makes it very clear what's going on. – ruakh Oct 05 '20 at 23:45
  • pushd and popd are implemented in bash as builtins so they don't fork like subshells. cd and cd $OLDPWD also avoids forks, these do actually change the shells pwd for the duration of the function while subshells dont. – teknopaul Feb 04 '24 at 20:38
18

It depends.

You can put the function in a subshell. (See also Do parentheses really put the command in a subshell?) What happens in a subshell stays in a subshell. Changes that the function makes to variables, the current directory, to redirections, to traps, and so on, do not affect the calling code. The subshell inherits all these properties from its parent but there is no transfer in the other direction. exit in a subshell only exits the subshell, not the calling process. You can put a piece of code in a subshell by wrapping it in parentheses (line breaks and even whitespace before and after the parentheses are optional):

(
  set -e # to exit the subshell as soon as an error happens
  cd -- "$1"
  do stuff # in $1
)
do more stuff # in whatever directory was current before the '('

If you want to run the whole function in a subshell, you can use parentheses instead of braces to wrap the function's code.

doStuffAt () (
    set -e
    cd -- "$1"
    # do stuff
)

With the Korn-style function definition syntax, you need:

function doStuffAt { (
    set -e
    cd -- "$1"
    # do stuff
) }

The downside of a subshell is that nothing escapes it. If you need to change the current directory but then update some variables, you can't do that with a subshell. There are only two easy ways to retrieve information from a subshell. Like any other command, a subshell has an exit status, but this is an integer between 0 and 255 so it doesn't convey much information. You can use a command substitution to produce some output: a command substitution is a subshell whose standard output (minus trailing newlines) is collected into a string. This lets you output one string.

data=$(
  set -e
  cd -- "$1"
  do stuff # in $1
)
# Now you're still in the original directory, and you have some data in $data

You can save the current directory into a variable, and restore it later.

set -e
old_cwd="$PWD"
cd -- "$1"
…
cd "$old_cwd"

However this is not very reliable. If the code exits between the two cd commands, it'll be in the wrong directory. If the old directory is moved in the meantime, the second cd will not return to the right place. It's possible to be in a directory that you have no permission to change into (because the script has less privileges than its caller), and in this case the second cd will fail. So you should not do this unless you're in a controlled environment where this can't happen (for example, to cd into and out of a temporary directory created by your script).

If you need to both change directory temporarily and affect the shell environment in some way (such as setting variables), you need to carefully split your scripts into parts that affect the shell environment and parts that change the current directory. The shell inherits limitations of early unix systems which didn't have a way to return to the previous directory. Modern unix systems do (you can “save” the current directory's file descriptor, and return to it with fchdir() in an exception handler), but there's no shell interface to this functionality.

12

When descending into a directory "temporarily" to do some work — IOW, when you want to scope the directory change in some way — it makes sense to take advantage of the directory stack by using pushd and popd. It's a common technique in things like build scripts.

Say you're building a bunch of plugins.

for plugindir in plugin1 plugin2 plugin2 plugin4; do
  pushd -- "$plugindir"
  make
  popd
done
FeRD
  • 921
  • 1
    There are two caveats to this approach: It only works in some shells (POSIX sh does not have pushd or popd, and some other Bourne-style shells do not either), and it only works if you have a unified exit path (IOW, if you return from the function between the pushd and popd, you don't change back to your original directory). – Austin Hemmelgarn Oct 03 '20 at 12:47
  • 3
    pushd/popd are great, but they do echo the path to stdout. If that would clutter up the output, make sure to redirect each one to /dev/null. – smitelli Oct 03 '20 at 21:48
  • @smitelli Good point, especially since (as the bash(1) man page says) "If the pushd command is successful, a dirs is performed as well." dirs will output the entire stack to stdout. (Though, generally I come down on the side of a directory-change hardly being clutter. "Silent" cds are teh ebil.) – FeRD Oct 04 '20 at 13:50
  • @AustinHemmelgarn That's true, which is why I'd typically do a pushd before calling a function that needs to operate in that directory, and then the popd immediately after it returns. That won't work for all situations, of course, but I figure whenever I have a function that's complex enough to have multiple exit paths, I probably don't want it also taking the initiative to go wandering around the directory tree on its own. Not if I can avoid it, anyway. – FeRD Oct 05 '20 at 17:00
7

[this answer is assuming bash; it won't work with other shells]

Using a subshell is simple and perfectly fine in most practical cases, except when your function has to modify variables in the main script.

For that case, you can use a RETURN trap to change back to the old current directory upon returning from the function. The RETURN trap is invoked no matter how your function is exited, and is not inherited by default by other functions.

doStuffAt(){
    cd -- "$1" || return
    local opwd=$OLDPWD
    trap 'cd "$opwd"' RETURN
    #
    # do stuff
    shift; "$@"
    # change some variable
    wasAt+=("$PWD")
}

% doStuffAt / % pwd /home/user % doStuffAt /usr % echo ${wasAt[@]} / /usr

But the old directory could've been renamed when you try to change back to it, and you will not be able to do it via its path. On Linux, you can emulate with /dev/fd in the shell the safer method of opening a file descriptor to . and then calling fchdir on it:

doStuffAt() {
    local fd
    command exec {fd}< . || return
    trap 'cd -P "/dev/fd/$fd"; exec {fd}<&-' RETURN; exec {fd}<.
    cd -- "$1" || return
# do stuff
shift; &quot;$@&quot;
# change some variable
wasAt+=(&quot;$PWD&quot;)

}

Here {fd}<. is used with a local variable instead of a fixed fd so that function be re-entrant.

Notice that cd -P /dev/fd/X will succeed even if the leading directories from its resolved path are not accessible. Simple example:

t=$(mktemp -d); mkdir -p $t/b/c; exec 7<$t/b/c; chmod -rwx $t
cd -P /dev/fd/7  # this will succeed
pwd
cd $(pwd)        # this will fail

Of course, the old cwd could be made itself inaccessible, instead of the dirs leading to it (by removing its x mode); but in that case, fchdir(2) wouldn't have helped either.

Also, there's still the case where the old cwd was removed. For that case, the trap could be changed to cd "/dev/fd/$fd"; cd -P . 2>/dev/null (the -P being mostly for cosmetical purposes). But it's not clear how changing back to an empty deleted directory would be useful at all.

  • @StéphaneChazelas thanks a lot for the fixes, but the last paragraph didn't seem right. see the changes (the "anonymous" edit was still mine) -- maybe I'm misunderstanding it? –  Oct 03 '20 at 23:04
  • bash -c 'mkdir 1; cd 1; exec 3<.; cd ..; chmod 0 1; cd -P /dev/fd/3' gives me a permission denied, but bash -c 'mkdir -p 1/2/3; cd 1/2/3; exec 3<.; cd ../../..; chmod 0 1; cd -P /dev/fd/3' does not (oddly enough). – Stéphane Chazelas Oct 04 '20 at 05:17
  • Note that the only thing bash-specific in there is the RETURN trap. ksh93 and zsh use EXIT instead for that (not in sh mode in zsh, and you need to use the ksh-style function definition syntax in ksh93). So it should be easily adapted to those shells. In those you wouldn't need that double-cd workaround in your last paragraph, and you could give the O_CLOEXEC flag to that fd to avoid leaking it to other commands. – Stéphane Chazelas Oct 04 '20 at 05:35
  • WRT your first example: it will not work in C / with fchdir(2) either. You cannot change the current dir to a dir to which you don't have search/execute permissions, no matter if it's via its path (with chdir(2)), or via an open fd (with fchdir(2)). That's what I was trying to say with "fchdir(2) wouldn't have helped either". But yes, that last part is a bit untidy / rambling. –  Oct 04 '20 at 06:10
  • Thanks, I stand corrected. I hadn't realised that. I had assumed using fd = open("."); fchdir(fd) was a reliable way to temporarily change the working directory. That's what ksh93 does to implement its non-forking subshells, and I see it fails at restoring the current working directory in echo "$(chmod 0 .; cd / && pwd)". – Stéphane Chazelas Oct 04 '20 at 06:28
  • 1
    I've raised https://github.com/ksh93/ksh/issues/141 – Stéphane Chazelas Oct 04 '20 at 06:59
1

Invoke it in a subshell.

(doStuffAt some/path/)
Quasímodo
  • 18,865
  • 4
  • 36
  • 73
  • 2
    That means you need to do this in every place where the function is used. The OP wants to make the function not change the pwd, this solution does work to leave the pwd unchanged, but only if you remember to code it that way. You may as well do doStuff some/path; cd -. – terdon Oct 02 '20 at 17:23
  • @terdon True, your solution looks better suited. I wonder how I failed to notice it! Just note that cd - is not a very good solution because if the script were terminated in between and had been sourced, OP would be left in some/path – Quasímodo Oct 02 '20 at 17:24
  • Ah yes. Fair point. – terdon Oct 02 '20 at 17:27