Pull requests that touch the team's bash scripts keep collecting comments about indentation and where `then` should go. What does the shfmt formatter do that ShellCheck does not, and how would you run it so CI fails on unformatted files without rewriting them?
answer
- one judges, one lays out
- never rewrite files inside CI
- diff mode exits non-zero
- shebang finds extensionless scripts
- pin the version or everything diffs
basics
~20 sshfmt is a formatter: it rewrites shell scripts into a canonical layout, while ShellCheck is a linter that finds bugs and never changes the file. In CI use shfmt -d, which prints a diff and exits non-zero instead of writing.
solid answer
~50 sThey solve different problems and you want both. ShellCheck is a linter — it reads a script and reports likely bugs, and it never edits anything. shfmt is a formatter — it parses the script and prints it back in a canonical layout, so indentation, line breaks and `then`/`do` placement stop being opinions. For CI, `shfmt -d .` prints a unified diff of what it would change and exits non-zero if anything differs, so the build fails without the job mutating the tree; developers run `shfmt -w` locally, or in a pre-commit hook, to apply it. The flags worth knowing are `-i N` for indent width (the default is tabs), `-ci` to indent `case` branches, `-bn` to put binary operators at the start of the continuation line, `-s` to simplify redundant syntax, and `-ln` to set the dialect. Given a directory, shfmt finds shell files by their shebang.
code
bash · 8 lines#!/usr/bin/env bash
set -euo pipefail
# Fails the build and shows exactly what is misformatted, without writing.
shfmt -i 2 -ci -d .
# Shebang-aware file discovery feeding the linter.
shfmt -f . | xargs -r shellcheck -x --severity=warninggo deeper
Know that shfmt formats and ShellCheck lints, and that -w writes while -d only shows a diff. Be able to say which one belongs in a CI job.
Explain why the CI job must not mutate the checkout, and how one pinned configuration keeps the local run and the pipeline run from disagreeing.
Show how you would introduce a formatter to an existing repository: one reformatting commit, the version pinned, the settings checked in, and the check wired into pre-commit as well as CI.
Frame it as removing a whole category of review comment so that review attention goes to substance, and be ready to argue what belongs to tooling versus to human judgment.
## Two tools, two jobs The distinction is the same one every ecosystem eventually draws: a **linter** judges whether the code is *correct*, a **formatter** decides how it is *laid out*. - **ShellCheck** parses the script and reports findings — unquoted expansions, unchecked `cd`, masked exit statuses. It emits messages. It does not modify your file. - **shfmt** parses the script and re-prints it. It is layout-only: it will not change what the script does, and it has no opinion about whether a variable should be quoted. Running only the linter leaves indentation as a matter of taste and therefore a matter of review comments. Running only the formatter gives you tidy scripts with the same silent bugs. The two are complementary and cheap enough to run together. ## Making formatting a non-discussion The point of a formatter is to remove a category of review comment entirely. That only works if the tool, not the reviewer, is the authority — which means it must run automatically in two places: ```bash shfmt -w . # locally / pre-commit: rewrite files in place shfmt -d . # CI: print the diff, exit non-zero, change nothing ``` `-d` (`--diff`) is the CI mode. It shows exactly what would change, which makes the failure self-explanatory — the developer copies the fix by running `-w`. A CI job must not use `-w`: a job that mutates the checkout either has nothing to commit the change to, or commits on the author's behalf, which surprises people. `-l` lists the paths whose formatting differs, which is handy when you only want the file names. Given a directory, shfmt walks it and identifies shell files by extension and by shebang, so a `deploy` script with no `.sh` suffix is still covered. That shebang-driven detection is worth knowing because the same principle applies to how you build the file list for ShellCheck. ## The flags that encode a house style The defaults follow the layout of the POSIX shell grammar with tab indentation. Teams usually pin a few options and check them in: - `-i N` — indent with N spaces (`-i 0`, the default, means tabs). - `-ci` — indent the branches of a `case` statement. - `-bn` — put binary operators such as `&&` at the beginning of the continuation line rather than the end of the previous one. - `-sr` — put a space after a redirection operator. - `-s` — simplify: remove redundant syntax, such as needless quotes or `${x}` where `$x` suffices. - `-ln DIALECT` — the language variant: `bash`, `posix`, `mksh`, `bats`. Otherwise the shebang decides. A typical invocation a repository standardises on looks like `shfmt -i 2 -ci -bn -d .`. Whatever you choose, put it in one place — a Makefile target, a pre-commit configuration, or an `.editorconfig`, which shfmt reads for indentation settings — so the local run and the CI run cannot drift apart. A formatter whose CI settings differ from the developers' settings is worse than none, because it produces failures nobody can reproduce. ## Where each one runs The usual arrangement is a single `lint` entry point that runs both: ```bash #!/usr/bin/env bash set -euo pipefail shfmt -i 2 -ci -d . shfmt -f . | xargs -r shellcheck -x --severity=warning ``` `shfmt -f` lists the shell files it finds — a convenient, shebang-aware way to build the file list for ShellCheck, which otherwise needs an explicit glob and will miss extensionless scripts. Pin both tools to a specific version, because a formatter upgrade that changes one layout rule turns every file in the repository into a diff. ## What a formatter cannot do shfmt reformats; it does not refactor and it does not judge. It will happily produce beautifully indented code that word-splits on the first filename with a space in it. Nothing about running it reduces the need for ShellCheck, tests, or review of the actual logic — it just removes the layout argument from the review so the remaining comments are about substance.
- Why should a CI job use `shfmt -d` rather than `shfmt -w`?`-w` rewrites the files in the runner's checkout, and a CI job has nowhere useful to put that change — it either discards it, so the build passes while the repository stays unformatted, or it commits on the author's behalf. `-d` prints the diff and exits non-zero, which fails the build with the fix visible in the log and leaves the tree untouched.
- How do you make sure the formatter finds a script called `deploy` with no `.sh` extension?Point shfmt at the directory rather than a glob: given a path it walks the tree and identifies shell files by shebang as well as extension, so extensionless scripts are covered. `shfmt -f .` prints that discovered list, which is also a good way to feed ShellCheck, since a `*.sh` glob would silently skip the same files.
- Does adopting shfmt reduce the need for ShellCheck?No — they do not overlap at all. shfmt changes only layout and is deliberately semantics-preserving, so a script it has formatted still has whatever quoting and exit-status bugs it started with. Formatting removes an argument from code review; the linter removes a bug class. Teams run both from the same entry point.
saying these in an interview costs you the question
- shfmt catches the same bugs, so ShellCheck is redundant
- Run shfmt -w in CI so the tree is always formatted
- A formatter can change what the script does
- A *.sh glob covers every shell script in the repo
- Everyone can just use their editor's own settings