How does the mv command work with external drives?

Solution 1:

macOS's mv is based on the BSD source code. You can find the source code to the mv command online. Using https://github.com/freebsd/freebsd/blob/master/bin/mv/mv.c as a reference, you can see that they do indeed first try to rename the file and then if it is crossing filesystems, it does a cp followed by a rm.

    /*
     * If rename fails because we're trying to cross devices, and
     * it's a regular file, do the copy internally; otherwise, use
     * cp and rm.
     */
    if (lstat(from, &sb)) {
        warn("%s", from);
        return (1);
    }
    return (S_ISREG(sb.st_mode) ?
        fastcopy(from, to, &sb) : copy(from, to));
}

Solution 2:

Yes, you're right in thinking that moving a single file on the same file system is really just implemented as a rename operating that the file system structure is changed to update the new name/location of the file, but the file contents are not read/written to the drive again.

When the move happens across two different file systems (drives or partitions), then the mv commands first deletes the destination (if there were an old file there already), copies over the contents of the file to the destination and then finally removes the source file.

The behavior is explained in the manual for mv on macOS:

As the rename(2) call does not work across file systems, mv uses cp(1) and rm(1) to accomplish the move. The effect is equivalent to:

rm -f destination_path && \
cp -pRP source_file destination && \
rm -rf source_file

In regards to the other answer that compares this behavior with the FreeBSD source code - the mv command on macOS is actually a bit different than on FreeBSD. In particular it makes sure that extended attributes and resource forks are moved over correctly and do not disappear when moving across file system boundaries.

You can read the actual macOS source for mv. You'll see that it is similar in structure as the FreeBSD version, but contains various Apple specific enhancement. In addition to the functionality regarding extended attributes and resource forks as described above, it also has performance enhancements for use with Xsan (distributed file system).

You'll find in the code that first a rename is attempted:

if (!rename(from, to)) {
    if (vflg)
        printf("%s -> %s\n", from, to);
    return (0);
}

If this rename() fails, the code checks why it failed. Especially it checks for the error number EXDEV, which means that the rename would have crossed file systems, and thus cannot be done:

if (errno == EXDEV) {
    struct statfs sfs;
    char path[PATH_MAX];

    /* Can't mv(1) a mount point. */
    if (realpath(from, path) == NULL) {
        warnx("cannot resolve %s: %s", from, path);
        return (1);
    }
    if (!statfs(path, &sfs) && !strcmp(path, sfs.f_mntonname)) {
        warnx("cannot rename a mount point");
        return (1);
    }
} else {
    warn("rename %s to %s", from, to);
    return (1);
}

Note here that this code aborts the move in case that the source contains unresolvable symbolic links, or if it is actually a mount point - and also generally if the rename() fails for other reasons than EXDEV.

Only in case that rename() fails with error number EXDEV, and not for the above mentioned reasons, the following code is run:

/*
 * If rename fails because we're trying to cross devices, and
 * it's a regular file, do the copy internally; otherwise, use
 * cp and rm.
 */
if (lstat(from, &sb)) {
    warn("%s", from);
    return (1);
}
return (S_ISREG(sb.st_mode) ?
    fastcopy(from, to, &sb) : copy(from, to));

This code branches out to do the move between file systems in two different ways depending on whether or not the source to be moved is actually a regular file - or it is something else. "Something else" is usually a directory, a symbolic link, a device node or similar.

In case of a regular file, it uses fastcopy() which simply opens the source and destination files, read()s the data from the source and write()s them to the destination. Unlike the FreeBSD version, the fastcopy() function uses fcopyfile() to copy over ACLs and extended attributes from the source to the destination.

In case of something that is not a regular file, it simply spawns external commands to perform the move: cp for copying and rm for deleting.