Bash and Linux

Create, Copy, Move and Delete

easyShell basics and files

Problem statement

Build a small release folder from the command line: create folders and files, copy them, rename one, and delete what you no longer need. Engineers do this every day when they prepare a deploy, back up a config before editing it, or clean up a server.

The script starts in an empty folder with two files:

app.conf

TEXT
port=8080

-old.txt (a file whose name starts with a dash)

TEXT
old notes

Do these steps in order:

  1. Make the folder release/v1/config, with its parents, in one command.
  2. Copy app.conf into release/v1/config/.
  3. Rename the copy to app.ini.
  4. Create an empty file release/v1/notes.txt.
  5. Copy the whole release/v1 folder to release/v2.
  6. Make a scratch folder with two files in it, then delete the folder and its files.
  7. Make an empty folder called empty, then remove it.
  8. Delete the file -old.txt.
  9. Print every path that is left, sorted.

Expected output:

TEXT
Final tree:
.
./app.conf
./release
./release/v1
./release/v1/config
./release/v1/config/app.ini
./release/v1/notes.txt
./release/v2
./release/v2/config
./release/v2/config/app.ini
./release/v2/notes.txt

Hints

Hint 1: mkdir can make a whole chain of folders at once with one flag, and cp needs a flag before it will copy a folder.

Approach

Optimal: mkdir, cp, mv, rm

Covers: mkdir -p, touch, cp, cp -r, mv, rm, rm -r, rmdir, --, find.

Five commands do almost all file work. Here is what each one does, with the flags you need most:

Command What it does Useful flags
mkdir name make a folder -p makes missing parents, and no error if it already exists
touch file make an empty file (or update the time of an existing one)
cp from to copy -r copies a folder and everything in it, -a also keeps owners, times and permissions
mv from to move or rename -i asks before overwriting
rm file delete -r deletes a folder and everything in it, -i asks first
rmdir name remove a folder only works if the folder is empty

mv moves and renames. There is no separate rename command. If the new path is in the same folder, mv renames. If it is in another folder, mv moves. Moving inside the same disk is instant, because only the name changes, not the data.

rm has no undo. There is no recycle bin on the command line. Once rm runs, the file is gone. That is why careful engineers:

  • run ls on the same path first, to see what would be deleted;
  • use rmdir for folders they expect to be empty, because it refuses to delete anything else;
  • never run rm -rf on a path built from a variable they have not checked.

What -- does. Most commands read any word that starts with - as an option. So rm -old.txt fails: rm reads -old.txt as a bundle of options and stops with "invalid option". Writing rm -- -old.txt says "no more options after this point", so -old.txt is read as a name. Writing rm ./-old.txt also works, because the name now starts with ..

Walking through the code. The # Setup: lines only create the sample files, so skip past them. They work in a new temporary folder, so your real files are safe.

%%{init: {"flowchart": {"padding": 18, "nodeSpacing": 30, "rankSpacing": 40, "htmlLabels": true}, "themeVariables": {"fontSize": "18px"}}}%% flowchart TB subgraph S1["Copy, then rename"] direction LR A["app.conf"]:::blue --> C1(["cp"]):::purple --> B2["app.conf copy"]:::yellow --> C2(["mv"]):::purple --> C["app.ini"]:::green end subgraph S2["Copy a folder"] direction LR D["release/v1"]:::blue --> C3(["cp -r"]):::purple --> E["release/v2"]:::green end subgraph S3["Delete a folder"] direction LR F["scratch/"]:::yellow --> C4(["rm -r"]):::purple --> G["gone, no undo"]:::red end S1 ~~~ S2 ~~~ S3 classDef blue fill:#dbeafe,stroke:#2563eb,color:#1e3a8a,stroke-width:2px classDef yellow fill:#fef3c7,stroke:#d97706,color:#78350f,stroke-width:2px classDef green fill:#d1fae5,stroke:#059669,color:#064e3b,stroke-width:2px classDef red fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,stroke-width:2px classDef purple fill:#ede9fe,stroke:#7c3aed,color:#4c1d95,stroke-width:2px classDef gray fill:#f3f4f6,stroke:#6b7280,color:#111827,stroke-width:2px linkStyle default stroke:#94a3b8,stroke-width:2px style S1 fill:transparent,stroke:#2563eb,stroke-width:2px style S2 fill:transparent,stroke:#059669,stroke-width:2px style S3 fill:transparent,stroke:#dc2626,stroke-width:2px
  1. mkdir -p release/v1/config makes release, then release/v1, then release/v1/config. Without -p, mkdir stops with an error because release does not exist yet.
  2. cp app.conf release/v1/config/ copies the file into the folder. The trailing / makes it clear the target is a folder.
  3. mv .../app.conf .../app.ini stays in the same folder, so it renames.
  4. touch release/v1/notes.txt creates an empty file.
  5. cp -r release/v1 release/v2 copies the folder. release/v2 does not exist yet, so cp creates it as the copy.
  6. rm -r scratch deletes the folder and both files inside it.
  7. rmdir empty works because the folder is empty.
  8. rm -- -old.txt deletes the file with the awkward name.
  9. find . | sort prints every path under the current folder. find lists files in whatever order the disk gives them, so sort makes the order stable.

Edge cases. mkdir -p on a folder that already exists does nothing and does not fail, which is why scripts use it. rmdir on a folder that still has files fails with "Directory not empty", which protects you. cp onto an existing file overwrites it without asking, unless you add -i.

# Setup: start in a fresh, empty temporary folder with one config file
cd "$(mktemp -d)"
printf 'port=8080\n' > app.conf
printf 'old notes\n' > -old.txt

mkdir -p release/v1/config               # -p: make every missing parent folder too
cp app.conf release/v1/config/           # copy a file into a folder
mv release/v1/config/app.conf release/v1/config/app.ini   # mv renames when it stays in the same folder
touch release/v1/notes.txt               # touch: create an empty file

cp -r release/v1 release/v2              # -r: copy a folder and everything inside it

mkdir scratch
touch scratch/a.tmp scratch/b.tmp
rm -r scratch                            # -r: delete a folder and everything inside it

mkdir empty
rmdir empty                              # rmdir only removes a folder that is already empty

rm -- -old.txt                           # --: "no more options", so -old.txt is read as a name

echo "Final tree:"
find . | sort                            # every path under here, in a stable order

Interview follow-ups

  • How do you make the delete step safe inside a script?

    Start the script with set -u, so an unset variable stops it instead of becoming empty. Write paths as "${DIR:?}" so an empty value stops the script too. Before a big delete, print what would go, for example with find "$DIR" -name '*.tmp' -print, and only then run the delete. Keep deletes inside a folder the script owns, such as one made with mktemp -d, so a mistake cannot reach anything else.

Frequently asked questions

If DIR is empty or unset, the shell turns rm -rf "$DIR/" into rm -rf "/", which tries to delete the whole system. This has happened in real install scripts. Protect yourself by writing rm -rf "${DIR:?}/": the :? makes the shell stop with an error if DIR is empty. Adding set -u at the top of a script also stops it when it uses a variable that was never set. Printing the path with echo before deleting is a good habit when you are unsure.

cp -r copies a folder and everything inside it, but the copies get you as the owner and the current time. cp -a (archive) also keeps the original owner, permissions, times and symlinks as they were. Use cp -a for backups and for copying app folders, where permissions matter. Use cp -r when you just want the contents somewhere else.

mv replaces the existing file silently, and the old one is gone. The same is true for cp. Add -i to make them ask first, or -n to never overwrite. In scripts, check first with a test like [[ -e target ]] when overwriting would be a problem.