Bash backup() Function — Files and Directories
Shell function that creates timestamped backups of both files (cp) and directories (zip), with extension preservation, exclusion lists, and force-overwrite flag.
Why / When to Use
Quick local backup before risky edits. Works for single config files and full project directories. Add to .zshrc or .bashrc.
Core Concept / Commands
backup() {
local FORCE=false
_backup_help() {
cat <<'EOF'
backup — create timestamped backup of a file or directory
USAGE
backup <path>
backup <path> <output>
OPTIONS
-f, --force overwrite existing backup
-h, --help show help
DEFAULT OUTPUT
directory → <name>_backup_YYYYMMDD_HHMM.zip
file → <name>_backup_YYYYMMDD_HHMM.<ext> (copy)
EXAMPLES
backup jellyfish/
backup jellyfish/ release.zip
backup config.yaml
backup -f config.yaml
EOF
}
while [[ "$1" == -* ]]; do
case "$1" in
-f|--force) FORCE=true ;;
-h|--help) _backup_help; return 0 ;;
*) echo "Unknown option: $1"; return 1 ;;
esac
shift
done
[[ -z "$1" ]] && { echo "Error: path required"; _backup_help; return 1; }
local TARGET="$1"
[[ ! -e "$TARGET" ]] && { echo "Error: '$TARGET' does not exist"; return 1; }
local BASENAME TIMESTAMP
BASENAME="$(basename "${TARGET%/}")"
TIMESTAMP="$(date +"%Y%m%d_%H%M")"
if [[ -f "$TARGET" ]]; then
local NAME="${BASENAME%.*}"
local EXT="${BASENAME##*.}"
local DEST
if [[ "$EXT" == "$BASENAME" ]]; then
DEST="${2:-${NAME}_backup_${TIMESTAMP}}"
else
DEST="${2:-${NAME}_backup_${TIMESTAMP}.${EXT}}"
fi
[[ -f "$DEST" && "$FORCE" != true ]] && { echo "Error: '$DEST' already exists (use -f to overwrite)"; return 1; }
echo "Creating backup:"; echo " Source : $TARGET"; echo " Output : $DEST"; echo
cp "$TARGET" "$DEST"
local STATUS=$?
elif [[ -d "$TARGET" ]]; then
local ZIPFILE="${2:-${BASENAME}_backup_${TIMESTAMP}.zip}"
[[ -f "$ZIPFILE" && "$FORCE" != true ]] && { echo "Error: '$ZIPFILE' already exists (use -f to overwrite)"; return 1; }
local EXCLUDES=(
"*/.git/*" "*/.venv/*" "*/venv/*" "*/node_modules/*" "*/__pycache__/*"
"*/.pytest_cache/*" "*/.mypy_cache/*" "*.pyc" "*.pyo" "*.DS_Store"
"*/.next/*" "*/.npm/*" "*/.cache/*" "*/cache/*" "*/tmp/*"
"*/runtime/*" "*/sessions/*" "*/.claude/*/*" "*/test-artifacts/*"
)
local -a EXCLUDE_ARGS=()
for p in "${EXCLUDES[@]}"; do EXCLUDE_ARGS+=(-x "$p"); done
echo "Creating backup:"; echo " Source : $TARGET"; echo " Output : $ZIPFILE"; echo
zip -r "$ZIPFILE" "$TARGET" "${EXCLUDE_ARGS[@]}"
local STATUS=$?
fi
[[ $STATUS -eq 0 ]] && echo "✓ Backup created: ${DEST:-$ZIPFILE}" || echo "✗ Backup failed"
return $STATUS
}Key Options / Variants
backup config.yaml→config_backup_20260601_1430.yamlbackup config.yaml my-config.yaml→ custom output filenamebackup -f config.yaml→ overwrite existing backupbackup project/→project_backup_20260601_1430.zip(excludes node_modules, .git, .next, etc.)- Files with no extension (e.g.
Makefile) get suffix without a dot:Makefile_backup_20260601_1430
Gotchas
- Extension detection:
EXT="${BASENAME##*.}"equalsBASENAMEwhen there’s no dot — the condition[[ "$EXT" == "$BASENAME" ]]catches this and omits the dot in the output name. - The exclusion list for directories covers most common project junk; extend
EXCLUDESarray as needed.
Source
Conversation “Extending backup function to support files” — 2026-06-01