bash – Swap a symbolic link with its target

This program takes a symbolic link as argument, and swaps the link with its referent. For example, given a -> b, the result will be b -> a. It creates a relative link if the original was relative, otherwise an absolute link.

I wanted to ensure that this is an all-or-nothing command, so if it fails, it leaves everything as it was. This isn’t completely achievable in the face of concurrent modification, but it does make a best-effort attempt to undo changes if it was unsuccessful. I also use rm with --no-clobber and ln without --force to avoid data loss regardless.

#!/bin/bash

set -eu -o pipefail

die()
{
    printf '%q: ' "$1"; shift
    printf '%sn' "$@"
    exit 1
}

usage()
{
    cat <<END
Usage: $0 FILE
       FILE  a symlink
The symlink will be swapped with its target
END
}

undo=""

add_undo()
{
    # prepend a command to the undo list
    local command
    printf -v command '%q ' "$@"
    undo="$command"$'n'"$undo"
}

restore()
{
    # execute the undo commands
    eval "$undo" ||
        die "$0" "Failed to restore to initial state!"
}

trap restore ERR;

if ( $# -ne 1 )
then
    usage >&2
    exit 1
fi
case "$1" in
    -h|--help)
        usage; exit ;;
esac

test -L "$1" || die "$1" 'not a symlink'
test -e "$1" || die "$1" 'dangling symlink'

target=$(readlink -e "$1")
lnopts=(--symbolic)
case "$(readlink "$1")" in
    /*)
        # make $1 absolute, without following the link itself
        set -- "$(realpath --no-symlinks "$1")"
        ;;
    *)
        lnopts+=(--relative)
        ;;
esac

# Create temporary working directory alongside the link
linkdir="$(mktemp --directory --tmpdir="$(dirname "$1")")"
test -d "$linkdir"
add_undo rm -r "$linkdir"

# Move the symlink into tempdir
mv -T "$1" "$linkdir/link"
add_undo mv -T "$linkdir/link" "$1"

# Create a new symlink next to the target
newlink="$(mktemp --dry-run --tmpdir="$(dirname "$target")")"
test -n "$newlink"
ln "${lnopts(@)}" "$1" "$newlink"
add_undo rm "$newlink"

# Move the target to its new location
# This is the riskiest and most expensive thing to restore, so do it last
mv -T --no-clobber "$target" "$1"
add_undo mv "$1" "$target"

# Move the new link into position left by target
mv -T --no-clobber "$newlink" "$target"

# Succeeded, so remove the temporary directory
undo=""
rm -r "$linkdir"