Computing Foundations › The Command Line › Day 14
Hands-on lab — Day 14: Automating Tasks with Shell Scripts and cron
- ← Back to the Day 14 lesson
- Open the hands-on files on GitHub — clone or download them from the public labs repository
- Local path in your clone:
labs/sections/computing-foundations/day-014-automating-tasks-with-shell-scripts-and/
Commands
Setup
cd labs/sections/computing-foundations/day-014-automating-tasks-with-shell-scripts-and Run
bash examples/organize_files.sh --dry-run ~/organize-demo
bash examples/organize_files.sh ~/organize-demo Test
bash tests/run_tests.sh File tree
examples/organize_files.sh examples/schedule-examples.md expected-output/FIELDS.md expected-output/sample-dry-run.txt expected-output/sample-real-run.txt metadata.yml README.md requirements/README.md security.md starter/automation-worksheet.md starter/organize_files.sh tests/run_tests.sh troubleshooting.md
Lab README
Day 014 lab — Automate a File-Organizing Task
Lesson
- Lesson title: Automating Tasks with Shell Scripts and cron
- Day number: 14 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-014-automating-tasks-with-shell-scripts-and
- Lab files: everything you need is in this directory — follow “How to run” below.
- Browse the course locally: from the repository root, this lab also appears in the course website at
/labs/day-014-automating-tasks-with-shell-scripts-andwhen the site is running.
Purpose
Day 14's lesson explains task automation: a safe script plus a scheduler. This lab makes the "safe script" half real. You build a file organizer that sorts a folder's files into subfolders by extension — with the three properties every scheduled job needs: a dry-run preview, a log of what it did, and idempotency so it is safe to run twice. You then read (but do not install) the cron schedules that would run it daily or weekly. Nothing here edits your crontab or touches anything outside a practice folder you create.
Learning objectives
- Write a shell script that sorts files into subfolders by extension.
- Add a
--dry-runmode that previews actions without changing anything. - Make a script log every action with a timestamp.
- Make a move operation idempotent, so a second run is harmless.
- Read a cron schedule field by field and write lines for "8 p.m. daily" and "6 a.m. Monday" — without adding them to your real crontab.
Prerequisites
- The Day 14 lesson (read it first — it explains cron, logging, dry-run, and idempotency).
- Day 12 (shell scripting: variables, loops, conditionals).
- A terminal: Terminal.app (macOS), any terminal (Linux), or WSL (Windows).
- No software to install and no scheduler to configure.
Supported operating systems
- macOS — fully supported (tested on macOS with Apple Silicon).
- Linux — fully supported (any distribution with a POSIX shell).
- Windows — run the scripts unmodified inside WSL.
Hardware requirements
Any computer made in roughly the last 15 years. The lab only creates a small scratch folder of empty files; it needs no minimum RAM, disk, or GPU.
Required software
bash(3.2 or newer — preinstalled on macOS and Linux).- Standard OS utilities only:
basename,dirname,mkdir,mv,tr,date,find. All preinstalled.
Free and open-source options
Everything here is free: bash and every command used ship with your OS. No account, API key, purchase, or network access is needed. The schedulers discussed in the lesson (cron, launchd, systemd timers, Task Scheduler) are all built into their operating systems at no cost — but this lab installs none of them.
Installation
None. Clone the repository (or copy this directory) and you are ready:
cd labs/sections/computing-foundations/day-014-automating-tasks-with-shell-scripts-and
File structure
day-014-automating-tasks-with-shell-scripts-and/
├── README.md ← you are here
├── metadata.yml ← machine-readable lab metadata
├── starter/
│ ├── organize_files.sh ← YOUR working file (5 exercises)
│ └── automation-worksheet.md ← cron expressions + dry-run prediction
├── examples/
│ ├── organize_files.sh ← completed reference implementation
│ └── schedule-examples.md ← cron lines to READ (not install)
├── tests/
│ └── run_tests.sh ← automated checks in an isolated temp dir
├── expected-output/
│ ├── sample-dry-run.txt ← real captured dry run
│ ├── sample-real-run.txt ← real captured real run + log
│ └── FIELDS.md ← invariants and platform notes
├── requirements/
│ └── README.md ← dependency statement (none beyond the OS)
├── troubleshooting.md
└── security.md
How to run
From this directory:
## 1. Make a scratch folder with some mixed files
mkdir -p ~/organize-demo
touch ~/organize-demo/report.pdf ~/organize-demo/notes.txt \
~/organize-demo/photo.jpg ~/organize-demo/data.csv ~/organize-demo/archive.zip
## 2. Preview first — the dry run changes nothing
bash examples/organize_files.sh --dry-run ~/organize-demo
## 3. Run it for real, then run it AGAIN to see idempotency
bash examples/organize_files.sh ~/organize-demo
bash examples/organize_files.sh ~/organize-demo
## 4. Your task: complete the five exercises in the starter, then run it
bash starter/organize_files.sh --dry-run ~/organize-demo
## 5. Check everything
bash tests/run_tests.sh
What the commands do
bash examples/organize_files.sh --dry-run ~/organize-demo— runs the reference organizer in preview mode: it prints one "would create directory" line per new extension and one "would move" line per file, and changes nothing on disk.bash examples/organize_files.sh ~/organize-demo— the real run: it creates extension subfolders (pdf/,txt/, …), moves each file into its folder, and appends a timestamped line per move to~/organize-demo/organize.log. A second identical run moves nothing (idempotency).bash starter/organize_files.sh ...— the same script with five exercises to complete; each numbered comment names the exact code to add.bash tests/run_tests.sh— creates a fresh temp directory of known files, verifies the dry run changes nothing, the real run sorts correctly and logs, and a second run is idempotent, then removes the temp directory. It never touches cron or anything outside its sandbox.
Expected output
See expected-output/sample-dry-run.txt
and expected-output/sample-real-run.txt
— real captured runs. The dry run prints, for a folder of five mixed files:
[dry-run] would create directory: /home/you/organize-demo/pdf
[dry-run] would move report.pdf -> pdf/
...
[dry-run] complete: 5 file(s) would be organized, 0 changes made
The real run reports the same moves as actions taken and writes organize.log;
a second real run reports 0 file(s) organized. Your file names and line order
may differ. expected-output/FIELDS.md lists the
invariants every correct run must satisfy.
Validation steps
- Run the dry run and confirm with
ls ~/organize-demothat nothing moved. - Run it for real and confirm files landed in subfolders named for their (lowercased) extension.
- Confirm
~/organize-demo/organize.loghas one timestamped line per move. - Run it a second time and confirm it reports
0 file(s) organized. - Run the tests (next section) — all checks must pass.
Tests
bash tests/run_tests.sh
Expected final line: 18 checks, 0 failure(s). The command exits 0 on
success and non-zero on any failure, so it can run in CI. All test activity
happens inside a temporary directory created with mktemp and removed on exit
— the tests never read or write cron, launchd, systemd, or anything outside
that sandbox.
Cleanup
rm -rf ~/organize-demo # remove your scratch folder
git checkout -- starter/organize_files.sh # optional: reset your work
The tests clean up their own temp directory automatically. Nothing in this lab leaves a scheduled job behind, because nothing in this lab creates one.
Troubleshooting
See troubleshooting.md for the full list (missing target folder, dry run appearing to move files, files without extensions, idempotency confusion, WSL notes, and why a real scheduled job might not run).
Security notes
See security.md. Short version: the script only touches the
folder you give it, needs no sudo, makes no network calls, and this lab never
edits your crontab. Always dry-run a move-or-delete script first.
Extension exercises
- Add a per-run summary line to
organize.log(a timestamp and the count of files moved this run) so a week of logs tells the folder's story at a glance. - Add a
--quietflag that suppresses per-file output but still logs, for use in a scheduled job whose console output you do not read. - On paper, design a real crontab line for your own Downloads folder: choose a time, write the full line with absolute paths, and note which log you would check the next morning to confirm it ran.
Navigation
- Previous day: Day 13 — Package Managers: Homebrew, apt, and winget
(
labs/sections/computing-foundations/day-013-package-managers-homebrew-apt-and-winget/). - Next day: Day 15 begins the next week (to be written). This lab feeds the Week 2 project — a Personal Automation Script.
Expected output
FIELDS.md
# Expected output — what a correct run produces
The captures in this directory are real runs of `examples/organize_files.sh`
on the authoring machine (macOS, Apple Silicon, 2026-07-12). Paths were
rewritten to `~/organize-demo` for readability; everything else is verbatim.
## Files here
- `sample-dry-run.txt` — a real `--dry-run` on a folder of five mixed files:
one "would create directory" line per new extension, one "would move" line
per file, and a summary ending in `0 changes made`. The folder is left
untouched.
- `sample-real-run.txt` — a real run (files sorted into `pdf/`, `txt/`, `jpg/`,
`csv/`, `zip/`), a second run proving idempotency (`0 file(s) organized`),
and the resulting `organize.log`.
## Invariants a correct run must satisfy (every platform)
1. Dry-run mode prints `[dry-run]` lines and makes **no changes** — no files
moved, no subfolders created, no log written.
2. A real run moves each file into a subfolder named for its **lowercased**
extension (so `photo.JPG` and `photo.jpg` both go to `jpg/`).
3. Each real move appends one timestamped line to `organize.log`.
4. A second real run on the same folder moves nothing and reports
`0 file(s) organized` (idempotency).
5. The script exits `0` on success and touches nothing outside the target
directory — in particular it never edits any crontab or scheduler.
## Platform differences
The script uses only POSIX shell features plus `tr`, `mv`, `mkdir`, `basename`,
and `date`, all present on macOS and every mainstream Linux distribution, so
output is identical across them apart from the absolute paths shown. The line
order follows the shell's alphabetical glob expansion, which is consistent on a
given system. On Windows, run it inside WSL for identical behavior.
## Automated test output (captured 2026-07-12, macOS)
```text
18 checks, 0 failure(s).
```
sample-dry-run.txt
$ bash examples/organize_files.sh --dry-run ~/organize-demo
[dry-run] would create directory: /home/you/organize-demo/zip
[dry-run] would move archive.zip -> zip/
[dry-run] would create directory: /home/you/organize-demo/csv
[dry-run] would move data.csv -> csv/
[dry-run] would create directory: /home/you/organize-demo/txt
[dry-run] would move notes.txt -> txt/
[dry-run] would create directory: /home/you/organize-demo/jpg
[dry-run] would move photo.jpg -> jpg/
[dry-run] would create directory: /home/you/organize-demo/pdf
[dry-run] would move report.pdf -> pdf/
[dry-run] complete: 5 file(s) would be organized, 0 changes made
sample-real-run.txt
$ bash examples/organize_files.sh ~/organize-demo
moved archive.zip -> zip/
moved data.csv -> csv/
moved notes.txt -> txt/
moved photo.jpg -> jpg/
moved report.pdf -> pdf/
complete: 5 file(s) organized
$ bash examples/organize_files.sh ~/organize-demo # second run: idempotent
complete: 0 file(s) organized
$ cat ~/organize-demo/organize.log
2026-07-12 13:38:23 moved archive.zip -> zip/
2026-07-12 13:38:23 moved data.csv -> csv/
2026-07-12 13:38:23 moved notes.txt -> txt/
2026-07-12 13:38:24 moved photo.jpg -> jpg/
2026-07-12 13:38:24 moved report.pdf -> pdf/
2026-07-12 13:38:24 run complete: 5 file(s) organized
2026-07-12 13:38:24 run complete: 0 file(s) organized
Source files
examples/organize_files.sh (3279 bytes)
#!/usr/bin/env bash
# Day 014 lab — completed reference implementation.
#
# Organize a directory by sorting its files into subfolders named for their
# file extension. Designed to be SAFE to run unattended and on a schedule:
# * --dry-run previews every action and changes nothing
# * logging appends one timestamped line per real move to organize.log
# * idempotent a second run moves nothing, because sorted files now live
# in subfolders that the top-level scan does not descend into
#
# Usage:
# organize_files.sh [--dry-run] <directory>
#
# This script only ever touches the directory you give it. It never edits
# your crontab or any system setting.
set -euo pipefail
usage() {
echo "Usage: $(basename "$0") [--dry-run] <directory>"
echo " Sort files in <directory> into subfolders by extension."
echo " --dry-run print what would happen without moving anything"
}
# --- parse arguments -------------------------------------------------------
dry_run=false
target=""
for arg in "$@"; do
case "$arg" in
--dry-run) dry_run=true ;;
-h|--help) usage; exit 0 ;;
-*) echo "error: unknown option: $arg" >&2; usage >&2; exit 2 ;;
*) target="$arg" ;;
esac
done
if [ -z "$target" ]; then
echo "error: no target directory given" >&2
usage >&2
exit 2
fi
if [ ! -d "$target" ]; then
echo "error: not a directory: $target" >&2
exit 2
fi
log="$target/organize.log"
prefix=""
$dry_run && prefix="[dry-run] "
# Track which extension folders we have already announced in a dry run,
# since dry-run never actually creates the directory.
announced=" "
count=0
for path in "$target"/*; do
# Only consider regular files that sit directly in the target directory.
[ -f "$path" ] || continue
base="$(basename "$path")"
# Skip our own log file so we never sort the log into a "log/" folder.
[ "$base" = "organize.log" ] && continue
# Skip dotfiles and anything without a clear extension (no dot in the name).
case "$base" in
.*) continue ;; # hidden files like .gitkeep
*.*) : ;; # has an extension — proceed
*) continue ;; # no extension at all — leave it alone
esac
# Extension = text after the final dot, lowercased for tidy folder names.
ext="$(printf '%s' "${base##*.}" | tr '[:upper:]' '[:lower:]')"
dest="$target/$ext"
# Announce (dry-run) or create (real) the destination folder once.
if [ ! -d "$dest" ] && [[ "$announced" != *" $ext "* ]]; then
if $dry_run; then
echo "${prefix}would create directory: $dest"
else
mkdir -p "$dest"
fi
announced="$announced$ext "
fi
# Idempotency guard: only move if the file is not already in its folder.
if [ "$(cd "$(dirname "$path")" && pwd)" = "$(cd "$dest" 2>/dev/null && pwd || echo "$dest")" ]; then
continue
fi
count=$((count + 1))
if $dry_run; then
echo "${prefix}would move $base -> $ext/"
else
mv "$path" "$dest/"
echo "moved $base -> $ext/"
echo "$(date '+%F %T') moved $base -> $ext/" >> "$log"
fi
done
if $dry_run; then
echo "${prefix}complete: $count file(s) would be organized, 0 changes made"
else
echo "complete: $count file(s) organized"
echo "$(date '+%F %T') run complete: $count file(s) organized" >> "$log"
fi
examples/schedule-examples.md (2679 bytes)
# Scheduling the organizer with cron — read, understand, opt in later
This file shows how you *would* run `organize_files.sh` on a schedule. You do
**not** have to install any of these, and this lab never edits your crontab for
you. Read the examples to understand the syntax; if and when you want a real
scheduled job, you add it yourself, deliberately, with `crontab -e`.
## The shape of a crontab line
```text
┌── minute (0–59)
│ ┌── hour (0–23)
│ │ ┌── day of month (1–31)
│ │ │ ┌── month (1–12)
│ │ │ │ ┌── day of week (0–7, 0 and 7 = Sunday)
│ │ │ │ │
* * * * * command to run
```
Always use **absolute paths** in a crontab line: cron runs with a bare
environment and an unpredictable working directory, so `~/Downloads` or a
relative script path may not resolve. Spell everything out.
## Ready-to-read examples
Organize your Downloads folder **every day at 8:00 p.m.**:
```text
0 20 * * * /usr/bin/env bash /Users/you/labs/.../examples/organize_files.sh "$HOME/Downloads"
```
Organize it **every Monday at 6:00 a.m.**:
```text
0 6 * * 1 /usr/bin/env bash /Users/you/labs/.../examples/organize_files.sh "$HOME/Downloads"
```
Organize it **every 15 minutes during weekday business hours** (a busy shared
folder):
```text
*/15 9-17 * * 1-5 /usr/bin/env bash /path/to/organize_files.sh "$HOME/Inbox"
```
Preview instead of acting — a **daily dry run** whose output you can inspect,
useful while you still don't fully trust a job:
```text
0 20 * * * /usr/bin/env bash /path/to/organize_files.sh --dry-run "$HOME/Downloads" >> "$HOME/organize-dryrun.log" 2>&1
```
The trailing `>> file 2>&1` sends both normal output and errors to a log file
you own, which is exactly how unattended jobs keep a record you can read the
next morning.
## How to add one yourself (only if you choose to)
```bash
crontab -l # list your current jobs (may be empty — that is normal)
crontab -e # open your crontab in an editor; add ONE line; save; quit
crontab -l # confirm the line is there
```
To remove it later, run `crontab -e` again and delete the line. On macOS the
modern, recommended scheduler is `launchd` rather than cron; on Linux you may
prefer a `systemd` timer. The five-field idea above is the same in all of
them — learn it once, read them all.
## Safety reminder
Only ever schedule a script you have **read**, that supports **--dry-run**, and
that **logs** what it does. Run it by hand (and in dry-run) several times first.
A scheduled job runs faithfully, unattended, for as long as you leave it in the
table — make sure it is a job you trust that much.
metadata.yml (708 bytes)
lesson_id: D014
day: 14
kind: shell-scripting
languages: [bash]
setup_commands:
- cd labs/sections/computing-foundations/day-014-automating-tasks-with-shell-scripts-and
run_commands:
- bash examples/organize_files.sh --dry-run ~/organize-demo
- bash examples/organize_files.sh ~/organize-demo
test_commands:
- bash tests/run_tests.sh
cleanup_commands:
- 'rm -rf ~/organize-demo # remove the scratch folder you created for practice'
- 'git checkout -- starter/organize_files.sh # optional: reset your work'
requires_network: false
requires_api_key: false
estimated_minutes: 30
last_executed: '2026-07-12'
executed_on: 'macOS (Apple Silicon), bash tests/run_tests.sh → 18 checks, 0 failure(s)'
requirements/README.md (782 bytes)
# Dependencies — Day 014 lab
**None beyond a POSIX shell.** This lab has zero installable dependencies:
- `bash` ≥ 3.2 (preinstalled on macOS and every mainstream Linux distribution)
- Standard OS utilities only: `basename`, `dirname`, `mkdir`, `mv`, `tr`,
`date`, `find` — all part of the base system.
There is deliberately no `requirements.txt` / `package.json` here: the lab runs
on a factory-fresh machine with nothing installed.
No scheduler is required or configured. The lab **reads about** cron, launchd,
systemd timers, and Task Scheduler but installs none of them and never edits
your crontab. If you later choose to schedule the script yourself, `crontab`
(part of cron) already ships with macOS and Linux — but that is optional and
outside the tested lab.
starter/automation-worksheet.md (2691 bytes)
# Automation worksheet — Day 014
Fill this in as you work through the lesson and lab. It feeds directly into
the Week 2 project (a Personal Automation Script). Nothing here touches your
real crontab — you are writing and reading schedules on paper, not installing
them.
## Part 1 — Write two cron expressions
Using only the five-field definition (minute, hour, day-of-month, month,
day-of-week), write the crontab line for each schedule and explain every
field. The command to run in both cases is:
```text
/usr/bin/env bash /absolute/path/to/organize_files.sh "$HOME/Downloads"
```
### 1a. Every day at 8:00 p.m.
```text
Your cron expression: ____ ____ ____ ____ ____ <command>
```
| Field | Your value | What it means |
| ----- | ---------- | ------------- |
| minute | | |
| hour | | |
| day of month | | |
| month | | |
| day of week | | |
### 1b. Every Monday at 6:00 a.m.
```text
Your cron expression: ____ ____ ____ ____ ____ <command>
```
| Field | Your value | What it means |
| ----- | ---------- | ------------- |
| minute | | |
| hour | | |
| day of month | | |
| month | | |
| day of week | | |
## Part 2 — Predict the dry-run output
Suppose the target folder contains exactly these entries before you run the
script:
```text
budget.xlsx
photo1.JPG
photo2.jpg
readme
notes.txt
.gitkeep
organize.log
```
Predict the dry-run output **before running anything**. Answer these:
1. Which subfolders would the script create? (List the extension names.)
Your answer: ______________________________________________
2. Which files would it report it "would move," and to which folder each?
Your answer: ______________________________________________
3. Which entries would it skip entirely, and why?
Your answer: ______________________________________________
4. How many files would the summary line report? ______
> Hints, from the script's rules: extensions are **lowercased** (so `photo1.JPG`
> sorts to `jpg/`, the same folder as `photo2.jpg`); files with **no dot** in
> the name are skipped; **dotfiles** (names starting with `.`) are skipped; and
> the script never sorts its own `organize.log`.
## Part 3 — Check your prediction
Create a scratch folder, recreate the listing above, and run the real script
in dry-run mode. Compare its output with your Part 2 answers.
```bash
mkdir -p ~/organize-demo && cd ~/organize-demo
touch budget.xlsx photo1.JPG photo2.jpg readme notes.txt .gitkeep organize.log
bash /path/to/lab/examples/organize_files.sh --dry-run ~/organize-demo
```
Note any difference between your prediction and the actual output here:
```text
Differences (if any): ______________________________________
```
starter/organize_files.sh (3600 bytes)
#!/usr/bin/env bash
# Day 014 lab — YOUR working file.
#
# Build a safe file-organizing script step by step. The skeleton already
# parses arguments and loops over the directory's files; your job is to fill
# in the five numbered exercises below so the script sorts files by extension,
# supports a --dry-run preview, logs its moves, and is safe to run twice.
#
# The completed reference version is in examples/organize_files.sh — try to
# finish this yourself first, then compare.
#
# Usage: organize_files.sh [--dry-run] <directory>
#
# This script must ONLY touch the directory you give it. It never edits your
# crontab or any system setting.
set -euo pipefail
usage() {
echo "Usage: $(basename "$0") [--dry-run] <directory>"
}
# --- argument parsing (already done for you) -------------------------------
dry_run=false
target=""
for arg in "$@"; do
case "$arg" in
--dry-run) dry_run=true ;;
-h|--help) usage; exit 0 ;;
-*) echo "error: unknown option: $arg" >&2; usage >&2; exit 2 ;;
*) target="$arg" ;;
esac
done
if [ -z "$target" ] || [ ! -d "$target" ]; then
echo "error: give one existing directory to organize" >&2
usage >&2
exit 2
fi
log="$target/organize.log"
prefix=""
$dry_run && prefix="[dry-run] "
announced=" "
count=0
for path in "$target"/*; do
[ -f "$path" ] || continue
base="$(basename "$path")"
[ "$base" = "organize.log" ] && continue
# Exercise 1 (SKIP FILES WITH NO EXTENSION):
# Skip dotfiles (names starting with '.') and any file with no dot in its
# name, so files without a clear extension are left alone. Use a `case`:
# case "$base" in .*) continue ;; *.*) : ;; *) continue ;; esac
case "$base" in
.*) continue ;;
*) : ;; # <-- replace this line to also skip names with no dot
esac
# Exercise 2 (COMPUTE THE EXTENSION AND DESTINATION):
# Set `ext` to the text after the final dot, lowercased, and `dest` to a
# subfolder of "$target" named for that extension. Hints:
# ext="$(printf '%s' "${base##*.}" | tr '[:upper:]' '[:lower:]')"
# dest="$target/$ext"
ext="unknown"
dest="$target/$ext"
# Exercise 3 (CREATE OR ANNOUNCE THE FOLDER):
# If the destination does not exist and has not been announced yet, then
# in dry-run mode print "${prefix}would create directory: $dest"
# and in real mode run mkdir -p "$dest"
# Record it: announced="$announced$ext "
if [ ! -d "$dest" ] && [[ "$announced" != *" $ext "* ]]; then
: # <-- add the dry-run echo / real mkdir here, then record it in announced
announced="$announced$ext "
fi
count=$((count + 1))
# Exercise 4 (MOVE OR PREVIEW, WITH LOGGING):
# In dry-run mode print "${prefix}would move $base -> $ext/"
# In real mode: mv "$path" "$dest/"
# echo "moved $base -> $ext/"
# and append a timestamped line to "$log":
# echo "$(date '+%F %T') moved $base -> $ext/" >> "$log"
if $dry_run; then
: # <-- print the "would move" line
else
: # <-- move the file, echo it, and append to the log
fi
done
# Exercise 5 (PRINT THE SUMMARY):
# In dry-run mode print: "${prefix}complete: $count file(s) would be organized, 0 changes made"
# In real mode print: "complete: $count file(s) organized"
# (and, in real mode, append the same summary to "$log")
if $dry_run; then
echo "(exercise 5: print the dry-run summary here)" # <-- replace this line
else
echo "(exercise 5: print the real-run summary here)" # <-- replace this line
fi
tests/run_tests.sh (4277 bytes)
#!/usr/bin/env bash
# Tests for the Day 014 lab. Run from the lab directory:
# bash tests/run_tests.sh
#
# Verifies the completed reference organizer (examples/organize_files.sh):
# * --dry-run previews actions and moves NOTHING
# * a real run sorts files into subfolders named for their extension
# * every real move is recorded in organize.log
# * a second real run is idempotent (moves nothing)
#
# The tests operate ONLY inside a fresh temp directory created with mktemp and
# removed on exit. They never read, write, or touch cron, launchd, systemd, or
# anything outside that temp directory.
set -u
lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
script="${lab_dir}/examples/organize_files.sh"
failures=0
checks=0
check() {
local label="$1" ok="$2"
checks=$((checks + 1))
if [ "${ok}" = "yes" ]; then
echo " ok: ${label}"
else
echo " FAIL: ${label}"
failures=$((failures + 1))
fi
}
# --- isolated sandbox ------------------------------------------------------
work="$(mktemp -d)"
cleanup() { rm -rf "${work}"; }
trap cleanup EXIT
sandbox="${work}/messy"
mkdir -p "${sandbox}"
# Known mixed files, including case and extension variety.
touch "${sandbox}/report.pdf" \
"${sandbox}/notes.txt" \
"${sandbox}/photo.JPG" \
"${sandbox}/data.csv" \
"${sandbox}/archive.zip"
before_listing="$(cd "${sandbox}" && ls | sort | tr '\n' ' ')"
echo "Testing dry-run mode (must change nothing) ..."
dry_out="$(bash "${script}" --dry-run "${sandbox}" 2>&1)"
check "dry-run exits successfully" "$([ $? -eq 0 ] && echo yes || echo no)"
echo "${dry_out}" | grep -q "would move report.pdf -> pdf/" && check "dry-run previews a move" "yes" || check "dry-run previews a move" "no"
echo "${dry_out}" | grep -q "0 changes made" && check "dry-run reports 0 changes made" "yes" || check "dry-run reports 0 changes made" "no"
after_dry="$(cd "${sandbox}" && ls | sort | tr '\n' ' ')"
[ "${before_listing}" = "${after_dry}" ] && check "dry-run left the folder untouched" "yes" || check "dry-run left the folder untouched" "no"
[ ! -e "${sandbox}/pdf" ] && check "dry-run created no subfolders" "yes" || check "dry-run created no subfolders" "no"
[ ! -e "${sandbox}/organize.log" ] && check "dry-run wrote no log" "yes" || check "dry-run wrote no log" "no"
echo "Testing real run (must sort files by extension) ..."
real_out="$(bash "${script}" "${sandbox}" 2>&1)"
check "real run exits successfully" "$([ $? -eq 0 ] && echo yes || echo no)"
[ -f "${sandbox}/pdf/report.pdf" ] && check "report.pdf landed in pdf/" "yes" || check "report.pdf landed in pdf/" "no"
[ -f "${sandbox}/txt/notes.txt" ] && check "notes.txt landed in txt/" "yes" || check "notes.txt landed in txt/" "no"
[ -f "${sandbox}/jpg/photo.JPG" ] && check "photo.JPG landed in jpg/ (extension lowercased)" "yes" || check "photo.JPG landed in jpg/ (extension lowercased)" "no"
[ -f "${sandbox}/csv/data.csv" ] && check "data.csv landed in csv/" "yes" || check "data.csv landed in csv/" "no"
[ -f "${sandbox}/zip/archive.zip" ] && check "archive.zip landed in zip/" "yes" || check "archive.zip landed in zip/" "no"
[ -z "$(find "${sandbox}" -maxdepth 1 -type f ! -name organize.log)" ] && check "no loose files remain at the top level" "yes" || check "no loose files remain at the top level" "no"
echo "Testing logging ..."
if [ -f "${sandbox}/organize.log" ]; then
check "organize.log was created" "yes"
logged="$(grep -c "moved .* -> " "${sandbox}/organize.log")"
[ "${logged}" -ge 5 ] && check "log records at least 5 moves" "yes" || check "log records at least 5 moves" "no"
else
check "organize.log was created" "no"
check "log records at least 5 moves" "no"
fi
echo "Testing idempotency (second real run must move nothing) ..."
second_out="$(bash "${script}" "${sandbox}" 2>&1)"
check "second run exits successfully" "$([ $? -eq 0 ] && echo yes || echo no)"
echo "${second_out}" | grep -q "0 file(s) organized" && check "second run organizes 0 files" "yes" || check "second run organizes 0 files" "no"
echo "Confirming isolation ..."
[ -d "${work}" ] && check "all work stayed inside the temp sandbox" "yes" || check "all work stayed inside the temp sandbox" "no"
echo
echo "${checks} checks, ${failures} failure(s)."
[ "${failures}" -eq 0 ]
Troubleshooting
Troubleshooting — Day 014 lab
error: give one existing directory to organize
You ran the script without a target folder, or the folder does not exist.
Create it first and pass its path: mkdir -p ~/organize-demo then
bash examples/organize_files.sh --dry-run ~/organize-demo.
The dry run seems to move files
It must not — a dry run only prints. If files actually moved, you left off the
--dry-run flag or put it after the folder in a way your shell mis-parsed.
The flag can go before or after the folder, but check it is spelled exactly
--dry-run and is passed as its own argument.
A file with no extension (like readme) was not sorted
That is by design. Files with no dot in the name, and dotfiles such as
.gitkeep, are skipped so the script never invents a folder for them. Give
the file an extension if you want it sorted, or extend the script (see the
lesson's extension challenge).
photo.JPG and photo.jpg ended up in the same folder
Also by design: the script lowercases extensions, so both land in jpg/.
This keeps you from ending up with separate JPG/ and jpg/ folders.
A second run says 0 file(s) organized — did it fail?
No — that is the script being idempotent. After the first run the files already live in their subfolders, which the top-level scan does not descend into, so there is nothing left to move. Zero moves on a second run is the correct, safe result.
Permission denied when running the script
Run it through bash explicitly: bash examples/organize_files.sh .... You do
not need to chmod +x it. You never need sudo for this lab; if a tutorial
ever tells you to sudo a script you have not read, stop and read it first.
Windows: bash is not recognized
Use WSL (wsl --install, then open Ubuntu and follow the Linux path). The
script and tests run unchanged inside WSL.
I want to actually schedule it, and cron does nothing
Scheduling is intentionally outside this lab, but the usual cause is a relative
path: cron runs with a bare environment and an unpredictable working directory.
Always use absolute paths for both the script and the target folder in a
crontab line, and redirect output to a log (>> file 2>&1) so you can see what
happened. See examples/schedule-examples.md.
Security notes
Security notes — Day 014 lab
- The script only touches the directory you give it.
organize_files.shoperates strictly inside its target folder: it creates extension subfolders there and moves that folder's files into them. It makes no network connections, needs no elevated privileges, and changes no system setting. - This lab never edits your crontab. You read about cron, launchd, systemd
timers, and Task Scheduler, and you write schedules on paper in the
worksheet — but nothing here installs a scheduled job. If you later add one
yourself with
crontab -e, that is a deliberate, separate act, and you can remove it the same way. - Dry-run first. The script is destructive in the sense that it moves
files. Always preview with
--dry-runbefore a real run so you can see exactly what will move and where — the single most important habit for any move-or-delete automation. - Scheduled jobs run unattended, so they must be safe and logged. The
reason this script is idempotent (safe to run twice) and writes
organize.log(a record you can read the next morning) is precisely so it could be scheduled safely. Never schedule a script that lacks these properties, and never schedule one you have not read. - Quote everything; trust nothing. The script quotes every path variable so a filename containing a space or special character cannot break a command apart. When you extend it, keep that discipline — unquoted variables in a job that runs on arbitrary filenames are a real hazard.
- Privacy of logs.
organize.logrecords file names and timestamps, which are mildly revealing. It lives inside the folder you organized; keep it where only you can read it, and trim it if it grows.