Manual diff review catches what you happen to look at. Automated diffing catches the change you did not know you made — generated files that drifted, a formatter that never ran, an API schema that moved. These five steps take about an hour to set up and then run forever.
The core primitive is git diff --exit-code, which returns 1 if the working tree differs from HEAD. Run your generator, then check nothing moved:
npm run build:schema
git diff --exit-code -- schema/ || {
echo "Generated schema is out of date. Run npm run build:schema and commit."
exit 1
}
Use git diff --exit-code --stat when you want the CI log to show which files drifted. This one pattern covers generated clients, OpenAPI specs, lockfiles, protobuf output and formatted source.
In CI you want a report, not a mutation. Every formatter has a check flag: prettier --check ., ruff format --check ., gofmt -l . (which prints offending files and needs test -z "$(gofmt -l .)" to actually fail), cargo fmt --check. Keep the write mode in a pre-commit hook so contributors never see a formatting failure they cannot fix in one command.
The pre-commit framework runs hooks only on staged files, which keeps it fast. A minimal .pre-commit-config.yaml with trailing-whitespace, end-of-file-fixer, check-merge-conflict and your formatter of choice removes most of the noise from review diffs. Install with pre-commit install and run the whole repo once with pre-commit run --all-files so the first real commit is not a 900-file reformat.
Text diffing JSON and YAML produces false positives every time key order or indentation changes. Normalise first. For JSON, jq -S . a.json > a.norm && jq -S . b.json > b.norm && diff -u a.norm b.norm sorts keys on both sides. For YAML, yq -P 'sort_keys(..)' does the same job. For code, difft compares parse trees, so a reindented block or a moved function is reported as such rather than as hundreds of changed lines.
The highest-value check most teams skip: compare what you are about to deploy against what is running. For container images, docker run --rm wagoodman/dive <image> shows layer-by-layer changes and wasted space, and container-diff reports package-level differences between two tags. For infrastructure, terraform plan -detailed-exitcode returns 2 when there are changes, which lets you gate a deploy on "the plan is empty". For static sites, keeping a checksum manifest of the previous build and diffing it against the new one catches the case where a config change silently rewrites 4,000 files.
Automated diffs fail the moment they cry wolf. Scope each check to the paths it owns (-- schema/, -- docs/), exclude vendored directories, and make every failure message say the exact command that fixes it. A check that prints "run make fmt and commit" gets obeyed; one that prints a 2,000-line diff gets ignored.