Why I Keep a Portable Bash Function for Safe File Moving

In my daily work I often need to move large sets of log files from one directory to another while preserving permissions and avoiding accidental overwrites. The built‑in mv command is fast, but it can silently clobber existing files unless you add extra checks. Over time I’ve wrapped this pattern into a reusable function that gives me a safety net without sacrificing speed.

Real‑world scenario

Imagine a nightly batch job that aggregates application logs into a staging directory before they are shipped to a central analytics pipeline. The staging area is shared among several services, so two jobs might try to place a file with the same name at the same time. If the second job runs mv without checking, it will overwrite the first job’s output, causing data loss. The function below solves this by:

  • Checking whether the destination already exists.
  • If it does, appending a timestamp or a numeric suffix to make the name unique.
  • Moving the file with mv -i (interactive) as a fallback, but the script never prompts because we handle the naming ourselves.
  • Preserving original attributes with mv --preserve=mode,ownership,timestamps on systems that support it (GNU coreutils).

The function

# Move a file safely, avoiding overwrites by generating a unique name if needed.
# Usage: safe_move SOURCE DEST_DIR
safe_move() {
    local src="$1" dest_dir="$2"

    # Basic validation
    [[ -z "$src" || -z "$dest_dir" ]] && { echo "Usage: safe_move SOURCE DEST_DIR" >&2; return 1; }
    [[ ! -e "$src" ]] && { echo "Error: source '$src' does not exist" >&2; return 2; }
    [[ ! -d "$dest_dir" ]] && { echo "Error: destination '$dest_dir' is not a directory" >&2; return 3; }

    # Extract the base name (filename) from the source path
    local base_name
    base_name="$(basename -- "$src")"

    # Build the initial destination path
    local dest_path="$dest_dir/$base_name"

    # If the destination already exists, create a unique variant
    if [[ -e "$dest_path" ]]; then
        # Try appending a timestamp first
        local timestamp
        timestamp="$(date +%Y%m%d%H%M%S)"
        local candidate="$dest_dir/${base_name%.*}_$timestamp.${base_name##*.}"
        # If the file has no extension, adjust accordingly
        if [[ "$base_name" != *.* ]]; then
            candidate="$dest_dir/${base_name}_$timestamp"
        fi

        # If the timestamped name also exists (unlikely but possible), fall back to a numeric loop
        if [[ -e "$candidate" ]]; then
            local i=1
            while [[ -e "$dest_dir/${base_name%.*}_$i.${base_name##*.}" ]]; do
                ((i++))
            done
            candidate="$dest_dir/${base_name%.*}_$i.${base_name##*.}"
            if [[ "$base_name" != *.* ]]; then
                candidate="$dest_dir/${base_name}_$i"
            fi
        fi
        dest_path="$candidate"
        echo "Warning: '$base_name' already exists in '$dest_dir'. Renaming to '$(basename -- "$dest_path")'" >&2
    fi

    # Perform the move, preserving metadata where possible
    if mv --preserve=mode,ownership,timestamps -- "$src" "$dest_path" 2>/dev/null; then
        return 0
    else
        # Fallback for systems that don't support --preserve
        mv -- "$src" "$dest_path"
    fi
}

Why this works

The function separates concerns: validation, name generation, and the actual move. By checking for existence up front we avoid the race condition that a simple mv -n would still have if another process creates the file after the check but before the move. In practice the window is tiny, and for most batch jobs it’s acceptable. If you need stronger guarantees you could rename the source to a temporary unique name first, then move it, but that adds complexity that rarely pays off.

Using date +%Y%m%d%H%M%S gives a human‑readable timestamp that sorts lexicographically, making it easy to later identify when a conflict occurred. The numeric fallback guarantees termination even if the clock were to go backwards (a scenario that can happen in containers with faulty NTP).

Preserving ownership and timestamps is important when the moved files are later consumed by processes that rely on those attributes (e.g., backup tools that increment based on mtime). The GNU mv flag --preserve does exactly that; the fallback ensures the script still runs on macOS or BSD systems where the flag is unavailable.

Edge cases and extensions

While the function handles regular files well, there are a few situations you might want to adapt:

  • Directories: The same logic works if you pass a directory as SOURCE. The timestamp is appended to the directory name, preserving its contents.
  • Multiple sources: If you need to move many files at once, wrap the call in a for loop. The function will generate a unique name for each source individually, preventing clashes between them.
  • Custom naming schemes: Replace the timestamp with a UUID (uuidgen) or a hash of the file contents if you need stronger uniqueness guarantees.

How to integrate it

Drop the function into your ~/.bashrc or a shared utilities file that you source in your scripts. Because it only uses POSIX‑compatible builtins (basename, date, mv) it works on any modern Linux box and on macOS with bash installed via Homebrew.

Testing the function

Here’s a quick sanity check you can run in a throwaway directory:

mkdir -p /tmp/safe_move_test/src /tmp/safe_move_test/dest
# Create a file with known content
echo "hello world" > /tmp/safe_move_test/src/test.log
# First move – should succeed with original name
safe_move /tmp/safe_move_test/src/test.log /tmp/safe_move_test/dest
ls -1 /tmp/safe_move_test/dest
# Create another file with the same name in the source
echo "second" > /tmp/safe_move_test/src/test.log
# Second move – should rename with timestamp
safe_move /tmp/safe_move_test/src/test.log /tmp/safe_move_test/dest
ls -1 /tmp/safe_move_test/dest
# Clean up
rm -rf /tmp/safe_move_test

Running this script should show the first file as test.log and the second as something like test_20240925153045.log. Feel free to replace the timestamp logic with a UUID to see the alternate behavior.

Closing thoughts

Having a small, well‑tested utility like this saves me from repetitive boilerplate and reduces the chance of silent data loss in automated pipelines. It’s a concrete example of how a few defensive lines can turn a risky one‑liner into a reliable building block for larger workflows.