Skip to content

Modern CLI Workflows Footguns

Mistakes that cause outages or wasted hours.


1. Piping to xargs without null delimiter — breaking on filenames with spaces

You run fd -e log | xargs rm to delete log files. A filename contains a space: application error.log. xargs splits on whitespace by default, treating application and error.log as two separate arguments. It deletes the wrong files or fails with "no such file." In worse cases, filenames with spaces can be crafted to make xargs execute arbitrary arguments. Fix: Use null-delimited output and input: fd -e log -0 | xargs -0 rm. fd's -0 flag outputs null-terminated names; xargs's -0 reads them. Alternatively, use fd's built-in execute: fd -e log -x rm {} — it passes each result as a single argument regardless of spaces. Never use find | xargs or fd | xargs without null delimiters on systems where filenames may contain spaces.

Remember: The null byte (\0) is the only character that cannot appear in a Unix filename. Newlines, spaces, quotes, and even glob characters are all legal in filenames. Null-delimited piping (-0 | xargs -0) is the only universally safe approach.


2. Forgetting fzf preview commands are slow — blocking the selector UI

You set up a fzf preview with kubectl describe {} to show pod details while browsing pods. Every keypress triggers a kubectl API call, the preview updates slowly (500ms-2s per call), and the fzf UI feels laggy and unresponsive. If you move quickly through results, kubectl calls pile up. Fix: Debounce slow preview commands: use --preview-window=right:50%:wrap with --bind 'change:first' to reduce unnecessary calls. For slow commands, add a cache layer: fzf --preview 'cache_cmd.sh {}' where the script caches results. Prefer fast preview commands where possible: bat, cat, head are near-instant. For kubectl, pre-fetch data with kubectl get pods -o json > /tmp/pods.json and preview against the cached file.


3. Relying on aliases in scripts — aliases don't expand in non-interactive shells

You have alias grep='rg' in your .bashrc. You write a script that uses grep expecting to get ripgrep. The script runs fine interactively. In CI, in cron jobs, or when called from another script, aliases are not expanded — you get the system grep, which may have different flags, different default behavior, or not be installed where rg is. The script behaves differently in different environments. Fix: In scripts, never rely on aliases. Use explicit tool names: rg not grep, fd not find. At the top of scripts, check for required tools: command -v rg >/dev/null || { echo "rg required"; exit 1; }. Aliases are for interactive shell convenience only. If you need consistent behavior, use a wrapper function and source it explicitly, or install tools in a well-known path in your CI environment.

Debug clue: When a script works interactively but fails in cron or CI, run type <command> to see if it resolves to an alias, function, or binary. In non-interactive shells, type grep returns /usr/bin/grep — never your alias. This is the first thing to check.


4. Not version-pinning tool configs — upgrading tools breaks pipelines silently

You upgrade delta from v0.15 to v0.17. The configuration format changed for one key. Git diffs now display without syntax highlighting and you don't notice for a week because the diff is still readable. Or you upgrade yq from v3 to v4 and all your scripts that parse YAML break because the expression syntax changed incompatibly. Fix: Pin tool versions in CI/CD (e.g., install specific versions via cargo install delta@0.15.0 or via package managers with pinned versions). Keep a tools.versions file or similar in your repo documenting expected versions. For yq specifically: always check yq --version before running scripts in a new environment — v3 and v4 have completely different syntax. Add a version check at the top of scripts that depend on specific tool behavior.


5. delta in non-interactive mode breaks CI diff output

You configure git globally to use delta as the pager ([core] pager = delta). This works great interactively. In CI, git diff is piped through delta, which outputs ANSI color codes and control characters into a log file. CI log parsers, artifact storage, and diff-checking scripts that do string matching break. Some CI systems interpret delta's output as errors. Fix: Disable delta in non-interactive contexts. Set GIT_PAGER=cat in CI environment variables to bypass delta. Or configure delta to detect non-interactive use: add [delta] color-only = false or use delta --no-gitconfig in CI scripts. Alternatively, scope delta to the user profile rather than the system git config so it only applies when a human is running git.


6. fd vs find behavior differences — silently ignoring files you need

You switch from find to fd and your script that searches for files stops finding some of them. fd respects .gitignore by default and excludes hidden files by default. Your target files are in a .hidden-dir/ or are excluded by a .gitignore rule. find would have found them; fd silently skips them. Fix: Know fd's default exclusions: (1) files matching .gitignore patterns, (2) hidden files (starting with .). Override with -H to include hidden, -I to ignore .gitignore, -u (unrestricted) to disable all exclusions. When migrating from find to fd, test with fd -u first to match find's behavior, then remove the flag once you understand which exclusions are appropriate. Use fd --show-errors to surface permission errors that find would show but fd hides.


7. jq null propagation surprises — silently getting null output instead of an error

You write jq '.items[].metadata.name' and some results print as null. You assume those items don't have names. They do — but the field path is slightly wrong (labels instead of label, for example). jq propagates null through expressions silently: .foo.bar on an object where .foo doesn't exist returns null, not an error. Your pipeline processes the null values downstream and produces wrong results with no error. Fix: Use // (alternative operator) to make missing values explicit: .metadata.name // "MISSING". Use error to fail on null: .metadata.name // error("name is null"). Add select(. != null) to filter out nulls when they're expected: .items[] | select(.metadata.labels != null) | .metadata.labels.env. For debugging, use jq 'debug' to print intermediate values to stderr: .items[] | debug | .metadata.name.

Gotcha: jq's null propagation is by design (like optional chaining in JavaScript), but it silently masks typos. .metadata.lables.env (typo: lables) returns null, not an error. Use // error("field missing") liberally during development to catch these early.