9 Automating Repetitive Dev Tasks
There’s a rule of thumb in software development: if you do something twice, you’ll do it a third time. If you do it a third time, you’ll do it a hundred times. The question isn’t whether a task is worth automating — it’s whether you’ll recognize the moment when it becomes worth it.
That moment usually arrives earlier than you think. The deployment sequence you run every afternoon. The database reset you perform before every test run. The five-command sequence to set up a new feature branch. The log-scraping you do every time something breaks in production. Each of these feels small in isolation. Together, they account for a surprising fraction of a developer’s day — and more importantly, they account for a surprising fraction of the errors that make days longer than they need to be.
This chapter is about eliminating that friction. We’ll cover shell scripts, aliases and functions, make as a task runner, environment management, and the patterns that separate automation that holds up over time from automation that creates more problems than it solves.
9.1 Shell Scripts: The Foundation
A shell script is a text file containing shell commands. That’s it. There’s no compilation step, no runtime to install, no dependency to manage. If a sequence of commands works in your terminal, it works in a shell script.
9.1.1 Anatomy of a shell script
#!/usr/bin/env bash
# Setup script for the development environment
set -euo pipefail
echo "Setting up development environment..."
# Install dependencies
npm install
# Copy environment template if .env doesn't exist
if [ ! -f .env ]; then
cp .env.example .env
echo "Created .env from template — please fill in your values"
fi
# Run database migrations
npm run db:migrate
echo "Setup complete."Three things every shell script should have:
The shebang line (#!/usr/bin/env bash) tells the operating system which interpreter to use. /usr/bin/env bash is more portable than /bin/bash because it finds bash wherever it’s installed on the system.
set -euo pipefail is a safety net: - -e exits immediately if any command fails - -u treats unset variables as errors - -o pipefail causes a pipeline to fail if any command in it fails (not just the last one)
Without these, a script will cheerfully continue running after a failure, often making things worse. With them, it stops at the first sign of trouble.
Comments explaining what each section does. Shell scripts have a reputation for being inscrutable — good comments are the antidote.
9.1.2 Making a script executable
chmod +x scripts/setup.sh # make executable
./scripts/setup.sh # run itOr without making it executable:
bash scripts/setup.sh9.1.3 Variables
#!/usr/bin/env bash
set -euo pipefail
# Constants
readonly DEPLOY_DIR="/var/www/app"
readonly LOG_FILE="/var/log/deploy.log"
# Variables
environment=${1:-development} # first argument, default to "development"
timestamp=$(date +%Y%m%d_%H%M%S)
branch=$(git rev-parse --abbrev-ref HEAD)
echo "Deploying branch $branch to $environment at $timestamp"readonly declares a constant — attempting to reassign it will cause an error. ${1:-development} uses the first command-line argument if provided, falling back to development if not. $(command) runs a command and captures its output.
9.1.4 Conditionals
# File existence checks
if [ -f config.yaml ]; then echo "Config exists"; fi
if [ ! -f config.yaml ]; then echo "Config missing"; fi
if [ -d logs/ ]; then echo "Logs directory exists"; fi
# String comparisons
if [ "$environment" = "production" ]; then
echo "Deploying to production — double-checking..."
fi
if [ -z "$API_TOKEN" ]; then
echo "Error: API_TOKEN is not set" >&2
exit 1
fi
if [ -n "$DEBUG" ]; then
echo "Debug mode enabled"
fi
# Numeric comparisons
if [ "$count" -gt 0 ]; then echo "Found $count items"; fi
if [ "$exit_code" -ne 0 ]; then echo "Command failed"; fiThe [ ] syntax is POSIX test syntax. Note the spaces inside the brackets — they’re required. [[ ]] is bash-specific but more powerful, supporting regex matching and logical operators without escaping:
if [[ "$branch" =~ ^feature/ ]]; then
echo "On a feature branch"
fi
if [[ "$status" == "200" && "$body" != "" ]]; then
echo "Success"
fi9.1.5 Loops
# Loop over files
for file in src/*.ts; do
echo "Processing $file"
npx tsc --noEmit "$file"
done
# Loop over an array
environments=("development" "staging" "production")
for env in "${environments[@]}"; do
echo "Checking $env config..."
diff "config/$env.yaml" "config/production.yaml" || true
done
# Loop over command output
git diff --name-only HEAD~1 | while read -r file; do
echo "Changed: $file"
done
# Numeric loop
for i in $(seq 1 10); do
echo "Attempt $i..."
curl -s https://api.example.com/health && break
sleep 5
done9.1.6 Functions
Functions let you organize scripts into reusable, named pieces:
#!/usr/bin/env bash
set -euo pipefail
# Logging helpers
log() { echo "[$(date +%H:%M:%S)] $*"; }
error() { echo "[ERROR] $*" >&2; }
die() { error "$*"; exit 1; }
# Check required tools are installed
require() {
command -v "$1" &>/dev/null || die "$1 is required but not installed"
}
# Wait for a service to become available
wait_for() {
local url=$1
local max_attempts=${2:-30}
local attempt=1
log "Waiting for $url..."
while ! curl -sf "$url" &>/dev/null; do
if [ $attempt -ge $max_attempts ]; then
die "Timed out waiting for $url"
fi
sleep 2
((attempt++))
done
log "$url is ready"
}
# Main script
require curl
require jq
require node
wait_for "http://localhost:3000/health"
log "Starting deployment..."The log, error, and die helpers are worth having in every script. They make output consistent and ensure errors go to stderr (>&2) rather than stdout, which matters when the script’s output is piped to other commands.
9.1.7 Error handling
# Run a command, capture exit code without triggering set -e
result=$(some_command) || exit_code=$?
if [ "${exit_code:-0}" -ne 0 ]; then
echo "Command failed with code $exit_code"
fi
# Cleanup on exit
cleanup() {
log "Cleaning up..."
rm -f /tmp/deploy_lock
# kill any background processes
jobs -p | xargs -r kill 2>/dev/null || true
}
trap cleanup EXIT
# Create lock file to prevent concurrent runs
LOCK_FILE="/tmp/deploy_lock"
if [ -f "$LOCK_FILE" ]; then
die "Deployment already in progress (lock file exists: $LOCK_FILE)"
fi
touch "$LOCK_FILE"The trap command runs a function when the script exits — whether normally, due to an error, or from a signal. It’s the shell equivalent of a finally block, and it’s essential for cleaning up temporary files, releasing locks, and terminating background processes.
9.2 Aliases and Functions: Instant Shortcuts
Shell scripts live in files and need to be called explicitly. Aliases and shell functions live in your shell configuration and are available instantly in any terminal session.
9.2.1 Aliases
An alias is a simple text substitution — a short name that expands to a longer command:
# In ~/.zshrc or ~/.bashrc
# Navigation
alias ..="cd .."
alias ...="cd ../.."
alias ll="ls -lah"
alias lt="ls -laht" # sorted by modification time
# Git
alias gs="git status -s"
alias ga="git add"
alias gc="git commit -m"
alias gp="git push"
alias gpl="git pull --rebase"
alias gl="git log --oneline --graph --all --decorate"
alias gd="git diff"
alias gds="git diff --staged"
# Docker
alias dps="docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'"
alias dlog="docker logs -f"
# Development
alias serve="python3 -m http.server 8080"
alias myip="curl -s https://ipinfo.io/ip"
alias week="date +%V" # current week number
# Safety nets
alias rm="rm -i" # confirm before deleting
alias cp="cp -i" # confirm before overwriting
alias mv="mv -i" # confirm before overwritingAfter editing your shell config, reload it:
source ~/.zshrc # or source ~/.bashrc9.2.2 Shell functions
When you need more than simple substitution — arguments, conditionals, multiple commands — use a function:
# Create a directory and cd into it
mkcd() {
mkdir -p "$1" && cd "$1"
}
# Find and kill a process by name
killport() {
local port=$1
local pid=$(lsof -ti tcp:"$port")
if [ -z "$pid" ]; then
echo "No process on port $port"
else
kill -9 $pid
echo "Killed process $pid on port $port"
fi
}
# Git: create a branch, push it, and set upstream in one step
gbc() {
git switch -c "$1" && git push -u origin "$1"
}
# Quick git commit with message
gcm() {
git add -A && git commit -m "$*"
}
# Extract any archive format
extract() {
case "$1" in
*.tar.gz|*.tgz) tar -xzf "$1" ;;
*.tar.bz2|*.tbz2) tar -xjf "$1" ;;
*.tar.xz) tar -xJf "$1" ;;
*.zip) unzip "$1" ;;
*.gz) gunzip "$1" ;;
*.bz2) bunzip2 "$1" ;;
*.7z) 7z x "$1" ;;
*) echo "Unknown archive format: $1" ;;
esac
}
# Search for a process
psg() {
ps aux | grep -v grep | grep "$1"
}
# HTTP status code lookup
httpcode() {
curl -s -o /dev/null -w "%{http_code}" "$1"
}9.2.3 Organizing your shell configuration
As your aliases and functions grow, keeping them all in .zshrc or .bashrc becomes unwieldy. A cleaner pattern:
# In ~/.zshrc
for file in ~/.config/shell/*.sh; do
[ -r "$file" ] && source "$file"
doneThen organize into separate files:
~/.config/shell/
|-- aliases.sh
|-- functions.sh
|-- git.sh
|-- docker.sh
`-- work.sh # work-specific shortcuts (not in dotfiles repo)
This approach also makes it easy to share your shell config as a dotfiles repository — a common practice among developers that lets you replicate your environment on a new machine in minutes.
9.3 make: The Underrated Task Runner
make was created in 1976 to automate C compilation. Fifty years later, it’s one of the best task runners available for any language or project type — not because of its build features, but because of its interface.
Every make target is a named task you can run with make <target>. The targets are defined in a Makefile in the project root. Any developer can run make help (if you write a help target) and see every available task. The interface is always the same, regardless of whether the project is Node, Python, Go, or something else.
9.3.1 A practical Makefile for a modern project
# Makefile
.PHONY: help install dev build test lint format clean deploy
# Default target: show help
help:
@echo "Available targets:"
@echo " install Install dependencies"
@echo " dev Start development server"
@echo " build Build for production"
@echo " test Run test suite"
@echo " lint Run linter"
@echo " format Format code"
@echo " clean Remove build artifacts"
@echo " deploy Deploy to staging"
install:
npm install
dev:
npm run dev
build:
npm run build
test:
npm test
lint:
npx eslint src/ --ext .ts,.tsx
format:
npx prettier --write src/
clean:
rm -rf dist/ node_modules/.cache
# Run checks before committing
check: lint test
@echo "All checks passed"
# Deploy with confirmation for production
deploy:
@read -p "Deploy to staging? [y/N] " confirm && \
[ "$$confirm" = "y" ] && npm run deploy:staging || echo "Aborted"9.3.2 Key Makefile concepts
.PHONY declares targets that aren’t actual files. Without it, make checks whether a file named test exists and skips running the target if it does. Any target that doesn’t produce a file of the same name should be listed in .PHONY.
Indentation with tabs — this is a notorious Make gotcha. Recipe lines must be indented with a tab character, not spaces. Many editors convert tabs to spaces automatically; if you’re getting “missing separator” errors, this is usually why.
@ prefix suppresses command echoing. Without @, make prints each command before running it. Prefixing with @ runs it silently — useful for echo statements and prompts that would look redundant alongside the command itself.
$$ is how you write a literal $ in a Makefile recipe — the first $ escapes the second.
9.3.3 Variables in Makefiles
# Variables
APP_NAME := myapp
VERSION := $(shell git describe --tags --always)
BUILD_DIR := dist
DOCKER_IMAGE := $(APP_NAME):$(VERSION)
build:
@echo "Building $(APP_NAME) version $(VERSION)"
npm run build
@echo "Output in $(BUILD_DIR)"
docker-build:
docker build -t $(DOCKER_IMAGE) .
@echo "Built $(DOCKER_IMAGE)"
docker-push: docker-build
docker push $(DOCKER_IMAGE)9.3.4 Target dependencies
Targets can depend on other targets, which run first:
# docker-push depends on docker-build, which runs automatically
docker-push: docker-build
docker push $(DOCKER_IMAGE)
# release runs lint, test, build in sequence
release: lint test build
@echo "Ready to release version $(VERSION)"9.3.5 Environment-specific targets
ENV ?= development # default, overridable with ENV=staging make deploy
deploy:
@echo "Deploying to $(ENV)"
./scripts/deploy.sh $(ENV)make deploy # deploys to development
ENV=staging make deploy # deploys to staging
ENV=production make deploy # deploys to production9.3.6 A self-documenting Makefile
A neat trick for auto-generating help output from comments:
.PHONY: help
help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
install: ## Install dependencies
npm install
test: ## Run test suite
npm test
build: ## Build for production
npm run build
deploy: ## Deploy to staging
./scripts/deploy.sh stagingAny target with a ## comment will appear in make help with its description, automatically formatted in a two-column layout. As the project grows, the help output grows with it — with no manual maintenance.
9.4 Environment Management
Automation breaks in subtle ways when environment variables are wrong — missing API keys, wrong database URLs, development credentials used in production. Good environment management prevents this class of problem.
9.4.1 Loading environment variables
# Simple: export from .env file
export $(cat .env | grep -v '^#' | xargs)
# Safer: handle spaces in values
set -a; source .env; set +a
# With dotenv tools
# direnv (https://direnv.net/) — loads .envrc automatically when you cd into a directory
# autoenv — similar to direnvdirenv deserves special mention. You define environment variables in a .envrc file in your project directory, and direnv automatically loads them when you enter the directory and unloads them when you leave. No manual source .env step, no risk of forgetting:
# .envrc
export DATABASE_URL="postgres://localhost:5432/myapp_dev"
export API_KEY="dev-key-not-for-production"
export NODE_ENV="development"brew install direnv
echo 'eval "$(direnv hook zsh)"' >> ~/.zshrc # add to shell
direnv allow . # approve the .envrc file9.4.2 Validating required variables
A function to check that required environment variables are set before running a script:
require_env() {
local missing=0
for var in "$@"; do
if [ -z "${!var:-}" ]; then
echo "Error: required environment variable $var is not set" >&2
missing=1
fi
done
[ $missing -eq 0 ] || exit 1
}
# Usage at the top of scripts
require_env DATABASE_URL API_TOKEN SLACK_WEBHOOK_URL9.4.3 Managing multiple environments
# scripts/env.sh — central environment loader
load_env() {
local env=${1:-development}
local env_file=".env.$env"
if [ ! -f "$env_file" ]; then
echo "Error: $env_file not found" >&2
exit 1
fi
set -a
source "$env_file"
set +a
echo "Loaded environment: $env"
}
# Usage
load_env production
load_env staging9.4.4 Secrets management
For real applications, secrets shouldn’t live in .env files — they should live in a secrets manager. But you can still access them from the command line:
# AWS Secrets Manager
secret=$(aws secretsmanager get-secret-value \
--secret-id "myapp/production/database" \
--query SecretString \
--output text | jq -r '.password')
# HashiCorp Vault
export DATABASE_PASSWORD=$(vault kv get -field=password secret/myapp/database)
# GitHub CLI (for CI/CD secrets during development)
gh secret set API_TOKEN < api_token.txt9.5 Scheduling and Background Tasks
9.5.1 cron: scheduled execution
cron runs commands on a schedule. Edit your crontab with:
crontab -eThe format is five time fields followed by the command:
# Minute Hour Day Month Weekday Command
0 9 * * 1-5 /home/alice/scripts/daily-report.sh
*/15 * * * * /home/alice/scripts/health-check.sh
0 0 1 * * /home/alice/scripts/monthly-cleanup.sh
0 2 * * * /home/alice/scripts/backup.sh >> /var/log/backup.log 2>&1
*/15 means “every 15 minutes.” 1-5 for the weekday field means Monday through Friday. * means “every.”
Always use absolute paths in crontab entries — cron runs with a minimal environment and PATH may not include your usual directories. Always redirect output to a log file, otherwise cron will try to email it to you (which usually means it disappears silently).
A quick cron expression reference:
# Every minute
* * * * *
# Every hour at :30
30 * * * *
# Every day at 2am
0 2 * * *
# Every Monday at 9am
0 9 * * 1
# Every 5 minutes
*/5 * * * *
9.5.2 Running scripts in the background
# Run in background, continue even after terminal closes
nohup ./scripts/long-running.sh &
# Run with output captured
nohup ./scripts/long-running.sh > logs/output.log 2>&1 &
# Check what's running in background
jobs -l
# Bring background job to foreground
fg %1
# Disown a job (detach from terminal)
./scripts/long-running.sh &
disown $!9.5.3 watch: repeated execution
watch runs a command repeatedly and displays the output, refreshing every two seconds by default:
watch -n 5 "curl -s https://api.example.com/health | jq '.status'"
watch -n 1 "git log --oneline -5"
watch -n 2 "docker ps --format 'table {{.Names}}\t{{.Status}}'"This is useful for monitoring live output without writing a full polling loop. Ctrl+C stops it.
9.6 A Practical Automation Toolkit
Here’s a collection of scripts that solve common development automation problems, ready to adapt:
9.6.1 Pre-commit checks
#!/usr/bin/env bash
# scripts/pre-commit.sh — run before every commit
set -euo pipefail
log() { echo "> $*"; }
log "Running pre-commit checks..."
# Lint
log "Linting..."
npx eslint src/ --ext .ts,.tsx --quiet
# Type check
log "Type checking..."
npx tsc --noEmit
# Tests
log "Running tests..."
npm test -- --passWithNoTests
# Check for secrets (basic)
log "Checking for secrets..."
if git diff --cached | grep -E "(api_key|password|secret)\s*=\s*['\"][^'\"]{8,}" -i; then
echo "[WARN] Possible secret detected in staged changes" >&2
exit 1
fi
log "All checks passed [OK]"Wire this to Git’s pre-commit hook:
cp scripts/pre-commit.sh .git/hooks/pre-commit
chmod +x .git/hooks/pre-commitOr use husky for a more robust hook management solution if you’re working on a team.
9.6.2 Database reset script
#!/usr/bin/env bash
# scripts/db-reset.sh — reset database to clean state
set -euo pipefail
log() { echo "[db-reset] $*"; }
: "${DATABASE_URL:?DATABASE_URL must be set}"
# Confirm if running against a non-development database
if [[ "$DATABASE_URL" != *"localhost"* ]]; then
read -rp "WARNING: This doesn't look like a local database. Reset $DATABASE_URL? [y/N] " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || { log "Aborted"; exit 0; }
fi
log "Dropping database..."
npm run db:drop 2>/dev/null || true
log "Creating database..."
npm run db:create
log "Running migrations..."
npm run db:migrate
log "Seeding..."
npm run db:seed
log "Done — database reset complete"9.6.3 New feature branch setup
#!/usr/bin/env bash
# scripts/new-feature.sh — set up a new feature branch
set -euo pipefail
if [ -z "${1:-}" ]; then
echo "Usage: $0 <feature-name>"
exit 1
fi
FEATURE_NAME=$1
BRANCH_NAME="feature/$FEATURE_NAME"
# Ensure main is up to date
git switch main
git pull --rebase origin main
# Create and push the branch
git switch -c "$BRANCH_NAME"
git push -u origin "$BRANCH_NAME"
echo "Created and pushed branch: $BRANCH_NAME"
echo "You're now on $BRANCH_NAME, ready to work"9.6.4 Deployment script with rollback
#!/usr/bin/env bash
# scripts/deploy.sh — deploy with automatic rollback on failure
set -euo pipefail
ENV=${1:-staging}
log() { echo "[deploy:$ENV] $*"; }
die() { log "ERROR: $*" >&2; exit 1; }
require_env DATABASE_URL API_TOKEN
PREVIOUS_VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "unknown")
CURRENT_VERSION=$(git describe --tags --always)
log "Deploying $CURRENT_VERSION (previous: $PREVIOUS_VERSION)"
# Run health check to verify current state
log "Pre-deploy health check..."
curl -sf "https://$ENV.example.com/health" || die "Pre-deploy health check failed"
# Deploy
log "Running migrations..."
npm run db:migrate
log "Deploying application..."
npm run deploy:"$ENV"
# Post-deploy health check
log "Post-deploy health check..."
sleep 10
if ! curl -sf "https://$ENV.example.com/health"; then
log "Health check failed — initiating rollback"
git revert HEAD --no-edit
npm run deploy:"$ENV"
die "Deployment failed — rolled back to previous version"
fi
log "Deployment successful [OK]"
log "Version $CURRENT_VERSION is live on $ENV"9.7 Putting It Together: A Complete Developer Automation Setup
Here’s how all these pieces fit together into a complete project automation setup:
project/
|-- Makefile # top-level task runner
|-- scripts/
| |-- setup.sh # first-run environment setup
| |-- dev.sh # start full development stack
| |-- pre-commit.sh # pre-commit quality checks
| |-- db-reset.sh # database reset
| |-- new-feature.sh # branch creation workflow
| `-- deploy.sh # deployment with health checks
|-- .env.example # template for environment variables
|-- .envrc # direnv config (not committed if contains secrets)
`-- .git/hooks/
`-- pre-commit -> ../../scripts/pre-commit.sh
The Makefile ties it all together as the single entry point:
.PHONY: help setup dev test lint build deploy reset
help: ## Show available commands
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
setup: ## Set up development environment from scratch
./scripts/setup.sh
dev: ## Start development server
./scripts/dev.sh
test: ## Run test suite
npm test
lint: ## Run linter and type checker
npx eslint src/ && npx tsc --noEmit
build: ## Build for production
npm run build
reset: ## Reset database to clean state
./scripts/db-reset.sh
feature: ## Create a new feature branch (usage: make feature NAME=my-feature)
./scripts/new-feature.sh $(NAME)
deploy: ## Deploy to staging
./scripts/deploy.sh staging
deploy-prod: ## Deploy to production
./scripts/deploy.sh productionAny developer on the team runs make setup to get started, make dev to start working, and make deploy to ship. The underlying complexity — environment loading, health checks, rollback logic — is hidden behind a consistent interface that never changes.
9.8 Chapter Summary
Automation is one of the highest-leverage investments you can make in your development workflow. A shell script that takes an hour to write but saves five minutes a day pays for itself in less than two weeks — and unlike manual processes, it doesn’t vary, doesn’t forget steps, and can be reviewed and improved over time.
The key habits to build:
- Start every shell script with
#!/usr/bin/env bashandset -euo pipefail - Use
trap cleanup EXITto ensure cleanup happens even when scripts fail - Add a
Makefileto every project as the standard entry point for common tasks - Use
direnvto manage project-specific environment variables automatically - Validate required environment variables at the top of scripts before doing anything
- Write
make helpor a help target in everyMakefile— future team members will thank you - Use
trap, locks, and health checks in deployment scripts to make failures recoverable
9.9 Exercises
1. Write a setup.sh script for a project you currently work on. It should install dependencies, copy .env.example to .env if it doesn’t exist, run database migrations, and print a success message. Make it idempotent — safe to run multiple times.
2. Add a Makefile to a project you work on with at least five targets: install, dev, test, lint, and clean. Add ## comments to each target and implement a self-documenting help target.
3. Write a shell function called newpr that: switches to main, pulls the latest changes, creates a new branch based on a name argument, and pushes it to origin with upstream tracking set. Add it to your shell config.
4. Set up direnv for a project. Move your .env contents to .envrc and verify that variables load automatically when you enter the directory and unload when you leave.
5. Write a deployment script for any project that includes: a pre-deploy health check, the deployment step itself, a post-deploy health check, and a rollback step that runs if the post-deploy check fails.
9.10 Quick Reference
| Command / Pattern | What it does |
|---|---|
#!/usr/bin/env bash |
Portable shebang line |
set -euo pipefail |
Exit on error, undefined vars, pipe failures |
${VAR:-default} |
Variable with fallback default |
${VAR:?error message} |
Variable or die with message |
$(command) |
Command substitution |
trap cleanup EXIT |
Run cleanup function on exit |
[ -f file ] |
Test if file exists |
[ -z "$VAR" ] |
Test if variable is empty |
[[ "$str" =~ regex ]] |
Regex match in bash |
source .env |
Load environment file |
set -a; source .env; set +a |
Load .env and export all variables |
make <target> |
Run a Makefile target |
.PHONY: target |
Declare target as not a file |
## comment |
Self-documenting Makefile target |
watch -n 5 "command" |
Repeat command every 5 seconds |
nohup cmd > log.txt 2>&1 & |
Run in background, capture output |
crontab -e |
Edit scheduled tasks |
0 2 * * * |
Cron: daily at 2am |
*/15 * * * * |
Cron: every 15 minutes |