Skip to content
ExponaBLOG
GIT & GITHUB FOR PROFESSIONAL DEVELOPERS · DAY 064 / 100

Large refactors with Git: a practical Git & GitHub guide

Keep big changes reviewable.

Expona Admin7 min read

Large refactors with Git: a practical Git & GitHub guide

Large refactors are inevitable in maturing codebases, but they can quickly become nightmares if not managed well. A single pull request touching hundreds of files across multiple concerns makes review impossible, introduces unnecessary merge conflicts, and makes it nearly impossible to bisect if something breaks. The key to keeping big changes reviewable is treating a large refactor not as one monolithic change, but as a carefully orchestrated series of smaller, focused commits and pull requests.

Why large refactors fail on GitHub

When you push a 500-file PR that renames a core service, extracts a new module, and updates all the call sites in one go, reviewers face an impossible task. They can't distinguish between mechanical changes and behavioral changes. They can't easily spot whether a logic error was introduced during the refactor or existed before. Most importantly, if something breaks in production three weeks later, git bisect will point to your massive commit and you'll have no way to narrow down which part caused the issue.

We've all seen pull requests where the reviewer skims the first twenty files, loses context, and clicks approve anyway. That's not lazy reviewing—it's a symptom of poorly structured work.

Split the work — Preparatory refactors in separate PRs

The foundation of manageable large refactors is breaking them into logical phases. Each phase should be a complete, testable, and independently valuable change.

