How to Resolve Difficult Git Merge Conflicts Step-by-Step
Resolve hard Git merge conflicts by reading ours, theirs, and base versions, handling renames, rebases, binaries, and tests.
How to Resolve Difficult Git Merge Conflicts Step-by-Step
A difficult Git merge conflict is rarely difficult because of the conflict markers themselves. It is difficult because you have to preserve the intent of two different changes at the same time. One branch renamed a function while another changed its behavior. One branch moved a file while another edited it. One branch changed a database migration sequence. Git can show you the overlap, but it cannot decide what the software should mean afterward.
When a merge stops with conflicts, do not start deleting markers immediately. First get oriented.
git status
Git will list unmerged paths. It may say both modified, deleted by us, deleted by them, both added, or something similar. Those phrases tell you the shape of the conflict.
If the merge feels wrong or you are not ready to resolve it, abort before making more changes:
git merge --abort
For a rebase conflict, the equivalent is:
git rebase --abort
That is not failure. It is a clean reset to the state before the operation, which is often the smartest move when you realize you need more context.
Read the Conflict as Three Versions
A normal conflict marker looks like this:
<<<<<<< HEAD
current branch version
=======
incoming branch version
>>>>>>> feature-branch
During a merge, HEAD is the branch you had checked out when you ran git merge. The bottom side is the branch being merged in.
For hard conflicts, use the three stages Git keeps in the index:
git show :1:path/to/file # common ancestor
git show :2:path/to/file # ours
git show :3:path/to/file # theirs
The common ancestor is the version both branches started from. It is useful because it shows what each branch actually changed. Without it, you may compare two final versions and miss the reason behind them.
You can also use:
git diff
git diff --ours -- path/to/file
git diff --theirs -- path/to/file
git diff --base -- path/to/file
This is where many people go too fast. The goal is not to choose "ours" or "theirs" as a team loyalty vote. The goal is to produce the correct final file.
A Safe Manual Workflow
Use this routine for each conflicted file:
- Open the file and find every conflict marker.
- Read the surrounding code, not just the marked lines.
- Check the commits that touched the file on both branches.
- Edit the file into the final intended version.
- Remove all conflict markers.
- Run the smallest relevant test or build check.
- Stage the file.
Useful commands while doing that:
git log --oneline --left-right --merge -- path/to/file
git diff --check
git grep -n '<<<<<<<\|=======\|>>>>>>>'
git diff --check catches leftover whitespace problems. git grep catches forgotten conflict markers before they reach CI.
After resolving one file:
git add path/to/file
When all conflicts are staged:
git status
git commit
During a rebase, use:
git rebase --continue
When Both Branches Changed the Same Function
This is the common case. Suppose one branch adds validation and the other renames a parameter:
<<<<<<< HEAD
function createUser(email) {
return db.users.insert({ email });
}
=======
function createUser(rawEmail) {
const email = rawEmail.trim().toLowerCase();
return db.users.insert({ email });
}
>>>>>>> normalize-email
The right answer may combine both:
function createUser(rawEmail) {
const email = rawEmail.trim().toLowerCase();
return db.users.insert({ email });
}
But only if callers were updated to pass rawEmail, and only if normalization is still desired. Search for the function:
git grep -n 'createUser'
Difficult conflicts often require checking nearby files. A function signature conflict in one file may require updates in tests, routes, types, mocks, or documentation.
Rename and Edit Conflicts
Rename conflicts are annoying because the file you want may not be where you expect. Start with status:
git status --short
Then inspect name-status information:
git diff --name-status --diff-filter=R
If one branch renamed src/user.js to src/account.js and the other edited src/user.js, you usually want the edited content applied to the new path. A visual merge tool can help, but the concept is simple: preserve the rename and preserve the meaningful edits.
After you decide the final path, remove the obsolete path if needed and stage the final one:
git rm old/path.js
git add new/path.js
Do not stage both files unless the final project really should contain both.
Deleted by Us or Deleted by Them
A delete/modify conflict means one branch deleted a file while the other changed it. Git cannot know whether the deletion made the change irrelevant.
If the file should stay deleted:
git rm path/to/file
If the file should remain, choose the version you want and stage it:
git checkout --theirs path/to/file
git add path/to/file
or:
git checkout --ours path/to/file
git add path/to/file
Be careful with --ours and --theirs during rebase. In a rebase, the labels can feel reversed because Git is replaying your commits onto another base. When unsure, inspect the stages:
git show :2:path/to/file
git show :3:path/to/file
Binary File Conflicts
Git cannot merge most binary files. If two branches changed the same image, archive, document, or compiled asset, you have to choose one version or create a new file manually.
To take our version:
git checkout --ours path/to/file.bin
git add path/to/file.bin
To take their version:
git checkout --theirs path/to/file.bin
git add path/to/file.bin
If the binary is generated, the best answer may be to regenerate it from source after resolving text files. If the binary is a design asset or document, talk to the person who changed the other side. Guessing can destroy work.
Use a Merge Tool When the File Is Too Hard to Read
A good merge tool shows four things: the base version, your version, their version, and the result. Configure one you actually like. Visual Studio Code is common:
git config --global merge.tool vscode
git config --global mergetool.vscode.cmd 'code --wait $MERGED'
Then run:
git mergetool
Other teams prefer Meld, KDiff3, Beyond Compare, or IDE-integrated tools. The tool matters less than understanding the three versions. Do not click "accept incoming" through a complex conflict just to make the red markers disappear.
After using a mergetool, check for backup files such as .orig:
git status --short
You can disable mergetool backup files globally if you do not want them:
git config --global mergetool.keepBackup false
Strategy Options Are Not Magic
You may see advice like:
git merge -X theirs feature
This does not mean "replace my branch with feature." It means that when Git's merge strategy sees conflicting hunks, it should prefer the other side for those hunks. Non-conflicting changes from both branches are still merged. That can be useful for generated lockfiles or mechanical formatting conflicts, but it is risky for business logic.
-X ours and -X theirs are strategy options. The ours merge strategy is different:
git merge -s ours old-branch
That records a merge while keeping the current tree. It is a specialized tool, often used to mark a branch as merged without taking its content. Do not use it for normal conflict resolution unless you are very sure.
Rebase Conflicts
During rebase, Git replays commits one at a time. That means you may resolve several smaller conflicts instead of one large merge conflict.
The loop is:
git status
# edit files
git add resolved-file
git rebase --continue
If a commit being replayed is no longer needed because the new base already contains the change, use:
git rebase --skip
Use skip carefully. It drops that commit from the rebased branch. Read the commit first:
git show
Again, --ours and --theirs can be confusing in rebase. Inspect :2: and :3: when in doubt.
Test the Resolution, Not Just the Merge
A merge can be syntactically resolved and still be wrong. After staging files, run tests that touch the changed area. For a frontend conflict, that may be a typecheck and a focused component test. For a backend conflict, it may be one service test or migration check. For a lockfile conflict, reinstall dependencies and run the package manager's verification command.
At minimum:
git diff --check
git grep -n '<<<<<<<\|=======\|>>>>>>>'
Then run the project-specific check that would have caught a bad combination.
Reduce Future Conflicts
The best conflict is the one you never create. Keep branches short-lived, rebase or merge from the main branch regularly, and avoid mixing mechanical changes with feature changes. A formatting-only PR should not also change logic. A file move should not also rewrite the file if you can avoid it.
For files that are always painful, consider ownership or structure changes. Large configuration files, generated snapshots, lockfiles, migration lists, and central route registries often create repeat conflicts because everyone edits the same area. Sometimes the fix is process. Sometimes the fix is splitting the file or generating it from smaller sources.
Use .gitattributes for files that need special merge behavior. For example, some generated lockfiles may have package-manager-specific merge drivers. Do not invent one casually, but do check whether your ecosystem has a recommended driver.
Merge conflicts are part technical work and part communication. If you do not understand the other branch's intent, ask. Ten minutes with the author is cheaper than silently merging code that passes tests but removes the feature they were building.
Lockfiles, Migrations, and Other High-Friction Files
Some files conflict more often because many branches edit the same small area. Dependency lockfiles are a common example. If two branches add packages, the lockfile conflict may be technically large but conceptually simple: regenerate it with the package manager after resolving the manifest file.
For a Node project, that might mean resolving package.json, then running the package manager that owns the lockfile:
npm install
# or pnpm install
# or yarn install
Then stage both the manifest and lockfile. Do not hand-edit a complex lockfile unless you understand its format. The package manager is less likely to make a subtle dependency graph mistake.
Database migrations need more care. If two branches create migrations with ordering assumptions, accepting both files may not be enough. Check the migration timestamps, sequence numbers, dependencies, and whether both migrations modify the same table or data. Sometimes the right resolution is a new follow-up migration that reconciles the two branches.
Generated snapshots and golden files have the same pattern: resolve the source change first, regenerate the output, then review the generated diff. If the generated diff is enormous, ask whether it belongs in the same merge commit. Huge generated changes can hide a bad manual resolution.
When a conflict spans files, write down the intended final behavior before editing. A short note like "keep the new validation from feature A, keep the renamed service from feature B, regenerate client types" prevents you from resolving each file locally while losing the overall design.
For especially risky merges, create a temporary branch before starting:
git switch -c merge-test/main-with-feature
git merge feature
If the resolution gets messy, you can abandon the temporary branch without disturbing your original branch. That small habit makes difficult conflicts less stressful because you always have a clean way back.
Review the Final Merge Like a Change of Its Own
A conflict resolution is new work. Treat it that way in review. The final diff should show not only both branches' changes, but also any glue code you wrote to make them work together. If the merge commit is large, explain the resolution in the commit message or pull request comment. Reviewers should not have to reverse-engineer why one side was chosen.
Before pushing, compare the final result against both parents when possible:
git diff HEAD^1..HEAD -- path/to/file
git diff HEAD^2..HEAD -- path/to/file
For an uncommitted merge, inspect staged changes:
git diff --cached
Look for accidental deletion of tests, imports that are no longer used, duplicated config entries, and code paths where both branches added similar logic under different names. These are the mistakes Git cannot identify for you.
If the conflict involved behavior, add or update a test that would fail if you had chosen the wrong side. That test does more than prove today's merge. It protects the decision from being undone in the next refactor.