Shell functions that earn their dotfiles spot
Eight shell functions for navigation, git, Docker, and file wrangling that survive years of dotfile churn. Each with trade-offs and edge cases.

I've been curating my dotfiles for over a decade, and the churn is real. Every six months I delete a handful of functions I was certain I'd use forever. The ones that survive are not the cleverest or the longest. They are the ones that solve a specific, recurring annoyance that has no built-in shell solution, and they do it with minimal ceremony. Here are the eight that have earned permanent residency.
Why most dotfile functions get deleted within a month
Over-customization creates cognitive load. If a function requires a mental lookup to use—what was that flag again? does it mutate state?—it's slower than just typing the command. I've seen people alias gco for git checkout and that's fine; it's a one-to-one mapping that saves three keystrokes. The functions that get undone are the ones that chain five operations, silently change directory, or overwrite files without confirmation.
The functions that survive are those that solve a frequent, moderately annoying task that has no single-command solution. They also need to be self-documenting: a good function name tells you exactly what it does, and the implementation should fit in one screen without scrolling.
I once wrote a function that parsed git log --oneline and opened the commit hash in a browser via open. It saved maybe two seconds per use, but I had to remember the exact invocation (no flag gotcha – it defaulted to the last commit, not current HEAD), and it broke whenever the remote URL changed. After three months of never using it, I deleted it. That experience taught me that any function requiring a mental lookup to recall exactly what it does is a liability.
Navigation: cd with fuzzy matching and project roots
I keep my projects under ~/code/org/repo. Typing cd ~/code/acme-inc/backend-service every time I switch context gets old fast. My solution is a function that takes a partial directory name, fuzzy-matches against common project roots, and drops me there.
# ~/.config/functions.sh
cdf() {
local root="${1:-$HOME/code}"
local dir
dir=$(find "$root" -maxdepth 3 -type d -name ".git" -prune | sed 's|/\.git$||' | fzf --query="$1" --height=40% --layout=reverse)
if [[ -n "$dir" ]]; then
cd "$dir"
echo "$dir" > /tmp/.cdf_last
fi
}I use find here because it's available everywhere, but fd is faster. The --maxdepth 3 prevents crawling into node_modules or deep vendor directories. The last selected path is stored in /tmp/.cdf_last, so pressing up in the shell recalls the last directory (I bind Ctrl+O to cd $(cat /tmp/.cdf_last)). The failure mode: if two projects share the same name under different orgs, fzf surfaces both and you pick. If you need deeper search, I add a --depth flag that overrides maxdepth.
Git: diff-review and commit-amend with safety checks
Two git functions that have survived every purge.
diff-review opens a side-by-side diff of unstaged changes in $PAGER with line numbers. It stages nothing, just shows you what you're about to add. I use delta as my pager, but it respects any $PAGER.
diff-review() {
git diff --no-color | head -n 500 | less -R
}(Yes, that's trivial. But it saves me from typing git diff and then git add -p in two separate steps. Muscle memory is real.)
commit-amend appends to the last commit only if the HEAD hasn't been pushed to any remote. The safety check uses git rev-list --count origin/main..HEAD—if that returns 0, the commit is local. If it's non-zero, the function refuses to run.
commit-amend() {
local remote_branch="${1:-origin/main}"
local ahead
ahead=$(git rev-list --count "$remote_branch..HEAD" 2>/dev/null)
if [[ "$ahead" -eq 0 ]]; then
git commit --amend --no-edit
else
echo "HEAD is ahead of $remote_branch by $ahead commits. Use --force to override." >&2
fi
}Edge case: if the branch has been force-pushed, the count may be misleading. I added a --force flag that prints a warning but proceeds. This function pairs well with Conventional Commits Without Hand-Written Changelogs—amend the message, then auto-generate the changelog.
Docker: prune with confirmation and context-aware exec
docker system prune -af --volumes is the nuclear option. I've accidentally wiped database volumes more than once. My docker-prune function prints a summary of what will be removed and requires typing 'yes' to proceed.
docker-prune() {
echo "Containers to remove: $(docker ps -aq 2>/dev/null | wc -l)"
echo "Images to remove: $(docker images -q 2>/dev/null | wc -l)"
echo "Volumes to remove: $(docker volume ls -q 2>/dev/null | wc -l)"
read -r -p "Proceed? (type 'yes'): " reply
if [[ "$reply" == "yes" ]]; then
docker system prune -af --volumes
fi
}The volume count is critical—anonymous volumes often hold database state. I've kept this function for three years because it adds friction to a destructive command.
dexec is docker exec -it with tab-completion for container names and an optional user flag. I generate the completion list from docker ps --format '{{.Names}}'. The function itself is trivial, but the completion script (sourced separately) is what makes it stick. I also added a filter: dexec web narrows the list to containers whose name includes "web", so I can type dexec api bash and get the right container without scanning a dozen names.
File wrangling: bulk rename with dry-run and undo
Bulk renaming files is dangerous. My rename-pattern function takes a sed expression and a glob, shows the diff of old→new names, and writes an undo script to /tmp before executing.
rename-pattern() {
local pattern="$1" glob="$2"
local tmpfile=$(mktemp /tmp/rename_undo.XXXXXX)
for f in $glob; do
new=$(echo "$f" | sed "$pattern")
if [[ "$f" != "$new" ]]; then
echo "mv '$new' '$f'" >> "$tmpfile"
echo "$f -> $new"
fi
done
read -r -p "Apply? (y/N): " reply
if [[ "$reply" == "y" ]]; then
for f in $glob; do
new=$(echo "$f" | sed "$pattern")
[[ "$f" != "$new" ]] && mv -- "$f" "$new"
done
echo "Undo script: $tmpfile"
fi
}An alternative is mmv, which handles brace expansion natively and is available on most systems. My function wraps mmv with the -n (dry-run) flag by default. Here's a comparison:
| Feature | rename-pattern (sed) |
mmv wrapper |
|---|---|---|
| Pattern syntax | sed expression | brace expansion / wildcards |
| Dry-run default | Yes, asks confirmation | Yes, -n flag |
| Undo support | Writes undo script | Manual reverse |
| Spaces in filenames | Quotes both sides | Quotes both sides |
| Newlines in filenames | Breaks (use find -print0) |
Breaks (use find -print0) |
Both fail on filenames with newlines. For those, I reach for find -print0 | xargs -0 in a one-off script rather than a general-purpose function.
Process management: port-kill and mux
port-kill takes a port number, finds the PID with lsof -ti :$1, and kills it with SIGTERM followed by SIGKILL after 2 seconds if still alive.
port-kill() {
local port="$1"
local pid
pid=$(lso -ti :"$port" 2>/dev/null)
if [[ -z "$pid" ]; then
echo "No process on port $port" >&2
return 1
fi
kill -TERM "$pid" 2>/dev/null
sleep 2
if kill -0 "$pid" 2>/dev/null; then
kill -KILL "$pid"
echo "Killed $pid with SIGKILL"
else
echo "Killed $pid with SIGTERM"
fi
}On WSL, lsof isn't available. I substitute netstat -ano | findstr :$1 and taskkill /PID. The function checks for lsof and falls back gracefully. I also added a --signal flag that lets me send a specific signal (e.g., port-kill 3000 --signal HUP), though I rarely use it—the two-step TERM/KILL cycle handles 99% of cases.
mux is a tmux wrapper: tmux new-session -A -s with a default session name derived from the basename of the current directory. It reduces tmux verbosity to zero—I just type mux and I'm in a session named after my project. If you want sessions that survive a laptop reboot, see Terminal sessions that survive a laptop reboot.
What to avoid: over-engineering and scope creep
Don't write a function that parses JSON, makes API calls, and formats output. That belongs in a dedicated script or a Go binary, not in your shell. Shell functions are for glue, not heavy lifting.
Functions that depend on non-default tools (jq, bat, fzf) should check for their presence and fall back gracefully. I add a guard at the top of my functions file:
for cmd in fzf lsof docker tmux; do
command -v "$cmd" >/dev/null 2>&1 || echo "Warning: $cmd not found"
doneEvery function should fit in a terminal window without scrolling. If it doesn't, it's too long and will be replaced by a script you'l forget exists. The Large-Scale Refactors with jscodeshift and ts-morph approach is a better pattern for complex logic: write a dedicated script, then alias it in your shell.
A concrete example: I once had a function that generated boilerplate React components—it prompted for component name, props, and styling framework, then wrote files. It was 60 lines, depended on jq and sed, and had three separate code paths. After a month I never used it because the interactive prompts were slower than just running a one-line cat > command from my snippet file. I replaced it with a simple touch aliases and a markdown snippet list.
Testing and sourcing strategy
I source functions from ~/.config/functions.sh rather than lumping them in .bashrc or .zshrc. This keeps reloading fast and makes the file git-friendly. I add a test mode: sourcing the file with an argument like --test exercises each function with sample inputs and reports failures.
if [[ "$1" == "--test" ]]; then
echo "Testing cdf..."
cdf /tmp
echo "Testing port-kill..."
port-kill 9999 # expects failure
exit 0
fiI also integrate this test mode into my shell startup: if --test is passed, the shell exits after running the tests; otherwise it continues normally. This way I can verify all my functions still work after I edit the file, without needing to open a new terminal and manually test each one.
I version each function with a comment header that includes the date added and the trigger that led to it. Six months later you'l know whether to keep or drop it.
# 2023-03-15: frequently killed wrong port, needed a safe way to kill by port
port-kill() { ... }Key takeaways
- The best shell functions are short, self-documenting, and solve a single problem that has no built-in solution.
- Always include safety checks: destructive commands require confirmation or a dry-run mode.
- Keep functions in a dedicated file, version them with dates, and add a test mode.
- Avoid scope creep—if a function grows beyond 20 lines, move it to a standalone script.
- Use fuzzy finding (
fzf) and process substitution to reduce keystrokes without sacrificing control.
Frequently asked questions
- Should I use aliases or functions for shell shortcuts?
- Aliases are fine for commands that need no arguments (e.g., alias gco='git checkout'). Once you need to handle arguments, conditionals, or error checks, a function gives you proper control flow and positional parameters.
- How do I share my dotfiles across macOS, Linux, and WSL?
- Check platform-specific availability inside each function using uname or [[ $OSTYPE ]]. For commands like lsof that don't exist on WSL, provide a branch using wmic or powershell. Test each platform individually — small platform differences in flag syntax (e.g., sed -i on macOS vs Linux) will break silently.
- How many shell functions is too many?
- If you need a cheat sheet to remember them, you have too many. I keep around 10-15 active ones and archive the rest to a separate file. Review every quarter: delete any function you haven't used in the last 30 days.


