/ Tags: DEVELOPER TIPS / Categories: TIPS

RuboCop in a Legacy Codebase — Safe Rollout Without the 4,000-File Diff

Someone adds RuboCop to a six-year-old Rails app, runs it, and gets 47,000 offences. The obvious next move — rubocop -A and commit — produces a diff touching every file in the repository, destroys git blame for the whole team, conflicts with every open branch, and has a non-zero chance of changing behaviour. There’s a much better sequence, and it starts by not fixing anything.

Step One: Freeze, Don’t Fix


RuboCop has a purpose-built mechanism for exactly this situation.

Example:

bundle exec rubocop --auto-gen-config

This generates .rubocop_todo.yml — every currently-violated cop, either disabled or configured with a threshold matching your worst existing case — and adds an inherit_from line to .rubocop.yml.

Result:

# .rubocop.yml
inherit_from: .rubocop_todo.yml

AllCops:
  TargetRubyVersion: 3.2
  NewCops: enable

Run RuboCop again and you get zero offences. Nothing changed in the code, and from this moment on new violations fail the build. That single property is worth more than any bulk autocorrect: the codebase stops getting worse immediately, at a cost of one commit that touches two files.

Generate it with per-file exclusions

By default, --auto-gen-config disables a cop entirely if it’s violated more than a threshold number of times. Force it to list files instead:

bundle exec rubocop --auto-gen-config --auto-gen-only-exclude --exclude-limit 10000

Now the TODO file names the specific files that violate each cop, rather than switching the cop off globally. New files are held to the standard automatically, and the list shrinks visibly as you clean up. It makes the TODO file large — that’s fine, nobody reads it top to bottom.

Step Two: Configure Before You Correct


Half the offences in a fresh run are RuboCop disagreeing with your team’s taste, not finding problems. Settle those first so you’re not autocorrecting toward a style you’ll change next month.

Example:

# .rubocop.yml
require:
  - rubocop-rails
  - rubocop-rspec
  - rubocop-performance

AllCops:
  TargetRubyVersion: 3.2
  NewCops: enable
  Exclude:
    - "db/schema.rb"
    - "db/migrate/**/*"      # historical, don't touch
    - "bin/**/*"
    - "vendor/**/*"
    - "node_modules/**/*"

Metrics/BlockLength:
  Exclude:
    - "spec/**/*"            # describe blocks are legitimately long
    - "config/routes.rb"

Style/Documentation:
  Enabled: false             # a comment above every class is noise

Layout/LineLength:
  Max: 120

Two of those deserve comment. Excluding db/migrate matters because migrations are a historical record — reformatting a migration from 2019 has no upside and a small chance of breaking a re-run. And Style/Documentation is the cop most teams turn off, because a mandatory comment above every class produces # The Order class. four hundred times.

Decide these in a short discussion, not in code review on every subsequent PR.

Step Three: Understand -a vs -A


This distinction is the entire safety story and it’s easy to miss.

bundle exec rubocop -a    # --autocorrect: safe corrections only
bundle exec rubocop -A    # --autocorrect-all: includes unsafe corrections

A safe correction is guaranteed not to change behaviour — whitespace, string quoting, unless for negated if. An unsafe one is a correction RuboCop’s authors have flagged as potentially behaviour-changing.

Real examples of unsafe corrections:

# Style/EachWithObject
result = {}
items.each { |i| result[i.id] = i }
# corrected to each_with_object — different if the block returns early

# Rails/Presence
value.present? ? value : default
# corrected to value.presence || default — differs when value is false

# Style/NumericPredicate
if count == 0
# corrected to count.zero? — raises if count is nil, where == 0 returns false

That last one is the classic. nil == 0 is false; nil.zero? is NoMethodError. An -A run across a legacy codebase will introduce this in places where the value can be nil, and your test suite may not cover them.

Only ever run -A on a small, reviewed diff. For bulk work, -a only.

Step Four: Chip Away, One Cop at a Time


With the TODO file holding the line, cleanup becomes optional work you can do in bounded chunks.

Example:

# Pick one cop from the TODO file
bundle exec rubocop --only Style/StringLiterals -a

# Verify nothing broke
bundle exec rspec

# Remove that cop's section from .rubocop_todo.yml, then confirm it's clean
bundle exec rubocop --only Style/StringLiterals

One cop, one commit, one clear message: style: normalise string literals to single quotes. It’s reviewable — a reviewer can scan a diff of pure quote changes in a minute — and if something does break, git revert undoes exactly one category of change.

