Short commands for everyday jobs: finding files and text, checking processes, disks and ports, packing archives, testing network endpoints, git housekeeping, reading JSON and reusing shell history. Each sits on one line with a comment above it saying what it does.
Read the comment above each command before you run it. Several of these delete, kill or reset things: run the dry-run variant first when there is one (find … -print instead of -delete, git clean -n instead of -f, du before rm), and only then the version that makes changes. Commands assume bash or zsh on a Unix box; where
macOS and Linux differ (they mostly disagree about sed -i and
sort -h), the note says so.
Files and text
# List .log files older than 7 days under /var/log. To delete them, check the list first, then swap -print for -delete.
find /var/log -name '*.log' -mtime +7 -print
# Search every file in this tree for TODO, showing filename and line number
grep -rn 'TODO' .
# Replace allow=1 with allow=2 in place, keeping config.ini.bak (-i.bak works on GNU and macOS sed; no backup: GNU sed -i, macOS sed -i '')
sed -i.bak 's/allow=1/allow=2/' config.ini
# Print only the second column of a CSV (splits on commas, so "quoted,commas" will bite you)
awk -F, '{print $2}' data.csv
# Count duplicate lines and show the most common ones first
sort names.txt | uniq -c | sort -rn
# Show a config file without the comment lines and blank lines
grep -vE '^\s*(#|$)' app.conf
# List files in this tree modified in the last 24 hours (swap to -mtime +7 for "older than a week")
find . -type f -mtime -1 -print
# Make every .sh script in the tree executable in one pass — {} batches them into a single chmod
find . -type f -name '*.sh' -exec chmod +x {} +
# Collapse duplicate lines to one distinct value each — sort | uniq folded into one flag
sort -u names.txt
# Add up every number in the first column of a file and print the total
awk '{s+=$1} END {print s}' nums.txt
# Count the lines in a file
wc -l names.txt
# Show what changed between two files: minus lines left, plus lines arrived
diff -u old.conf new.conf
# What kind of file this is, from the bytes, not the extension
file mystery.bin
# Make a unique temp file and print its path. The file is empty until you write to it.
mktemp
# Follow a log as it grows. Ctrl-C stops the follow. The file stays.
tail -n 40 -f app.log
Compare lists
Set operations on plain text: lines in both lists, lines in only one, the difference between two commands' output, two files matched on a shared key, and a list folded onto one line. None of these change the input files. The <(...) form needs bash, zsh or ksh93, not plain sh.
# Lines that appear in both lists (comm only works on sorted input, so each side is sorted on the way in)
comm -12 <(sort inventory.txt) <(sort monitored.txt)
comm prints three columns (only in the first, only in the second, in both), and -1/-2/-3 hide the ones you don't want. GNU comm warns about unsorted input. macOS comm doesn't check, so unsorted input just gives wrong results. It does have -i for case-insensitive matching, which GNU lacks. Run sort and comm under the same locale (e.g. LC_ALL=C on both), or matches go missing. A line repeated in one file counts each time, so use sort -u if you want sets.
# Lines in the first list that are missing from the second (swap to -13 for the other way round)
comm -23 <(sort inventory.txt) <(sort monitored.txt)
# Compare two commands' output without temp files: here, which files differ between two directories by name
diff <(ls old) <(ls new)
Each <(...) hands diff a path like /dev/fd/63 to read the command's output from. Exit status is 1 when they differ. Add -u for the unified format.
# Match two CSVs on their first column; headers are dropped first, otherwise "host" gets sorted in with the data
join -t, <(tail -n +2 hosts.csv | sort -t, -k1,1) <(tail -n +2 owners.csv | sort -t, -k1,1)
Both inputs must be sorted on the key, under the same locale as join. It prints one line per matching pair, so a key repeated in both files multiplies. Use -v1 for rows with no match in the second file, and -a1 -a2 -e NONE -o 0,1.2,2.2 to keep every row (with the same tail -n +2 inputs, so the header doesn't land mid-output). GNU join has --header. macOS join doesn't, which is why the headers are stripped with tail. -t, doesn't understand quoted fields: use miller for real CSV.
# Fold a list onto one line with commas between items, e.g. to pass hosts to a tool that wants a,b,c
comm -12 <(sort inventory.txt) <(sort monitored.txt) | paste -sd, -
Keep the trailing -: macOS paste needs a file argument, and - means standard input. -d cycles through its characters, so -d ', ' alternates comma and space instead of giving ", ".
| Command | bash | zsh | ksh93 | POSIX sh (dash, mksh) |
|---|---|---|---|---|
comm -12 <(sort a) <(sort b) | yes | yes | yes | no |
comm -23 <(sort a) <(sort b) | yes | yes | yes | no |
diff <(cmd1) <(cmd2) | yes | yes | yes | no |
join -t, <(… sort …) <(… sort …) | yes | yes | yes | no |
paste -sd, - | yes | yes | yes | yes |
Tested with GNU coreutils 9.7, diffutils 3.10, bash 5.2.37, zsh 5.9 and ksh93u+m on Debian 13. macOS notes come from the BSD man pages. Related: files and text, column, miller, zsh cheat sheet.
Processes
# Find the line for a process by name without grep matching itself (the bracket trick)
ps aux | grep '[s]leep 3333'
# Get just the PID of whatever matches a full command line
pgrep -f 'sleep 3333'
# Pause a process without killing it (stays alive but frozen — resume with kill -CONT <pid>)
kill -STOP <pid>
# See which process is listening on port 3000 (name and PID)
lsof -i :3000
# Kill whatever is squatting on port 3000, no questions asked
kill -9 $(lsof -t -i:3000)
# Kill every process whose full command line matches a pattern
pkill -f 'sleep 3333'
# Show the ten processes eating the most memory right now
ps aux | sort -rk 4 | head -10
# Top CPU eaters by PID and command, sorted by the process table itself (macOS/BSD: -r sorts by CPU; on Linux use --sort=-pcpu)
ps -eo pcpu,pid,comm -r | head -5
# Inventory every TCP port currently listening, with the owning process (-n -P keep IPs and ports numeric)
lsof -iTCP -sTCP:LISTEN -n -P
Disk space
# Human-readable size of every item in the current directory, biggest first
du -sh * | sort -rh
# Same idea, one level deep: which subdirectory is the space hog
du -h -d 1 . | sort -rh | head -20
# Disk free space in human units, one line per mount
df -h
# Find every file over 100MB under the current tree (dry run — nothing is deleted)
find . -type f -size +100M
# Check whether you're running out of inodes, not disk space (df -h lies to you here)
df -i
Docker
Housekeeping for a Docker host: see where the disk went, look before you prune, follow recent logs, take one snapshot of resource use, and pull single fields out of inspect. Only the prune changes anything.
# How much disk images, containers, volumes and build cache use, and how much a prune could free
docker system df
RECLAIMABLE is what no container uses. Add -v for a per-image table whose CONTAINERS column shows which images are unused (0). Build cache is separate: docker builder prune (or docker system prune) clears it, and image prune doesn't touch it.
# No dry run for prune, so look first. In Docker 29's default view, images without U (in use) in EXTRA have no container, running or stopped, and would go with -a. So would untagged images, which only show with -a.
docker image ls -a
# Remove every image no container uses, except ones labelled keep=true
docker image prune -a --filter 'label!=keep=true'
Plain docker image prune only removes dangling images (check with docker image ls --filter dangling=true). -a also removes images you pulled on purpose. A label!= filter can't be previewed with docker image ls, so list first. U is the default view since Docker 29.0 (on 27 and 28 only with the experimental --tree), and -q, --format and similar flags switch back to the old output without it: otherwise use the CONTAINERS column of docker system df -v. With the containerd image store, update to 29.4.1 or later before trusting label!=.
# Show the last 10 minutes of a container's log and keep following it (Ctrl-C to stop)
docker logs --since 10m -f web
--since takes 30s, 10m or a timestamp. Use --tail 3 to limit by lines instead, and -t to add the daemon's UTC timestamp to each line.
# One snapshot of CPU, memory and process count, then exit (no live screen); leave off the names for all running containers
docker stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.PIDs}}' web
Drop table for bare lines without a header. The LIMIT is the host's memory unless the container has its own limit.
# State, start time, restart count and restart policy for several containers, one line each
docker inspect --format '{{.Name}} {{.State.Status}} started={{.State.StartedAt}} restarts={{.RestartCount}} policy={{.HostConfig.RestartPolicy.Name}}' web <container>
Use {{index .Config.Labels "key"}} to read a label, including keys with dashes or dots. Keep templates in single quotes.
Tested with Docker Engine 29.9.2 on Debian 12 (aarch64). Related: Docker cheat sheet, disk space, lazydocker.
Archives
# Tarball a directory but never pack the junk: logs and node_modules are excluded at creation
tar -czf site-backup.tar.gz --exclude='*.log' --exclude='node_modules' site/
# Stream a gzip tarball to stdout — pipe it over ssh or straight into a second tar to list what's inside
tar -czf - site/ | tar -tz
# Peek inside a .tar.gz without unpacking it (drop the z for plain .tar)
tar -tzf site-backup.tar.gz
Network
# Get just the HTTP status code from a URL — 200, 404, 500 — and nothing else
curl -so /dev/null -w '%{http_code}\n' https://example.com
# Fetch your public IP address as plain text
curl -s ifconfig.me
# Resolve a hostname to its IP addresses, short and clean
dig +short example.com
# Mirror a directory from one machine to another, resumable, with a progress bar
rsync -avz --partial --progress src/ user@host:/srv/src/
# Jump through a bastion host to reach a machine that isn't publicly reachable
ssh -J user@jumphost user@target
# Reuse one SSH connection for the next 10 minutes so logins stop costing 3 seconds each
ssh -o ControlMaster=auto -o ControlPath=/tmp/ssh-%r@%h:%p -o ControlPersist=10m user@host
# Is a host:port reachable? Exit 0 means open, 1 means refused — wire it straight into an if
nc -z -w 2 example.com 443; echo $?
# Smoke-test an endpoint in one line: HTTP status and total time, body thrown away
curl -s -o /dev/null -w 'HTTP %{http_code} in %{time_total}s\n' https://example.com
# When does a site's TLS certificate expire? Handshake noise dropped, only the dates printed
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | openssl x509 -noout -dates
ssh
Checks for when ssh does something you didn't expect: reach a host behind a bastion with no config at all, see which settings ssh will really use, test a connection from a script without it hanging on a prompt, run a remote command and process the output locally, and look after a shared connection.
# Reach a host behind a bastion in one command, ignoring every config file (-F none), user given per hop
ssh -F none -J lab@bastion.example.com lab@target.example.com hostname
ssh passes -F and -v to the jump host, but -i, -p and -o apply to the target only: put the bastion's user and port in the -J argument or in ~/.ssh/config. -J needs OpenSSH 7.3 or later.
# Print the settings ssh would use for this host after every Host block is applied, without connecting
ssh -G target | grep -i proxy
The first value found wins, and command-line options come first: ssh -G -o ProxyJump=none target shows the override. Keep grep -i in case the case of -G output changes (see the OpenSSH 10.6 release notes).
# Does the host answer and does key login work? Exit 0 means yes. With ProxyJump, put BatchMode and ConnectTimeout in the bastion's Host block too.
ssh -o ConnectTimeout=5 -o BatchMode=yes target true && echo ok
Every failure exits 255: a refused key fails at once instead of asking for a password, and a host that drops packets fails after 5 seconds. -o options don't reach a jump host, so set BatchMode yes and ConnectTimeout 5 in the bastion's Host block too, or a prompt there can still stop a script. Behind ControlPersist, bastion errors show only as Connection closed by UNKNOWN port 65535: add -v.
# The quoted part runs on the target; everything after the closing quote runs on your machine
ssh target 'cat /etc/os-release' | grep '^PRETTY'
ssh reads stdin, so inside while read the first ssh swallows the rest of the list: use ssh -n. Never use -n when piping data into ssh: it then sends nothing. To compare two hosts: diff <(ssh a 'cat file') <(ssh b 'cat file').
# Is a multiplexed master connection open for this host? Then close it (needs ControlPath in your config)
ssh -O check target && ssh -O exit target
With ProxyJump the bastion gets its own master, so close that one too. -O conninfo needs OpenSSH 10.3 or later.
Tested with OpenSSH 9.2p1 on Debian 12 (aarch64). Related: ssh config, ssh cheat sheet, network, compare lists, rsync.
Git
# See the whole branch landscape: one line per commit, graph lines, branch labels
git log --oneline --graph --all --decorate
# Jump back to the branch you were just on (like cd -, but for git)
git checkout -
# Stash your uncommitted work with a reminder of why
git stash push -m "wip before pull"
# Put your stashed work back and drop the stash entry in one move
git stash pop
# Undo the last commit but keep every change staged, ready to recommit
git reset --soft HEAD~1
# Preview which untracked files a `git clean -fd` would delete (dry run)
git clean -fdn
# Delete every branch that's fully merged, except the one you're on and main/master
git branch --merged | grep -vE '^\*|main|master' | xargs -n 1 git branch -d
# Who wrote this line and when — with whitespace-only churn ignored (-w)
git blame -w -- index.html
# What did the last three commits change, as files touched and +/- line counts
git diff --stat HEAD~3
Git history search
Find the commit that added, changed or removed a line, follow one file back past a rename, search every commit at once, and get back a commit you lost to a reset. Everything here only reads history, except the last step, which adds a branch.
# Which commits added or removed this exact string (it counts occurrences, so edits that keep the count are skipped)
git log -S 'MAX_RETRIES' --oneline
-S takes a plain string and lists the commits that changed how many times it appears in a file: usually where it was added and where it was removed, plus any commit that added or removed an extra copy. Moving or editing a line within the same file keeps the count, so -S skips it and -G finds it. Add -p to see the change, or -- src/ to search one directory.
# Which commits added or removed a line matching a regex, including edits that only changed a value
git log -G 'MAX_RETRIES[[:space:]]*=[[:space:]]*[0-9]+' --oneline
-G checks the added and removed lines of each diff, so it also catches a commit that only changed 3 to 5. Add -i to ignore case. It is an extended POSIX regex, so write [[:space:]], not \s.
# Full history of one file, including the commits from before it was renamed
git log --follow --oneline -- src/settings.py
Without --follow the log stops at the rename. It works on one file at a time. Add --name-status to see the rename as an R line with the old and new path.
# Search the files in every commit for a pattern (it reads each commit's whole tree, so it is slow on big repos)
git grep -n 'MAX_RETRIES' $(git rev-list --all)
This finds text that has since been deleted, and each hit starts with the full commit ID. Try git log -S first on a large repo. If the shell says the argument list is too long, use git rev-list --all | xargs git grep -n 'MAX_RETRIES'. Add --reflog to rev-list to include commits only the reflog remembers.
# Find a commit you lost to git reset: the reflog lists every commit HEAD has pointed at recently
git reflog -n 5
# Look at the commit first (use the ID from the reflog; HEAD@{1} moves whenever HEAD does, even after git stash)
git show --stat 821878c
# If it's the one, give it a branch name (fails rather than overwrites if "rescue" already exists)
git branch rescue 821878c
The reflog is local to your clone and is never pushed. When git gc runs, entries for commits that are no longer reachable from the branch expire after 30 days by default (90 days for the rest). HEAD@{1} shifts every time HEAD moves, so copy the commit ID from the reflog.
Tested with git 2.47.3, bash 5.2.37 and zsh 5.9 on Debian 13. Related: git cheat sheet: read history, undo, git housekeeping, lazygit, delta.
JSON with jq
# Pretty-print and validate JSON from an API (bad JSON exits nonzero)
curl -s https://api.github.com/repos/git/git | jq .
# Pull one field out of a JSON response as plain text, no quotes
curl -s https://api.github.com/repos/git/git | jq -r '.stargazers_count'
# List one field from every element in a JSON array
jq -r '.[] | .name' issues.json
# Keep only the array elements that match a condition, then take a field
jq '.[] | select(.state == "open") | .name' issues.json
# Print each array element on one compact line — one JSON object per line, ready for grep
jq -c '.[]' issues.json
# Discover a JSON object's shape: list all its keys
jq -r 'keys[]' issues.json
# Supply a fallback value when a field is missing or null
jq -r '.mirror // "https://fallback.example"' config.json
# Tally JSON objects per value of a field — counts by state, no for loop
jq 'group_by(.state) | map({(.[0].state): length}) | add' orders.json
Shell history
# List your 20 most recent history entries by number
fc -l -20
# Rerun the previous command as root because you forgot sudo
sudo !!
# Insert the last argument of the previous command at the cursor (Esc then . in bash/zsh)
# e.g. after `mkdir -p /long/path/x`, type `cd` then Esc-.
cd <Esc-.>
# Search your history interactively as you type (bash and zsh)
# Ctrl-R, then type a fragment; press Ctrl-R again to cycle older matches
# Repeat the last command that started with "git" (full word match, use !-1 for anything)
!git
# Redo the last command and fix a typo in it (finds/replace across the whole line)
^chaown^chown