Skip to content

A copy nests if the destination exists

Level: 201 · you have run cp -R and found a copy inside the copy, or a src/ inside the folder you meant to fill

One line: cp -R src dst has no fixed meaning: it asks whether dst already exists and copies into it if so, which makes the command non-idempotent — run it twice and the second run buries a copy at dst/src. A trailing slash on the source is where the two machines part: BSD cp reads src/ as the contents of src and keeps filling dst, while GNU cp ignores the slash and nests exactly as it does without one. Each system has its own spelling for "never nest", and neither one works on the other.

This is the copy equivalent of a race you cannot see: the first run succeeds, looks right, and teaches you the wrong rule. The second run — a retry after an interruption, a script run twice, a cron job — is the one that builds dst/src/src. rsync made the trailing slash meaningful precisely because cp did not, and a habit carried from rsync to cp, or from a Mac to Linux, silently changes what the command does.

Measured

Seven commands, on a directory holding two files. The same script, run on both machines, and the keys differ — which is the lesson.

Verified output of cp_nests_sh.sh on Linux — regenerated by tools/run_examples.py, never hand-typed.

$ find src | sort
src
src/a.txt
src/b.txt

$ cp -R src dst; find dst | sort
dst
dst/a.txt
dst/b.txt

$ cp -R src dst; find dst | sort
dst
dst/a.txt
dst/b.txt
dst/src
dst/src/a.txt
dst/src/b.txt

$ cp -R src/ slash; find slash | sort
slash
slash/a.txt
slash/b.txt

$ cp -R src/ slash; find slash | sort
slash
slash/a.txt
slash/b.txt
slash/src
slash/src/a.txt
slash/src/b.txt

$ cp -RT src tgt; find tgt | sort
tgt
tgt/a.txt
tgt/b.txt

$ cp -RT src tgt; find tgt | sort
tgt
tgt/a.txt
tgt/b.txt

Verified output of cp_nests_sh.sh on macOS — regenerated by tools/run_examples.py, never hand-typed.

$ find src | sort
src
src/a.txt
src/b.txt

$ cp -R src dst; find dst | sort
dst
dst/a.txt
dst/b.txt

$ cp -R src dst; find dst | sort
dst
dst/a.txt
dst/b.txt
dst/src
dst/src/a.txt
dst/src/b.txt

$ cp -R src/ slash; find slash | sort
slash
slash/a.txt
slash/b.txt

$ cp -R src/ slash; find slash | sort
slash
slash/a.txt
slash/b.txt

$ cp -RT src tgt; find tgt | sort
cp: illegal option -- T
usage: cp [-R [-H | -L | -P]] [-fi | -n] [-aclpSsvXx] source_file target_file
       cp [-R [-H | -L | -P]] [-fi | -n] [-aclpSsvXx] source_file ... target_directory
find: tgt: No such file or directory

$ cp -RT src tgt; find tgt | sort
cp: illegal option -- T
usage: cp [-R [-H | -L | -P]] [-fi | -n] [-aclpSsvXx] source_file target_file
       cp [-R [-H | -L | -P]] [-fi | -n] [-aclpSsvXx] source_file ... target_directory
find: tgt: No such file or directory

The first cp -R src dst creates dst as the copy. The second, identical command finds dst present and copies src inside it. Nothing warns you; both runs exit 0.

On a Mac

Two differences, and they point the same way: BSD cp gives the trailing slash a meaning GNU cp does not, and withholds the flag GNU uses instead.

  • cp -R src/ dst keeps filling dst. BSD cp treats the slash as the contents of, so repeating the command is safe. On Linux the slash is discarded and the second run nests, exactly as it would without it. A script written and tested on a Mac can therefore build dst/src/ the first time it runs on Linux.
  • cp -T does not exist. GNU's --no-target-directory is the explicit "dst IS the copy, never treat it as a folder to copy into". BSD cp answers illegal option -- T and prints its usage, so a portable script cannot reach for it.
  • ditto src dst never nests, with or without a slash, whether or not dst exists. It is macOS's own copy tool, it preserves resource forks, ACLs and extended attributes, and its idempotence is why it is the safe choice for a script that may be re-run. There is no ditto on Linux.

The portable rule that survives both: delete the destination first, or name it something that cannot already exist. rm -rf dst && cp -R src dst means one thing on every system.

In zsh and fish

Nothing changes, and that is worth stating rather than assuming: cp is an external program, so the shell contributes nothing to the decision. The zsh and fish scripts run the same seven commands and print, on a given machine, the same bytes as bash. Only the helper differs — fish needs $argv and end where bash and zsh use $* and }.

The split in the keys is between Linux and macOS, never between the shells.

bash zsh fish
does cp -R nest into an existing dst? yes yes yes
does a trailing src/ change that? on the OS, not the shell same same
a scratch directory work=$(mktemp -d) work=$(mktemp -d) set work (mktemp -d)
cd or give up cd "$work" \|\| exit 1 cd "$work" \|\| exit 1 cd $work; or exit 1

If you are coming from another library

  • Python. shutil.copytree ↗ takes the opposite position by default: it raises FileExistsError rather than nesting, and dirs_exist_ok=True is the opt-in. The standard library chose to make the ambiguous case an error instead of a guess.
  • Rust. fd, rg, bat: what the Rust rewrites actually bought ↗ — the same theme of a rewritten Unix tool choosing different defaults from the one it replaces.

See also