Bash – Creating the own cp function in bash

bashfilesscriptingshellshell-script

For an assignment I am asked to cleverly write a bash function which has the same basic functionality as the function cp (copy). It only has to copy one file to another, so no multiple files copied to a new directory.

Since I am new to the bash language, I can't understand why my program is not working. The original function asks to overwrite a file if it is already present, so I tried to implement that. It fails.

The file fails at multiple lines it seems, but most importantly at the condition where it checks if the file to be copied to already exists ([-e "$2"]). Even so, it still shows the message that is supposed to be triggered if that condition is met (The file name …).

Could anyone help me in fixing this file, possibly providing some useful insight in my basic comprehension of the language?
The code is as follows.

#!/bin/sh
echo "file variable: $2"
if [-e file]&> /dev/null
then
    echo "The file name already exists, want to overwrite? (yes/no)"
    read  | tr "[A-Z]" "[a-z]"
    if [$REPLY -eq "yes"] ; then
        rm "$2"
        echo $2 > "$2"
        exit 0
    else
        exit 1
    fi
else
    cat $1 | $2
    exit 0
fi

Best Answer

The cp utility will happily overwrite the target file if that file already exists, without prompting the user.

A function that implements basic cp capability, without using cp would be

cp () {
    cat "$1" >"$2"
}

If you want to prompt the user before overwriting the target (note that it may not be desireable to do this if the function is called by a non-interactive shell):

cp () {
    if [ -e "$2" ]; then
        printf '"%s" exists, overwrite (y/n): ' "$2" >&2
        read
        case "$REPLY" in
            n*|N*) return ;;
        esac
    fi

    cat "$1" >"$2"
}

The diagnostic messages should go to the standard error stream. This is what I do with printf ... >&2.

Notice that we don't really need to rm the target file as the redirection will truncate it. If we did want to rm it first, then you'd have to check whether it's a directory, and if it is, put the target file inside that directory instead, just the way cp would do. This is doing that, but still without explicit rm:

cp () {
    target="$2"
    if [ -d "$target" ]; then
        target="$target/$1"
    fi

    if [ -d "$target" ]; then
        printf '"%s": is a directory\n' "$target" >&2
        return 1
    fi

    if [ -e "$target" ]; then
        printf '"%s" exists, overwrite (y/n): ' "$target" >&2
        read
        case "$REPLY" in
            n*|N*) return ;;
        esac
    fi

    cat "$1" >"$target"
}

You may also want to make sure that the source actually exists, which is something cp does do (cat does it too, so it may be left out completely, of course, but doing so would create an empty target file):

cp () {
    if [ ! -f "$1" ]; then
        printf '"%s": no such file\n' "$1" >&2
        return 1
    fi

    target="$2"
    if [ -d "$target" ]; then
        target="$target/$1"
    fi

    if [ -d "$target" ]; then
        printf '"%s": is a directory\n' "$target" >&2
        return 1
    fi

    if [ -e "$target" ]; then
        printf '"%s" exists, overwrite (y/n): ' "$target" >&2
        read
        case "$REPLY" in
            n*|N*) return ;;
        esac
    fi

    cat "$1" >"$target"
}

This function uses no "bashisms" and should work in all sh-like shells.

With a little bit more tweaking to support multiple source files and a -i flag that activates the interactive prompting when overwriting an existing file:

cp () {
    local interactive=0

    # Handle the optional -i flag
    case "$1" in
        -i) interactive=1
            shift ;;
    esac

    # All command line arguments (not -i)
    local -a argv=( "$@" )

    # The target is at the end of argv, pull it off from there
    local target="${argv[-1]}"
    unset argv[-1]

    # Get the source file names
    local -a sources=( "${argv[@]}" )

    for source in "${sources[@]}"; do
        # Skip source files that do not exist
        if [ ! -f "$source" ]; then
            printf '"%s": no such file\n' "$source" >&2
            continue
        fi

        local _target="$target"

        if [ -d "$_target" ]; then
            # Target is a directory, put file inside
            _target="$_target/$source"
        elif (( ${#sources[@]} > 1 )); then
            # More than one source, target needs to be a directory
            printf '"%s": not a directory\n' "$target" >&2
            return 1
        fi

        if [ -d "$_target" ]; then
            # Target can not be overwritten, is directory
            printf '"%s": is a directory\n' "$_target" >&2
            continue
        fi

        if [ "$source" -ef "$_target" ]; then
            printf '"%s" and "%s" are the same file\n' "$source" "$_target" >&2
            continue
        fi

        if [ -e "$_target" ] && (( interactive )); then
            # Prompt user for overwriting target file
            printf '"%s" exists, overwrite (y/n): ' "$_target" >&2
            read
            case "$REPLY" in
                n*|N*) continue ;;
            esac
        fi

        cat -- "$source" >"$_target"
    done
}

Your code has bad spacings in if [ ... ] (need space before and after [, and before ]). You also shouldn't try redirecting the test to /dev/null as the test itself has no output. The first test should furthermore use the positional parameter $2, not the string file.

Using case ... esac as I did, you avoid having to lowercase/uppercase the response from the user using tr. In bash, if you had wanted to do this anyway, a cheaper way of doing it would have been to use REPLY="${REPLY^^}" (for uppercasing) or REPLY="${REPLY,,}" (for lowercasing).

If the user says "yes", with your code, the function puts the filename of the target file into the target file. This is not a copying of the source file. It should fall through to the actual copying bit of the function.

The copying bit is something you've implemented using a pipeline. A pipeline is used to pass data from the output of one command to the input of another command. This is not something we need to do here. Simply invoke cat on the source file and redirect its output to the target file.

The same thing is wrong with you calling of tr earlier. read will set the value of a variable, but produces no output, so piping read to anything is nonsensical.

No explicit exit is needed unless the user says "no" (or the function comes across some error condition as in bits of my code, but since it's a function I use return rather than exit).

Also, you said "function", but your implementation is a script.

Do have a look at https://www.shellcheck.net/, it's a good tool for identifying problematic bits of shell scripts.


Using cat is just one way to copy the contents of a file. Other ways include

  • dd if="$1" of="$2" 2>/dev/null
  • Using any filter-like utility who can be made to just pass data through, e.g. sed "" "$1" >"2" or awk '1' "$1" >"$2" or tr '.' '.' <"$1" >"$2" etc.
  • etc.

The tricky bit is to make the function copy the metadata (ownership and permissions) from source to target.

Another thing to notice is that the function I've written will behave quite differently from cp if the target is something like /dev/tty for example (a non-regular file).

Related Question