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:
- Phase 1 (PR #1): Extract
AuthenticationServicewith new interfaces. No call-site changes yet. Tests pass. - Phase 2 (PR #2): Gradually move authentication logic into the new service. Update call sites incrementally.
- Phase 3 (PR #3): Deprecate old methods in
UserService, add warnings. - 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:
git log --oneline feature/extract-auth-service
a1b2c3d Add AuthenticationService interface and contract
d4e5f6g Add AuthenticationService implementation with tests
h7i8j9k Update imports and documentationEach 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:
# 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:
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:
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:
git checkout -b feature/extract-auth-service
# Make changes
git push origin feature/extract-auth-service
# Open PR #1, wait for mergeOnce PR #1 merges to main, create the next branch:
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 #2The 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:
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:
git config blame.ignoreRevsFile .git-blame-ignore-revsFor teams, make this a global config in your repo setup or CI:
git config --global blame.ignoreRevsFile ~/.gitignore-revs
# Or set it in your GitHub organization's default settings if availableNow 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-revssogit blameremains 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.






Comments
Loading comments…