Command-line options
getopts, long options, --help, -- and a dry-run that changes nothing.
tar -xzf bo-args.tar.gz, which creates bo-args/. SHA-256: 1eeb05d4e96395d949b696d78e08bd57d7d48d29d795015a07563ef6ee791dbeOps scripts need settings: how many days of logs to keep, whether to only show what they would do, which directories to work on. Options are how people pass those settings, and a script that handles them carelessly can do the wrong thing on a typo. In this lesson you will parse short options with getopts, parse long options such as --dry-run with a while and case loop, print --help, reject a bad command line with a message and status 2 before anything happens, make -- end the options, validate option values, and prove that a dry run changes nothing. It also sets out the exit-status contract this course uses for Bash scripts. The examples run in ~/bash-ops/args.
Options, operands and the exit-status contract
Most Unix commands follow the conventions in POSIX's Utility Syntax Guidelines. An option starts with a dash: -n. Some options take a value, called an option-argument: -d 7, or -d7 written together. Options without values can be grouped, so -nv means -n -v. Operands are the other arguments, usually files or directories. -- ends the options: everything after it is an operand, even when it starts with a dash. GNU tools add long options with two dashes (--dry-run, and --days 7 or --days=7 for a value) and also accept options after operands.
A script that follows these conventions should also follow an exit-status contract, so that the scheduler, pipeline or person running it can react without reading its messages:
getopts: short options
getopts is a shell builtin (POSIX, so it works in Dash too) that parses short options one at a time. It runs as the condition of a while loop, with an option string that lists the letters it accepts; a letter followed by : takes a value. This script only reports what it parsed:
#!/usr/bin/env bash# show-opts.sh: parse -n, -d DAYS and -h with getopts, then show what was parsed.dry_run=0days=14while getopts ':nd:h' opt; docase $opt inn) dry_run=1 ;;d) days=$OPTARG ;;h)printf 'usage: show-opts.sh [-n] [-d DAYS] DIR...\n'exit 0;;:)printf 'show-opts.sh: option -%s needs a value\n' "$OPTARG" >&2exit 2;;'?')printf 'show-opts.sh: unknown option -%s\n' "$OPTARG" >&2exit 2;;esacdoneshift $((OPTIND - 1))printf 'dry_run=%s days=%s, %s operand(s):' "$dry_run" "$days" "$#"printf ' [%s]' "$@"printf '\n'
':nd:h' accepts -n, -d VALUE and -h. Each pass of the loop puts the letter in opt and any value in OPTARG. The leading : switches getopts to silent mode: instead of printing its own error, it sets opt to : when a value is missing and to ? for an unknown option, with the letter in OPTARG, so the script prints its own message and exits 2. The '?' pattern is quoted because an unquoted ? in case matches any single character. When the options run out, getopts returns non-zero and the loop ends. OPTIND then holds the position of the first argument it did not process, so shift $((OPTIND - 1)) removes the options and leaves the operands in $@.
-nd7 was taken apart into -n and -d 7, and the operand with a space in it arrived whole.
Both mistakes produced a message on standard error and status 2, and the script did nothing else.
getopts stops at the first operand, as POSIX specifies, so in logs -n the -n became a second operand and dry_run stayed 0. -- ended the options, so the -n after it is an operand, which is how you pass a name that starts with a dash.
getopts has no long options. It read --dry-run as the option - (unknown) followed by more letters, and the script stopped. For scripts with only short options, getopts is the right tool; for long options, you write the loop yourself.
Long options with a while and case loop
prune-logs.sh deletes old .log files from the directories it is given. It takes short and long options and is long enough to read in three parts. Part 1 holds the help text and the error helper:
#!/usr/bin/env bash# prune-logs.sh: delete *.log files older than DAYS days directly inside each DIR.# Exit status: 0 done, 1 a directory was missing or a deletion failed, 2 usage error.usage() {cat <<'END'Usage: prune-logs.sh [OPTIONS] [--] DIR...Delete *.log files older than DAYS days directly inside each DIR.-d, --days DAYS age limit in whole days, 1 to 3650 (default 14)-n, --dry-run list what would be deleted; delete nothing-h, --help show this help and exitExit status: 0 done, 1 a directory was missing or a deletion failed, 2 usage error.END}usage_error() {printf 'prune-logs.sh: %s\n' "$1" >&2printf "Try 'prune-logs.sh --help' for more information.\n" >&2exit 2}
usage prints the help with a quoted here-document (<<'END', so nothing inside is expanded). usage_error prints the problem and a hint on standard error and exits 2; every command-line mistake goes through it, so they all look and behave alike. Part 2 is the parser:
days=14dry_run=0dirs=()while (( $# > 0 )); docase $1 in-h | --help)usageexit 0;;-n | --dry-run) dry_run=1 ;;-d | --days)(( $# >= 2 )) || usage_error "option $1 needs a value"days=$2shift;;--days=*) days=${1#--days=} ;;--)shiftdirs+=("$@")break;;-?*) usage_error "unknown option: $1" ;;*) dirs+=("$1") ;;esacshiftdone
The loop looks at $1, handles it, and shifts it away until no arguments remain. -n only sets a variable. For an option with a value, the branch first checks with (( $# >= 2 )) that a value follows, takes $2, and shifts once more so the value is not read as the next option. --days=* handles the --days=7 spelling with the # trimming from the previous lesson. -- adds every remaining argument to dirs as an operand. -?* (a dash and at least one more character) catches every other option as unknown. Anything else is an operand, collected in the dirs array.
Unlike getopts, this loop keeps reading options after an operand, as GNU tools do. For a command that deletes things this matters: someone who types prune-logs.sh logs --dry-run expects a dry run. A parser that stopped at logs would take --dry-run for a directory, delete the old logs in logs, and only then complain. The value check matters too, because shift refuses to shift more arguments than there are:
shift 2 with one argument left failed and shifted nothing, so $1 was still --days. A loop that ended its --days branch with a plain shift 2 would meet the same argument again, forever. The check turns that case into a usage error. Part 3 validates and does the work:
if ! [[ $days =~ ^[1-9][0-9]{0,3}$ ]] || (( days > 3650 )); thenusage_error "--days must be a whole number from 1 to 3650, not '$days'"fi(( ${#dirs[@]} > 0 )) || usage_error "no directory given"status=0for dir in "${dirs[@]}"; dopath=$dir[[ $path == /* ]] || path=./$path # find reads -old as a test even after --; ./-old is a pathif [[ ! -d $path ]]; thenprintf 'prune-logs.sh: not a directory: %s\n' "$dir" >&2status=1continuefiif (( dry_run )); thenfind "$path" -maxdepth 1 -type f -name '*.log' -mtime +"$days" -printf 'would delete %p\n'elsefind "$path" -maxdepth 1 -type f -name '*.log' -mtime +"$days" -printf 'deleting %p\n' -delete || status=1fidoneexit "$status"
The values are checked before anything uses them: --days must be a whole number from 1 to 3650, matched with a regular expression before (( )) sees it (the rule from "Exit status and tests"), and at least one directory must be given. find reads any argument that starts with - as the start of its search expression, even after --, so it would take a directory named -old for one of its tests; a relative path therefore gets ./ in front, the fix from the quoting lesson. -mtime +DAYS selects files whose age, rounded down to whole days, is more than DAYS, so a file must be at least DAYS+1 whole days old: with --days 14, a file 14.9 days old is kept and one 15.1 days old is deleted (the lab checked both). -maxdepth 1 stays in the directory itself, and -delete removes what matched. A missing directory is reported and skipped with status 1, so one bad operand does not stop the others. The whole file is the three parts in order. Now some test data, then the help:
The help goes to standard output with status 0, because the user asked for it: it can be piped into less or grep like any other output.
Usage errors stop the script before it acts
A usage error should stop the script before it touches anything, with a message that says what was wrong. Here are four common mistakes, each followed by its status:
Every mistake was caught before any file was touched, each with its own message, the same hint and status 2. The first one deserves attention: a typo in the very option that makes a run safe. A parser that skipped unknown options would have gone on and deleted files. logs --days shows the value check at work, now as a clean usage error. The status is the part a machine reads:
The message was thrown away, and the caller still knew what kind of failure it was: 2 says that retrying cannot help and a person must fix the command, while 1 would mean the job itself failed. Document every status in the script's header and in --help, as prune-logs.sh does, and never use 2 for a failure that running the job again could fix: 2 tells the caller that a person must change how the command is run. (Some tools, grep among them, also use 2 for an input they cannot use at all; the Python course does the same. The meaning for the caller does not change.)
--dry-run must change nothing
A dry run is only useful if it is honest: it must select exactly what a real run would, and change nothing. Check that instead of assuming it, by recording the files before and after and comparing:
diff found no difference, and the two files listed are the .log files older than 14 days. --dry-run came after the directory and still counted. old-notes.txt is just as old but is not a .log file, and the recent logs are too new.
The real run deleted exactly the files the dry run listed. Both runs use the same find selection and differ only in the last action, which is what keeps the dry run honest. A dry run with its own separate listing code can drift away from what the real run deletes. Finally, -- for a directory whose name starts with a dash:
Without --, -old was an unknown option: a usage error, and nothing happened. After -- it was a directory, and the script's own ./ kept find from misreading it.
One directory did not exist: the script said so, still processed the others, and exited 1. The job was only partly done, and the status says so.
Try this
A wrong pattern or --days value can match far more files than you meant. Add a safety brake to a copy of prune-logs.sh: -m N, --max N and --max=N (default 100). When more than N files match in a directory, delete nothing there, print prune-logs.sh: DIR: COUNT files match, more than --max N; deleting nothing to standard error, and set status 1. Validate the value with usage_error: a whole number from 1 to 999999, checked with a regular expression capped at six digits as --days is (^[1-9][0-9]{0,5}$), and add the option to the help text and to the exit-status lines. To count matches whatever the file names contain, run the same find with -printf '.' (one dot per file) and count the dots with wc -c, then test (( count > max )) before the dry-run branch. With three .log files older than 14 days in logs, one of them with a space in its name:
1. ./prune-logs.sh --max 2 logs: expect prune-logs.sh: logs: 3 files match, more than --max 2; deleting nothing, status 1, and all three files still there.
2. ./prune-logs.sh --max 0 logs: expect --max must be a whole number from 1 to 999999, not '0' (or your own wording), the hint, and status 2.
3. ./prune-logs.sh --max=5 logs: expect three deleting lines, status 0, and an empty logs.
Takeaway
Parse and validate every option before the script acts, send usage errors to standard error with status 2, keep 1 for a job that failed, and document each status. Give --dry-run the same selection as the real run so that it shows exactly what would happen and changes nothing, and accept -- so that any operand can be passed safely.
getopts ':nd:'. An operator runs ./cleanup.sh /var/app/logs -n, meaning a dry run, and the script deletes files. Why?prune-logs.sh does.getopts handles separate and grouped options alike; -n -d 7 worked in the lesson.: only switches error reporting to silent mode, so the script can print its own messages.d: takes a value and n does not.--day 7 instead of --days 7. What should the wrapper do with status 2?--dry-run branch prints would delete for every result of ls *.log, while the real branch runs find . -name '*.log' -mtime +14 -delete. What is wrong with this design?ls *.log lists such names (the glob passes them whole). The problem is which files are chosen, not how they are printed.