Let's say you're refactoring a monolithic UserService into domain-specific services. Don't do this in one PR. Instead:

  1. Phase 1 (PR #1): Extract AuthenticationService with new interfaces. No call-site changes yet. Tests pass.
  2. Phase 2 (PR #2): Gradually move authentication logic into the new service. Update call sites incrementally.
  3. Phase 3 (PR #3): Deprecate old methods in UserService, add warnings.
  4. Phase 4 (PR #4): Remove deprecated methods and the old implementation.

Each PR is reviewable in isolation. Each one can be merged, tested in staging, and reverted independently if needed. The entire refactor takes longer, but the risk is distributed and the review quality is dramatically higher.

Here's the commit structure for Phase 1:

bash
git log --oneline feature/extract-auth-service
a1b2c3d Add AuthenticationService interface and contract
d4e5f6g Add AuthenticationService implementation with tests
h7i8j9k Update imports and documentation

Each of those commits is small enough that a reviewer can understand the change in five minutes. The entire PR might be 200 lines instead of 2,000.

Mechanical commits — Separate renames and moves from logic changes

One of the highest-value practices in managing large refactors is separating mechanical changes from logical ones. A mechanical change is one where the behavior doesn't change—renaming a variable, moving a file, reorganizing imports, or reformatting code. A logical change is one where behavior changes.

These must be in different commits. Here's why: when you rename a function and change its logic in the same commit, the diff becomes nearly unreadable. Git's rename detection helps, but reviewers still have to parse both changes simultaneously.

Instead, do this:

bash
# Commit 1: Pure rename, no logic change
git add .
git commit -m "refactor: rename calculatePrice() to computeProductPrice()"

# Commit 2: Update logic in the renamed function
git add src/pricing/computeProductPrice.ts
git commit -m "fix: handle discount tier edge case in computeProductPrice()"

The first commit is entirely mechanical. Reviewers can skim it—they're just checking that the rename was applied everywhere. The second commit is entirely logical. Reviewers focus on the algorithm change, not the syntax.

This applies to file moves too. Moving src/utils/logger.ts to src/core/logger.ts should be one commit. Updating how it's used should be another. Git can handle this gracefully if you use the right flags:

bash
git mv src/utils/logger.ts src/core/logger.ts
git commit -m "refactor: move logger to core module"

# Then in a separate commit, update imports across the codebase
git add src/**/*.ts
git commit -m "refactor: update logger imports after relocation"

Stacked PRs — Each PR builds on the previous one

When you have multiple phases of a refactor, you'll likely work on them sequentially. GitHub and Git support a workflow where each PR is based on the previous one's branch, creating a "stack" of PRs. This keeps the refactor logically organized and makes the dependency clear.

Here's the branch structure:

bash
main
  ├── feature/extract-auth-service (based on main)
  │     ├── feature/migrate-auth-logic (based on extract-auth-service)
  │           ├── feature/deprecate-old-auth (based on migrate-auth-logic)
  │                 └── feature/remove-old-auth (based on deprecate-old-auth)

Set up your first PR and get it merged:

bash
git checkout -b feature/extract-auth-service
# Make changes
git push origin feature/extract-auth-service
# Open PR #1, wait for merge

Once PR #1 merges to main, create the next branch:

bash
git checkout main
git pull origin main
git checkout -b feature/migrate-auth-logic
# Make changes
git push origin feature/migrate-auth-logic
# Open PR #2

The benefit here is that each PR is smaller and faster to review. Reviewers see the evolution of the refactor. And if something breaks, you can revert individual phases without touching the others.

On GitHub, you can note the dependency in the PR description: "Depends on #1" or "Stacked on #1". Some teams use tooling like git-stacked-rebase or GitHub's draft PR feature to manage these more formally, but the core idea is the same.

Ignore formatting in blame — Add the commit to .git-blame-ignore-revs

Even with the best practices, some refactors involve formatting changes—tabs to spaces, line wrapping, or auto-formatting. When those changes are mixed with logic, git blame becomes useless. A developer investigating a bug sees the formatting commit instead of the actual logic that introduced the issue.

Git has a built-in solution: .git-blame-ignore-revs. This file tells git blame to skip over specific commits when determining authorship.

Create the file in your repo root:

bash
cat > .git-blame-ignore-revs << 'EOF'
# Bulk formatting pass - tabs to spaces
a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6

# Auto-format with Prettier
b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7
EOF

git add .git-blame-ignore-revs
git commit -m "chore: add formatting commits to blame ignore list"

Then configure Git to use it:

bash
git config blame.ignoreRevsFile .git-blame-ignore-revs

For teams, make this a global config in your repo setup or CI:

bash
git config --global blame.ignoreRevsFile ~/.gitignore-revs
# Or set it in your GitHub organization's default settings if available

Now when you run git blame src/core/auth.ts, it skips over the formatting commits and shows you the actual logic change. This is especially valuable for large refactors that touch many files.

Common mistakes

  • Mixing concerns in a single PR: Extracting a service, renaming functions, and updating tests all in one commit. Split them. Each commit should have one reason to change.
  • Not testing each phase independently: A refactor isn't done until each PR is merged to main and tested there. Don't assume all five PRs together will work if you haven't verified each one.
  • Forgetting to update documentation: Large refactors often change how developers interact with a module. Update your README, architecture docs, or migration guides before merging the final PR.
  • Ignoring deprecation warnings: If you're removing code, leave deprecation warnings in the phase before removal. Gives consumers time to migrate. Some teams skip this and introduce breaking changes unnecessarily.
  • Pushing all PRs at once: Wait for each PR to be reviewed and merged before opening the next one. You'll learn from review feedback and may need to adjust later phases accordingly.

Key takeaways

  • Break large refactors into multiple PRs, each addressing one concern or phase. This keeps each PR reviewable and testable in isolation.
  • Separate mechanical changes (renames, moves, formatting) from logical changes (behavior updates) into different commits. Reviewers can evaluate them independently.
  • Use stacked PRs where each PR builds on the previous one's branch. Maintain clear dependencies and merge in order.
  • Add formatting-heavy commits to .git-blame-ignore-revs so git blame remains useful for debugging.
  • Test and merge each phase before starting the next one. Don't treat a multi-PR refactor as a single unit of work—treat each PR as a complete, shippable change.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles