The go-to resource for upgrading Ruby, Rails, and your dependencies.

Squashing PRs vs. Small Commits: Best Git Practices for Rails Upgrades


In 1799, a French soldier in Egypt discovered a heavily inscribed slab of granodiorite that would eventually become known as the Rosetta Stone. The stone itself was not a masterwork of literature; it was a mundane decree issued by King Ptolemy V. However, its immense value to history derived from the fact that it presented the exact same text in three distinct scripts: Ancient Egyptian hieroglyphs, Demotic script, and Ancient Greek. By providing a granular, side-by-side translation of a single event, the Rosetta Stone allowed scholars to finally decipher a lost language, unlocking centuries of historical context that would have otherwise remained permanently opaque.

Similarly, when maintaining large and complex software systems, the value of your version control history is not merely in recording what the final product looks like, but in preserving the precise context of how and why it evolved.

When we undertake a major framework migration — such as upgrading a legacy application from Ruby on Rails 6.1 to 8.0 — we inevitably collide with a philosophical question about Git workflows: should we squash our Pull Requests (PRs) into a single, clean commit, or preserve a granular history of small, atomic commits?

The answer to this question profoundly impacts our ability to manage technical debt, remediate security vulnerabilities, and debug regressions long after the Rails upgrade is deployed.

The Case for Squashing Commits

Let’s first examine the case for squashing commits. A “squash and merge” strategy takes all the individual commits within a feature branch and compresses them into a single, unified commit on the main branch.

Many engineering leaders advocate for this approach because it creates a pristine, highly readable project history. If you look at the main branch’s log, you see a neat, linear progression of completed features:

* Add payment gateway integration
* Update user profile UI
* Fix pagination bug in admin dashboard

Of course, for routine feature development, this approach is often entirely appropriate. It hides the messy reality of the development process — the typos, the false starts, the temporary debugging statements — and presents a clean ledger of business value delivered.

However, a Rails upgrade is fundamentally different from routine feature development. A major version bump is not a single feature; it is a systemic, structural reinforcement of the entire application. When we squash a long-running upgrade PR, we destroy the Rosetta Stone of our modernization effort.

The Case for Granular Commits

Upgrading a Ruby on Rails application typically involves hundreds of distinct, disconnected changes. You might need to resolve deprecation warnings in ActiveRecord, update a suite of third-party gems, alter the configuration for the asset pipeline, and rewrite dozens of failing test cases.

If you squash a massive Rails upgrade branch into a single commit with a message like Upgrade to Rails 7.2, you have effectively created a black box. This introduces several critical risks to the organization.

There are several distinct reasons for preserving granular commits during an upgrade. One is the power of git bisect when debugging regressions. Another important advantage is providing contextual archaeology for future developers.

The Power of git bisect

Consider a scenario that occurs frequently in enterprise software maintenance: three weeks after successfully deploying a Rails upgrade to the production environment, a user reports a subtle, intermittent bug in a critical billing calculation.

The standard procedure for identifying the root cause of a regression is to use git bisect, a tool that performs a binary search through your commit history to find the exact change that introduced the failure.

If your upgrade was squashed into a single, monolithic commit containing 15,000 lines of changed code across 400 files, git bisect becomes essentially useless. It will successfully identify that the “Upgrade to Rails 7.2” commit caused the issue, but it cannot tell you what specific change within that massive diff is responsible.

Conversely, if you preserved a granular commit history, git bisect might pinpoint a specific, atomic commit: Fix deprecation warning in Invoice#calculate_tax. What could have been a multi-day debugging nightmare becomes a straightforward, ten-minute fix.

Contextual Archaeology

When future developers encounter a strange implementation detail in the codebase, their first action is often to run git blame to understand the origin of the code.

If git blame points to a squashed upgrade commit, the developer learns nothing other than the fact that the code was modified during the Rails upgrade. They cannot determine if the change was a deliberate architectural decision, a temporary workaround for a bug in a specific gem, or a mechanical syntax update.

