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 blame is 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

Whole-tree reformat versus formatting what you touchA whole-tree reformat produces one commit touching every file, which reassigns blame across the codebase, conflicts with every open branch, and makes the following weeks of review noisy. Incremental formatting touches only the lines already being changed, so blame, branches and reviews are unaffected.reformat everythingformat what you touchblameone author owns everythingunchangedopen branchesall conflict, all at onceunaffectedreview noiseweeks of unreadable diffsnonecoveragecomplete on day onegrows with editsonly the last row favours the big-bang approach โ€” and coverage is the thing you can afford to wait for

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
Blame before and after ignore-revsWithout an ignore-revs file, every line of a reformatted file is attributed to the formatting commit and its author. With the commit listed and blame.ignoreRevsFile configured, blame looks through it and reports the last change that altered meaning.without ignore-revsa4f1c2 (style-bot 2026-07-31) function total(rows) {a4f1c2 (style-bot 2026-07-31) return rows.reduce(โ€ฆ)a4f1c2 (style-bot 2026-07-31) }every line points at the reformat โ€”the history of why is unreachablewith ignore-revs9b2e10 (r.okafor 2024-03-12) function total(rows) {c71d55 (l.tanaka 2025-11-02) return rows.reduce(โ€ฆ)9b2e10 (r.okafor 2024-03-12) }blame looks through the style commitand reports who changed the meaningthe ignore file only works when each clone sets blame.ignoreRevsFile โ€” put it in the contributing guide

SAFETY WARNING โ€” a โ€œpure formattingโ€ commit must genuinely change nothing else. Verify it with git diff --ignore-all-space HEAD~1 before pushing; if that shows any output, a real change is hiding inside a commit that reviewers and blame have 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"
Coverage grows without a migration projectEach quarter, directories that have become fully formatted through incremental edits are promoted to whole-file enforcement. Coverage rises steadily without any single large commit, and the remaining legacy area shrinks.enforced whole-file (green) versus range-limited (outline)Q1legacy โ€” format what you touchQ2Q3Q4no big-bang commit, no branch conflicts, and blame stays useful throughout

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.