Order the cops by risk, cheapest first:

  1. Layout cops — whitespace, indentation, alignment. Zero behavioural risk.
  2. Style cops that are safe-correctable — string literals, %w arrays, hash syntax.
  3. Lint cops — these often find real bugs. Read each one; don’t autocorrect blindly.
  4. Metrics cops — method length, complexity. Not autocorrectable at all; these are refactoring work.
  5. Unsafe-correctable cops — one at a time, with a careful read of the diff.

Step Five: Keep git blame Usable


Even a pure-formatting commit ruins blame for the lines it touches. Git has a fix.

Example:

# .git-blame-ignore-revs
# Normalise string literals
a3f8c21b9d4e5f60718293a4b5c6d7e8f9012345
# Layout cops autocorrect
b7e2d94c1a8f3b62950174e8d3c9b0a5f6182937
git config blame.ignoreRevsFile .git-blame-ignore-revs

GitHub reads this file automatically. Blame skips those commits and shows the previous meaningful author. Add every bulk-formatting SHA to it, in the same commit that does the formatting, and the objection that most often blocks a style rollout disappears.

Step Six: Only Lint What Changed


For a repo where full cleanup will take a year, run RuboCop against the diff rather than the tree.

Example:

# Files changed against main
git diff --name-only --diff-filter=ACM main... \
  | grep -E '\.(rb|rake)$' \
  | xargs -r bundle exec rubocop --force-exclusion

--force-exclusion is required — without it, explicitly passing a file bypasses your Exclude config.

In CI:

- name: Lint changed files
  run: |
    git fetch origin $ --depth=1
    CHANGED=$(git diff --name-only --diff-filter=ACM origin/$...HEAD | grep -E '\.rb$' || true)
    if [ -n "$CHANGED" ]; then
      echo "$CHANGED" | xargs bundle exec rubocop --force-exclusion
    fi

Pro-Tip: Add bundle exec rubocop --auto-gen-config to a scheduled job that opens a PR when the TODO file shrinks on its own. As people refactor files for unrelated reasons, they incidentally fix violations, and the TODO entries for those files become stale — the cop is still listed as excluded for a file that no longer violates it. A monthly regeneration tightens the net automatically, and the diff is a satisfying list of removals rather than work anyone had to do. Just make sure the job regenerates with --auto-gen-only-exclude so it doesn’t silently switch a cop from per-file exclusions to globally disabled the first time a file count crosses the threshold — that’s a regression disguised as cleanup.

What Not to Do


  • Don’t run rubocop -A on the whole repo. It’s the single most common way this goes wrong.
  • Don’t enable every cop because it exists. A style guide the team disagrees with gets rubocop:disable comments sprinkled everywhere, which is worse than no linter.
  • Don’t merge formatting and logic in one PR. The formatting hides the logic change from reviewers.
  • Don’t let rubocop:disable accumulate without reason. Require an inline comment saying why: # rubocop:disable Metrics/MethodLength -- generated dispatch table, splitting hurts readability.

Conclusion


The safe rollout is: generate .rubocop_todo.yml so nothing gets worse, agree the configuration before correcting anything, then clean up one cop per commit with -a and never -A in bulk. Add every formatting commit to .git-blame-ignore-revs in the same change, and lint only the diff in CI while the backlog is large. Done this way, adopting a linter on a legacy codebase is a two-file commit on day one and a slow, low-risk improvement afterwards — rather than a 4,000-file diff nobody can review and everybody has to rebase onto.

FAQs


Q1: Should .rubocop_todo.yml be committed?
Yes. It’s part of the configuration, and CI needs it to produce the same result as local runs.

Q2: How do I know whether a cop’s autocorrection is safe?
bundle exec rubocop --show-cops Style/NumericPredicate prints the cop’s configuration including SafeAutoCorrect. The RuboCop docs mark unsafe cops explicitly, and -a simply skips them.

Q3: What about standard instead of RuboCop?
standard is RuboCop with a fixed, non-configurable ruleset. It eliminates the style-debate phase entirely, which is a real benefit for a team that keeps relitigating it. The migration path from a large TODO file is harder, since you can’t gradually adopt a configuration you can’t configure.

Q4: Should linting block CI?
Once the TODO file is in place, yes — that’s what stops new violations. Before it, no; a permanently red build teaches people to ignore the build.

Q5: How do I handle a file that legitimately can’t satisfy a cop?
Inline # rubocop:disable CopName with a reason comment, scoped as narrowly as possible and always paired with # rubocop:enable. A per-file exclusion in the config is preferable when the whole file is the exception.

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More