Small, atomic commits allow developers to leave a trail of breadcrumbs. A commit message reading Update RSpec syntax to accommodate keyword arguments in Ruby 3.2 provides immediate, precise context that prevents future engineers from accidentally reverting a necessary change.

Best Git Practices for Rails Upgrade Projects

To balance the need for a readable history with the necessity of granular debugging context, we recommend the following Git practices when executing a Rails upgrade.

1. Commit Isolated, Logical Changes

A commit should represent a single, logical unit of work. Do not mix unrelated changes. For instance, if you are fixing a deprecation warning in a model, do not also sneak in a formatting change to a CSS file in the same commit.

# Bad: Mixed concerns
$ git commit -m "Fix User model deprecation and update button colors"

# Good: Isolated, logical units
$ git commit -m "Resolve ActiveRecord::Base.errors deprecation in User model"

This isolation ensures that if a specific change needs to be reverted, it can be undone cleanly without dragging unrelated code down with it.

2. Utilize Merge Commits

There are three major approaches to integrating feature branches into the main branch.

The first is squashing commits, which compresses all changes into a single commit. As we have discussed, this is often detrimental for massive upgrades.

The second is utilizing standard merge commits. This is the approach I personally prefer for upgrades. A merge commit preserves the entire history of the feature branch while still providing a single integration point on the main timeline. This gives you the best of both worlds: a high-level view of when the upgrade was integrated, alongside the detailed, commit-by-commit history required for deep debugging.

The third option is to use the git rebase command. Rebasing rewrites the project history by moving the base of your feature branch to the tip of the main branch, preserving individual commits without creating a merge commit.

Generally speaking, the merge commit option is simpler and preserves historical context better. The rebase option, though, will often make more sense if your team strictly requires a linear history.

3. Write Pragmatic, Context-Rich Commit Messages

A well-written commit message is a letter to your future self. In the context of a Rails upgrade, it is crucial to explain why a change was necessary, not what was changed.

The first line (the subject) should be a concise summary of the action. The body should contain the context, referencing specific CVEs, deprecation warnings, or architectural decisions.

Fix positional argument deprecation in UserMailer

Rails 6.1 deprecates passing positional arguments to mailer actions 
when the mailer expects keyword arguments. This updates the call site 
in the registration controller to pass explicit kwargs, preventing 
a crash upon upgrading to Rails 7.0.

Ref: https://guides.rubyonrails.org/upgrading_ruby_on_rails.html

4. Divide and Conquer with Multiple PRs

Perhaps the most effective way to manage Git history during a massive upgrade is to avoid massive branches entirely. Rather than executing the entire upgrade in a single, long-running branch, split the work into smaller, independently deployable PRs.

For example, you can often execute the following tasks in production before actually bumping the Rails version:

  • Updating third-party dependencies and gems.
  • Fixing deprecation warnings logged in production.
  • Migrating test suites from older testing frameworks.
  • Upgrading the underlying Ruby version (e.g., moving to Ruby 3.2 before tackling Rails 7.2).

By merging these preparatory steps as separate, focused PRs, you drastically reduce the size and complexity of the final framework version bump.

The Economics of Predictability

Strictly speaking, maintaining a pristine, atomic Git history requires more discipline than squashing every PR. It demands that developers think carefully about how they stage their changes and write their commit messages.

However, this discipline is an investment in predictability. The cost of a Rails upgrade is not merely the initial engineering effort; it includes the long-term cost of maintaining the upgraded system.

By preserving a granular, context-rich Git history, you ensure that when inevitable regressions occur, your team possesses the archaeological tools necessary to identify, understand, and resolve them swiftly — keeping your application secure, stable, and economically viable for years to come.

Sponsored by Durable Programming

Need help maintaining or upgrading your Ruby on Rails application? Durable Programming specializes in keeping Rails apps secure, performant, and up-to-date.

Hire Durable Programming