29 March 2022

Unix Shell Scripting

While building a simple HTTP Server in Racket, I wanted to automatically restart the server when I changed the source file.

To achieve this, I wrote a simple shell script.

First, we need to detect when the file has been changed. We can do this by checking the timestamp for changes repeatedly, using stat -c '%Y' "$FILE"

#!/usr/bin/env sh

FILE="$1"
CHANGETIME="$(stat -c '%Y' "$FILE")"
while true; do
    NEWCHANGETIME="$(stat -c '%Y' "$FILE")"
    if [ $NEWCHANGETIME -ne $CHANGETIME ]; then
        echo "$FILE has changed!"
        CHANGETIME="$NEWCHANGETIME"
    fi;
    sleep 1
done;

Save this to a file, and make it executable (chmod +x $SCRIPT_FILE).

Let's test this:

touch testfile.txt

# In an second terminal:
./watch.sh testfile.txt

# In the original terminal:
touch testfile.txt

Each time we run touch testfile.txt, the file changed time will be updated, and our script should detect this and print testfile.txt has changed!.

#!/usr/bin/env sh

CMD="$1"
FILE="$2"

# get the file changed time as Unix time
getChangeTime () {
    stat -c '%Y' "$FILE"
}

# Start the program, store its process id as PSID,
# and set CHANGETIME to the file changed time of $FILE
startCmd () {
    $CMD $FILE &
    PSID=$!
    CHANGETIME="$(getChangeTime)"
    echo "Started $CMD $FILE at $(TZ=UTC date)"
}

# Initial start
startCmd

# Check for changes every second, if the file has
# changed, then kill the process and restart it
while true; do
    NEWCHANGETIME="$(getChangeTime)"
    if [ $NEWCHANGETIME -ne $CHANGETIME ]; then
        echo "$FILE has changed!"
        kill $PSID
        startCmd
    fi;
    sleep 1
done;

Let's test this:

./watch.sh racket ./server.rkt

# In a second terminal
echo '; A trailing comment' >>  ./server.rkt

And we should see the program killed, and restarted with a message:

Started racket ./server.rkt at Sun 13 Mar 13:31:12 UTC 2022

But what if we want to watch multiple files? We can use the following commands to get the latest time that any file from a directory was changed, and then use this in our script.

# Set the directory to watch
DIR="./src"
# Get all the last updated times for all files in the directory, sort this descending,
# and take the first one.
find "$DIR" -printf '%T@\n' | sort -r  | sed '1q'

find "$DIR" -printf '%T@\n' will print the time as a decimal, so we also need to change $NEWCHANGETIME -ne $CHANGETIME to $NEWCHANGETIME != $CHANGETIME - we will now compare the values as strings, not as integers.

Making these changes, and adding the directory as a third argument to the script, gives us the following new script:

#!/usr/bin/env sh

CMD="$1"
FILE="$2"
DIR="$3"

getChangeTime () {
    find "$DIR" -printf '%T@\n' | sort -r | sed 1q
}

startCmd () {
    $CMD $FILE &
    PSID=$!
    CHANGETIME="$(getChangeTime)"
    echo "Started $CMD $FILE at $(TZ=UTC date)"
}

echo "Watching directory $DIR"

startCmd

while true; do
    NEWCHANGETIME="$(getChangeTime)"
    if [ $NEWCHANGETIME != $CHANGETIME ]; then
        echo "A file has changed!"
        kill $PSID
        startCmd
    fi;
    sleep 1
done;

We can run this as follows:

./watch.sh racket server3.rkt .

And test by touching files in the directory:

echo foo > somefile
# Each time we touch a file, the script should restart our program.
touch somefile

If this has been useful, you have any comments or questions, or I have made a mistake in this post, please get in touch!

Tags: Shell