Formatting only changed lines in a legacy codebase Jump to heading
Adding a formatter to a codebase that has never had one produces a familiar dilemma. Format everything, and you get a single commit touching every file: git blame now attributes the whole codebase to whoever ran the command, every open branch conflicts, and the next fortnight of reviews is unreadable. Format nothing, and the tool never actually applies. The way out is to format only the lines a change touches, so consistency accumulates where people are working. This recipe implements that within Lint-Staged & Formatting Automation.
When to use this approach Jump to heading
- The codebase predates the formatter and a full run would produce a diff nobody can review.
- Several long-lived branches are in flight, and a whole-tree reformat would conflict with all of them.
git blameis actively used to understand why code is the way it is โ the usual case in a mature system.- You want the formatter applied to new work immediately, without a migration project first.
- If the codebase is small or has no branches in flight, just format everything once and skip all of this; the complexity below is only worth it at scale.
Step 1 โ Understand why whole-file formatting fails here Jump to heading
Step 2 โ Format only the changed line ranges Jump to heading
The staged diff already tells you which lines changed. Most formatters accept a line range.
#!/bin/sh
# scripts/format-changed.sh โ format only the lines staged in each file.
# Usage: scripts/format-changed.sh <file> [<file> ...]
set -eu
for file in "$@"; do
# Extract "+start,count" hunk headers for the staged diff of this file
ranges=$(git diff --cached --unified=0 -- "$file" \
| awk '/^@@/ { split($3, a, ","); start = substr(a[1], 2);
count = (a[2] == "" ? 1 : a[2]);
if (count > 0) printf "--range-start=%d --range-end=%d ", start - 1, start + count - 1 }')
[ -n "$ranges" ] || continue
# shellcheck disable=SC2086 -- word splitting is intended for the range flags
npx prettier --write $ranges "$file"
git add "$file"
done What changed: only the lines this commit is already touching get reformatted, so the diff a reviewer sees contains no unrelated churn.
chmod +x scripts/format-changed.sh
# Try it on a real staged change
git add src/legacy/report.js
sh scripts/format-changed.sh src/legacy/report.js
git diff --cached --stat # the change is still scoped to your edit Formatters vary in what they call this. Prettier uses --range-start/--range-end, clang-format uses -lines=start:end, and black has no line-range support at all โ for that one the practical substitute is per-file opt-in via the scope expansion in Step 5.
Step 3 โ Wire it into the commit hook Jump to heading
{
"lint-staged": {
"src/legacy/**/*.js": ["sh scripts/format-changed.sh"],
"src/modern/**/*.{js,ts}": ["prettier --write"]
}
} What changed: legacy paths get range-limited formatting while already-clean paths get ordinary whole-file formatting โ the two policies coexist, keyed by directory.
git add src/legacy/report.js && git commit -m "fix: correct rounding in report totals"
# Expect: only your lines reformatted; the rest of the file untouched The two-policy arrangement is the point. Directories that have been cleaned should be held to whole-file formatting, because anything less lets them drift back. The pre-commit frameworkโs file scoping expresses the same split with files patterns if you use that instead of lint-staged.
Step 4 โ Keep blame readable when you do reformat Jump to heading
Some files are worth reformatting wholesale โ a file being rewritten anyway, or a directory finishing its migration. Do it in an isolated commit and tell blame to skip it.
# 1. A pure formatting commit, containing nothing else
npx prettier --write "src/legacy/reporting/**/*.js"
git add src/legacy/reporting
git commit -m "style: format reporting module (no functional change)"
# 2. Record it so blame looks through it
git rev-parse HEAD >> .git-blame-ignore-revs
git add .git-blame-ignore-revs
git commit -m "chore: ignore reporting format commit in blame"
# 3. Make every clone use it
git config blame.ignoreRevsFile .git-blame-ignore-revs # Verify: blame should attribute lines to the last meaningful change
git blame src/legacy/reporting/summary.js | head -5
# Expect authors and dates from real changes, not the style commit SAFETY WARNING โ a โpure formattingโ commit must genuinely change nothing else. Verify it with
git diff --ignore-all-space HEAD~1before pushing; if that shows any output, a real change is hiding inside a commit that reviewers andblamehave both been told to ignore. That is a good way to land an unreviewed behaviour change.
Step 5 โ Widen the scope one directory at a time Jump to heading
# Which directories are already clean?
for d in src/*/; do
if npx prettier --check "$d**/*.js" >/dev/null 2>&1; then
echo "clean: $d"
else
n=$(npx prettier --list-different "$d**/*.js" 2>/dev/null | wc -l)
echo "dirty: $d ($n files)"
fi
done # Promote a clean directory to whole-file formatting in lint-staged config,
# then confirm CI agrees it is clean
npx prettier --check "src/modern/**/*.js" && echo "safe to promote" Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Does partial formatting leave files inconsistent? Jump to heading
Yes, temporarily and deliberately. A file gradually becomes formatted as it is edited, which is exactly the trade: consistency arrives where people are actually working rather than everywhere at once. The alternative โ one commit touching every file โ destroys blame, conflicts with every open branch, and buries the next month of reviews in noise.
What does the blame ignore-revs file actually do? Jump to heading
It lists commits that git blame should look through rather than attribute to. A pure formatting commit listed there stops appearing as the author of every line it touched, so blame keeps pointing at whoever last changed the meaning. It is per-repository configuration and needs blame.ignoreRevsFile set for each clone, which is worth putting in the contributing guide.
Can CI enforce this without reformatting everything? Jump to heading
Yes โ run the same range-limited check against the pull requestโs diff rather than the whole tree. The job computes the changed line ranges against the merge base and asserts those ranges are formatted, so a contributor is never asked to fix code they did not touch.
Related Jump to heading
- Lint-Staged & Formatting Automation โ the parent guide: staged-file filtering, glob patterns, and keeping the commit hook fast.
- Running Prettier and ESLint Only on Staged Files โ the whole-file version, for code that is already clean.
- The pre-commit Framework for Polyglot Repositories โ expressing the same clean-versus-legacy split with
filespatterns.