Conflict Resolution Patterns and Strategies
A practical guide to resolving Git merge conflicts effectively, covering command-line and visual tools, package-lock.json handling, rerere, conflict prevention, and automation strategies.
Conflict Resolution Patterns and Strategies
Merge conflicts are not bugs. They are Git telling you that two humans made decisions about the same code and it needs a human to reconcile them. Understanding conflict resolution deeply — not just clicking "Accept Incoming" — separates engineers who use Git from engineers who fight it.
This guide covers the full spectrum: reading conflict markers, choosing the right tools, handling generated files like package-lock.json, automating what you can, and preventing conflicts before they happen.
Prerequisites
- Git 2.30+ installed
- Comfortable with branching and merging basics
- A Node.js project with at least a few collaborators
- Optional: VS Code, Beyond Compare, or Meld installed
Understanding Conflict Markers
When Git cannot automatically merge two changes, it writes conflict markers directly into the file. Every developer has seen these, but not every developer reads them carefully.
<<<<<<< HEAD
var port = process.env.PORT || 3000;
=======
var port = process.env.PORT || 8080;
>>>>>>> feature/update-port
Three markers define the conflict:
<<<<<<< HEAD— Start of your current branch's version (what you have checked out)=======— Divider between the two versions>>>>>>> feature/update-port— End marker showing the incoming branch name
The key insight: HEAD is always your side. Whether you are merging or rebasing, the content between <<<<<<< and ======= is what is currently in your working tree. The content between ======= and >>>>>>> is what is being brought in.
Multi-way Conflicts with diff3
The default two-way conflict view hides critical information: what the code looked like before either side changed it. Enable diff3 style to see the common ancestor:
git config --global merge.conflictstyle diff3
Now conflicts show three sections:
<<<<<<< HEAD
var port = process.env.PORT || 3000;
||||||| merged common ancestors
var port = 8080;
=======
var port = process.env.PORT || 8080;
>>>>>>> feature/update-port
The ||||||| section shows the original. Now you can see that HEAD added process.env.PORT || and the feature branch kept the same pattern but changed the default. The correct resolution is obvious: keep both changes.
var port = process.env.PORT || 8080;
I recommend diff3 for every developer. Set it globally and never look back.
Merge Conflicts vs Rebase Conflicts
Both operations produce conflicts, but they behave differently and require different mental models.
Merge Conflicts
A merge creates a single merge commit. All conflicts appear at once. You resolve everything in one pass.
git checkout main
git merge feature/auth-system
# Conflicts appear — resolve all at once
git add .
git commit
If you have 5 conflicting files, you see all 5 immediately. This is straightforward but can feel overwhelming on large branches.
Rebase Conflicts
A rebase replays your commits one at a time on top of the target branch. Conflicts can appear at every commit in your branch.
git checkout feature/auth-system
git rebase main
# Conflict at commit 1 of 12
# Resolve, then:
git add .
git rebase --continue
# Conflict at commit 4 of 12
# Resolve again...
Rebase conflicts are smaller individually but can be repetitive. If commit 3 changes a function signature and commit 7 modifies the same function, you may resolve the same area twice.
When to prefer merge: Large branches with many conflicts. Team branches where multiple people have committed. When you want to preserve the exact history.
When to prefer rebase: Small feature branches. Solo work that you want to linearize. When the branch is behind main by many commits and you want a clean history.
Aborting Safely
If a merge or rebase goes sideways, bail out cleanly:
# Abort a merge in progress
git merge --abort
# Abort a rebase in progress
git rebase --abort
Both commands return your working tree to the state before you started. No damage done.
Resolving Conflicts with Command-Line Tools
Manual Resolution
The most fundamental approach. Open the file, read the markers, edit the code, remove the markers.
# See which files have conflicts
git status
# Output:
# Unmerged paths:
# both modified: src/server.js
# both modified: src/config.js
# both modified: package-lock.json
Open each file, find the markers, decide what the correct code should be, and remove the markers entirely. Then stage the resolved files:
git add src/server.js src/config.js
Using git checkout for Wholesale Resolution
When you know one side is entirely correct, skip the manual editing:
# Accept YOUR version (current branch) for a file
git checkout --ours src/config.js
# Accept THEIR version (incoming branch) for a file
git checkout --theirs src/config.js
# Stage the resolved file
git add src/config.js
Warning during rebase: The meaning of --ours and --theirs flips during rebase. In a rebase, --ours refers to the branch you are rebasing onto (usually main), and --theirs refers to your feature branch commits. This trips up almost everyone at least once.
Using git mergetool
Git ships with a built-in command that launches your configured merge tool for each conflicting file:
git mergetool
Configure your preferred tool:
# Set VS Code as the merge tool
git config --global merge.tool vscode
git config --global mergetool.vscode.cmd 'code --wait --merge $REMOTE $LOCAL $BASE $MERGED'
# Set Beyond Compare
git config --global merge.tool bc
git config --global mergetool.bc.path "C:/Program Files/Beyond Compare 4/bcomp.exe"
# Set Meld
git config --global merge.tool meld
After resolving, git mergetool creates .orig backup files. Disable them:
git config --global mergetool.keepBackup false
Visual Merge Tools
VS Code
VS Code has the best built-in merge editor in the business for a free tool. When you open a conflicting file, it shows inline annotations:
- Accept Current Change — Keep your version
- Accept Incoming Change — Keep their version
- Accept Both Changes — Concatenate both (rarely what you actually want)
- Compare Changes — Side-by-side diff
For complex conflicts, use the three-way merge editor (available since VS Code 1.70+):
- Open the conflicting file
- Click "Resolve in Merge Editor" in the notification bar
- You get three panes: Incoming (left), Current (right), Result (bottom)
- Check boxes to accept individual changes or edit the result directly
The three-way editor is the right tool when a conflict spans many lines or involves structural changes.
Beyond Compare
Beyond Compare is the gold standard for paid merge tools. Its three-way merge view is exceptional for complex conflicts. Configure it in Git:
git config --global merge.tool bc
git config --global mergetool.bc.path "/usr/local/bin/bcomp"
git config --global diff.tool bc
git config --global difftool.bc.path "/usr/local/bin/bcomp"
Beyond Compare handles binary files, images, and even structured data comparisons. Worth the license fee if you work on projects with frequent conflicts.
Meld
Meld is the best free GUI merge tool on Linux. It provides a clean three-pane view and handles Unicode well. On macOS, install via Homebrew:
brew install meld
Handling package-lock.json and yarn.lock Conflicts
This is where most developers waste the most time. Lock file conflicts look terrifying — thousands of lines of JSON diffs. The solution is simple: never manually resolve lock file conflicts.
package-lock.json Strategy
# When you hit a conflict in package-lock.json:
# 1. Accept either version (doesn't matter which)
git checkout --theirs package-lock.json
# 2. Regenerate the lock file from the merged package.json
npm install
# 3. Stage both files
git add package.json package-lock.json
The lock file is a derived artifact of package.json. Once package.json is correctly merged, regenerating the lock file produces the correct result.
yarn.lock Strategy
Same principle, different command:
git checkout --theirs yarn.lock
yarn install
git add package.json yarn.lock
Automating Lock File Resolution with Git Attributes
You can tell Git how to handle lock files automatically using a custom merge driver:
# .gitattributes
package-lock.json merge=npm-merge
yarn.lock merge=yarn-merge
# Configure the custom merge drivers
git config merge.npm-merge.name "Regenerate package-lock.json"
git config merge.npm-merge.driver "cp %A %A.bak && git checkout --theirs %A && npm install && git add package-lock.json"
# Simpler approach: just accept theirs and regenerate
git config merge.npm-merge.driver "npm install --package-lock-only"
In practice, I find the manual three-step approach more reliable than automated drivers. Lock file merge drivers can behave unpredictably in CI environments.
Resolving Conflicts in Generated Files
Generated files — bundled assets, compiled output, auto-generated API clients — should never have their conflicts manually resolved.
The Rule
If a file is generated by a build step, resolve the conflict by re-running the build step.
# Example: Prisma client conflicts
git checkout --theirs prisma/schema.prisma # or resolve manually
npx prisma generate
git add prisma/
# Example: OpenAPI generated client
git checkout --theirs openapi.yaml # or resolve manually
npm run generate-api-client
git add src/generated/
Should Generated Files Be in Git?
Honestly, most should not be. Add them to .gitignore and generate them during CI/CD. But if your team has decided to track them (which is common for lock files and Prisma schemas), the regenerate-after-resolve pattern is the way.
Rerere: Reuse Recorded Resolution
rerere stands for "reuse recorded resolution." It is one of Git's most underused features. When enabled, Git remembers how you resolved a conflict and automatically applies the same resolution if it encounters the same conflict again.
Enabling Rerere
git config --global rerere.enabled true
How It Works
- You encounter a conflict and resolve it manually.
- Git records the pre-image (the conflict) and post-image (your resolution).
- Next time Git sees the exact same conflict, it applies your recorded resolution automatically.
This is invaluable during long-running feature branches where you rebase frequently. Without rerere, you resolve the same conflicts every time you rebase. With rerere, you resolve once and Git handles it going forward.
Managing Recorded Resolutions
# See recorded resolutions
git rerere status
# See the diff of a recorded resolution
git rerere diff
# Clear all recorded resolutions (nuclear option)
git rerere forget --all
# Clear the resolution for a specific file
git rerere forget src/server.js
Rerere and Rebase Workflows
Rerere shines in teams that use rebase-heavy workflows. Consider this scenario:
- You rebase your feature branch onto main, resolving 3 conflicts.
- You push for code review.
- Reviewer requests changes. You make new commits.
- You rebase again onto updated main.
- Without
rerere: you resolve the same 3 conflicts again. - With
rerere: Git auto-applies the previous resolutions. You only handle new conflicts.
Enable it. There is no downside.
Conflict Prevention Strategies
The best conflict is one that never happens. These strategies reduce conflict frequency dramatically.
Small, Focused Pull Requests
A PR that touches 3 files over 2 days will rarely conflict. A PR that touches 40 files over 3 weeks will almost certainly conflict. Keep branches short-lived and tightly scoped.
# Good: focused branch
git checkout -b fix/validate-email-input
# Touch: src/validators/email.js, tests/validators/email.test.js
# Lifespan: 1 day
# Bad: sprawling branch
git checkout -b feature/redesign-user-system
# Touch: 47 files across 6 directories
# Lifespan: 3 weeks
Rebase Before Merging
Always rebase your feature branch onto the target before opening a PR:
git fetch origin
git rebase origin/main
# Resolve any conflicts now, before the PR
git push --force-with-lease
--force-with-lease is safer than --force — it refuses to overwrite remote changes that you have not pulled.
Communication and File Ownership
If two developers are working on the same file simultaneously, talk to each other. Formalize this with a CODEOWNERS file:
# .github/CODEOWNERS
src/auth/ @alice
src/payments/ @bob
src/shared/utils/ @alice @bob
package.json @alice @bob
CODEOWNERS does not prevent conflicts, but it ensures the right people review changes to shared code, which reduces conflicting approaches.
Consistent Formatting
Adopt a formatter (Prettier, ESLint with --fix) and enforce it in CI. Formatting conflicts are the most wasteful kind — they carry zero semantic value.
{
"scripts": {
"format": "prettier --write \"src/**/*.js\"",
"format:check": "prettier --check \"src/**/*.js\""
}
}
Run the formatter before every commit. Use a pre-commit hook:
npx husky add .husky/pre-commit "npx pretty-quick --staged"
Merge Strategies
Git supports several merge strategies that control how conflicts are handled at a high level.
Recursive (Default)
The standard strategy for merging two branches. It finds a common ancestor and performs a three-way merge.
git merge feature/auth
# Equivalent to:
git merge -s recursive feature/auth
Ours
Completely ignores the incoming branch's changes. The merge commit exists, but only your side's content survives. This is useful for "we considered this branch and decided not to take it" situations.
git merge -s ours feature/abandoned-experiment
Do not confuse -s ours (strategy) with -X ours (strategy option). The -X ours option uses the recursive strategy but automatically resolves conflicts by picking your side:
# Strategy: ignore their branch entirely
git merge -s ours feature/branch
# Strategy option: merge normally, but when conflicts occur, pick our side
git merge -X ours feature/branch
Theirs (Strategy Option)
There is no -s theirs strategy, but the -X theirs option resolves all conflicts in favor of the incoming branch:
git merge -X theirs feature/branch
This is useful when you know the other branch is authoritative and any conflicts should be resolved in its favor.
Subtree
Used for merging repositories or subdirectory merges. Rarely needed in day-to-day work.
git merge -s subtree --prefix=libs/external feature/vendor-update
Handling Complex Conflicts
Renamed Files
When one branch renames a file and another modifies it, Git usually detects this and merges correctly. But sometimes it fails:
# Branch A renames: src/utils.js -> src/helpers/utils.js
# Branch B modifies: src/utils.js
git merge branch-a
# CONFLICT (modify/delete): src/utils.js deleted in branch-a
# and modified in HEAD.
Git sees a delete + create instead of a rename. Resolve manually:
# Check what branch-a did
git diff main...branch-a -- src/utils.js src/helpers/utils.js
# Apply your modifications to the new location
git show HEAD:src/utils.js > src/helpers/utils.js
# Edit src/helpers/utils.js to incorporate branch-a's changes
git rm src/utils.js
git add src/helpers/utils.js
Moved Code Blocks
When two branches move the same code to different locations within a file, Git produces a conflict that looks like duplication. The resolution requires understanding what both sides intended:
<<<<<<< HEAD
function validateInput(data) {
// moved to top of file for visibility
if (!data.email) throw new Error("Email required");
if (!data.name) throw new Error("Name required");
}
module.exports = { validateInput, processForm };
=======
module.exports = { processForm, validateInput };
// validation logic grouped at bottom
function validateInput(data) {
if (!data.email) throw new Error("Email required");
if (!data.name) throw new Error("Name required");
if (!data.password) throw new Error("Password required");
}
>>>>>>> feature/add-password
Here, HEAD moved the function to the top and the feature branch added password validation. The correct resolution combines both: the function at its new location with the added validation.
Conflict Resolution in Monorepos
Monorepos amplify conflict frequency because more developers touch overlapping files. Specific patterns help.
Root-Level Configuration Conflicts
Files like package.json, tsconfig.json, and CI configs at the repo root are conflict magnets. Mitigate this:
# Use workspace-level configs that extend root
# packages/api/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist"
}
}
Each package extends the base config. Developers modify their package config, not the shared root.
Shared Dependency Version Conflicts
In monorepos with shared package.json files, version bumps cause frequent conflicts:
<<<<<<< HEAD
"express": "^4.18.2",
=======
"express": "^4.19.0",
>>>>>>> feature/upgrade-deps
Resolution: always take the higher version (unless there is a specific reason not to). Automate this with a merge driver:
# .gitattributes
package.json merge=union
The union merge driver keeps both sides' additions. It works for simple cases but can produce invalid JSON on complex conflicts. Use with caution.
CI Configuration Conflicts
When multiple developers add CI jobs simultaneously:
# .github/workflows/ci.yml
jobs:
test:
runs-on: ubuntu-latest
<<<<<<< HEAD
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- run: npm test
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm run lint
=======
steps:
- uses: actions/checkout@v4
- run: npm test
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm audit
>>>>>>> feature/add-security
Resolution: accept both new jobs and the timeout addition. YAML conflicts require careful attention to indentation.
Automating Conflict Resolution for Specific File Types
Custom Merge Drivers
Git lets you define custom merge drivers in .gitattributes for any file pattern:
# .gitattributes
*.generated.js merge=regenerate
db/schema.sql merge=migrate
package-lock.json merge=npm-lock
# Configure drivers in .git/config or global config
git config merge.regenerate.driver "npm run generate -- %A"
git config merge.npm-lock.driver "npm install --package-lock-only"
Script-Based Resolution
For teams with predictable conflict patterns, a resolution script can save hours:
// scripts/resolve-conflicts.js
var fs = require("fs");
var path = require("path");
var execSync = require("child_process").execSync;
function getConflictedFiles() {
var output = execSync("git diff --name-only --diff-filter=U").toString();
return output.trim().split("\n").filter(Boolean);
}
function resolveFile(filePath) {
var ext = path.extname(filePath);
var basename = path.basename(filePath);
// Lock files: regenerate
if (basename === "package-lock.json") {
console.log("Regenerating package-lock.json...");
execSync("git checkout --theirs package-lock.json");
execSync("npm install --package-lock-only");
execSync("git add package-lock.json");
return true;
}
if (basename === "yarn.lock") {
console.log("Regenerating yarn.lock...");
execSync("git checkout --theirs yarn.lock");
execSync("yarn install --mode update-lockfile");
execSync("git add yarn.lock");
return true;
}
// Generated files: regenerate
if (filePath.match(/\.generated\.(js|ts|json)$/)) {
console.log("Skipping generated file (will regenerate): " + filePath);
execSync("git checkout --theirs \"" + filePath + "\"");
execSync("git add \"" + filePath + "\"");
return true;
}
return false;
}
var files = getConflictedFiles();
var resolved = 0;
var manual = [];
files.forEach(function(file) {
if (resolveFile(file)) {
resolved++;
} else {
manual.push(file);
}
});
console.log("\nResolved automatically: " + resolved);
if (manual.length > 0) {
console.log("Requires manual resolution:");
manual.forEach(function(f) { console.log(" - " + f); });
}
# Run after a merge with conflicts
node scripts/resolve-conflicts.js
# Then manually resolve remaining files
Complete Working Example
Let us walk through resolving realistic conflicts in a Node.js project. We will create a scenario with three types of conflicts: code, package-lock.json, and configuration.
Setup
mkdir conflict-demo && cd conflict-demo
git init
Create the initial project:
npm init -y
npm install express dotenv
// server.js
var express = require("express");
var app = express();
var port = process.env.PORT || 3000;
app.get("/", function(req, res) {
res.json({ status: "ok", version: "1.0.0" });
});
app.get("/health", function(req, res) {
res.json({ healthy: true });
});
app.listen(port, function() {
console.log("Server running on port " + port);
});
// config.js
var config = {
appName: "conflict-demo",
logLevel: "info",
maxRetries: 3
};
module.exports = config;
git add -A
git commit -m "Initial commit"
Create Diverging Branches
Branch A: Add authentication middleware
git checkout -b feature/auth
// server.js (modified on feature/auth)
var express = require("express");
var helmet = require("helmet");
var app = express();
var port = process.env.PORT || 3000;
app.use(helmet());
app.get("/", function(req, res) {
res.json({ status: "ok", version: "1.1.0" });
});
app.get("/health", function(req, res) {
res.json({ healthy: true, uptime: process.uptime() });
});
app.get("/auth/login", function(req, res) {
res.json({ message: "Login endpoint" });
});
app.listen(port, function() {
console.log("Server running on port " + port);
});
// config.js (modified on feature/auth)
var config = {
appName: "conflict-demo",
logLevel: "info",
maxRetries: 3,
sessionSecret: process.env.SESSION_SECRET || "dev-secret",
tokenExpiry: 3600
};
module.exports = config;
npm install helmet
git add -A
git commit -m "Add auth middleware and helmet"
Branch B: Add monitoring and update config
git checkout main
git checkout -b feature/monitoring
// server.js (modified on feature/monitoring)
var express = require("express");
var morgan = require("morgan");
var app = express();
var port = process.env.PORT || 8080;
app.use(morgan("combined"));
app.get("/", function(req, res) {
res.json({ status: "ok", version: "1.2.0" });
});
app.get("/health", function(req, res) {
res.json({ healthy: true, timestamp: Date.now() });
});
app.get("/metrics", function(req, res) {
res.json({ requests: 0, errors: 0 });
});
app.listen(port, function() {
console.log("Server running on port " + port);
});
// config.js (modified on feature/monitoring)
var config = {
appName: "conflict-demo",
logLevel: "debug",
maxRetries: 5,
metricsInterval: 30000
};
module.exports = config;
npm install morgan
git add -A
git commit -m "Add monitoring with morgan and metrics"
Merge and Resolve
# Merge auth into main first (no conflicts)
git checkout main
git merge feature/auth
# Fast-forward, clean merge
# Now merge monitoring (conflicts!)
git merge feature/monitoring
Expected output:
Auto-merging server.js
CONFLICT (content): Merge conflict in server.js
Auto-merging package.json
CONFLICT (content): Merge conflict in package.json
Auto-merging package-lock.json
CONFLICT (content): Merge conflict in package-lock.json
Auto-merging config.js
CONFLICT (content): Merge conflict in config.js
Automatic merge failed; fix conflicts and then commit the result.
Step 1: Resolve package-lock.json (30 seconds)
Do not read the lock file diff. Just regenerate.
git checkout --theirs package-lock.json
# Ensure package.json is resolved first (accept both dependencies)
# Then regenerate:
npm install
git add package.json package-lock.json
Step 2: Resolve config.js (CLI approach)
Open config.js and see:
var config = {
appName: "conflict-demo",
<<<<<<< HEAD
logLevel: "info",
maxRetries: 3,
sessionSecret: process.env.SESSION_SECRET || "dev-secret",
tokenExpiry: 3600
||||||| merged common ancestors
logLevel: "info",
maxRetries: 3
=======
logLevel: "debug",
maxRetries: 5,
metricsInterval: 30000
>>>>>>> feature/monitoring
};
module.exports = config;
With diff3, the common ancestor shows us: both sides changed logLevel and maxRetries, and each added new fields. The correct resolution keeps monitoring's logLevel and maxRetries (they are the later intent) and preserves both additions:
var config = {
appName: "conflict-demo",
logLevel: "debug",
maxRetries: 5,
sessionSecret: process.env.SESSION_SECRET || "dev-secret",
tokenExpiry: 3600,
metricsInterval: 30000
};
module.exports = config;
git add config.js
Step 3: Resolve server.js (VS Code approach)
Open server.js in VS Code. Use the three-way merge editor:
- Click "Resolve in Merge Editor"
- Accept helmet middleware from Current (auth branch)
- Accept morgan middleware from Incoming (monitoring branch)
- For the version number, manually type
"1.3.0"(both features combined) - Accept the health endpoint from Incoming (it has the timestamp)
- Accept both new routes:
/auth/loginfrom Current,/metricsfrom Incoming - For the port, decide: use
process.env.PORT || 8080(monitoring's default)
Final resolved server.js:
var express = require("express");
var helmet = require("helmet");
var morgan = require("morgan");
var app = express();
var port = process.env.PORT || 8080;
app.use(helmet());
app.use(morgan("combined"));
app.get("/", function(req, res) {
res.json({ status: "ok", version: "1.3.0" });
});
app.get("/health", function(req, res) {
res.json({ healthy: true, uptime: process.uptime(), timestamp: Date.now() });
});
app.get("/auth/login", function(req, res) {
res.json({ message: "Login endpoint" });
});
app.get("/metrics", function(req, res) {
res.json({ requests: 0, errors: 0 });
});
app.listen(port, function() {
console.log("Server running on port " + port);
});
git add server.js
Step 4: Complete the Merge
git commit -m "Merge feature/monitoring with auth changes"
Verify everything works:
node server.js
# Server running on port 8080
curl http://localhost:8080/health
# {"healthy":true,"uptime":0.234,"timestamp":1706900000000}
Total resolution time for an experienced developer: 3-5 minutes.
Common Issues and Troubleshooting
1. "fatal: Not possible to fast-forward, aborting"
fatal: Not possible to fast-forward, aborting.
This happens when you have pull.ff only set in your Git config and the remote has diverged. Fix:
# Pull with rebase instead
git pull --rebase origin main
# Or allow merge commits
git pull --no-ff origin main
# Check your config
git config pull.rebase
git config pull.ff
2. "CONFLICT (modify/delete)"
CONFLICT (modify/delete): src/old-utils.js deleted in feature/cleanup
and modified in HEAD. Version HEAD of src/old-utils.js left in tree.
One branch deleted a file that another branch modified. Decide:
# If the delete was intentional and modifications should be discarded:
git rm src/old-utils.js
git add .
# If the modifications are needed, move them to the new location:
git show HEAD:src/old-utils.js > src/new-utils.js
git rm src/old-utils.js
git add src/new-utils.js
3. "error: Your local changes to the following files would be overwritten by merge"
error: Your local changes to the following files would be overwritten by merge:
src/server.js
Please commit your changes or stash them before you merge.
You have uncommitted changes that conflict with the merge. Stash or commit first:
# Stash changes
git stash
git merge feature/branch
git stash pop
# Now resolve any conflicts between your stash and the merge
# Or commit your work-in-progress
git add -A
git commit -m "WIP: save work before merge"
git merge feature/branch
4. "Resolved but unfinished — you must edit all merge conflicts and then mark them as resolved"
error: Committing is not possible because you have unmerged files.
hint: Fix them up in the work tree, and then use 'git add/rm <file>'
hint: as appropriate to mark resolution and make a commit.
You tried to commit before staging all resolved files. Check what is still unresolved:
git status
# Look for "Unmerged paths" section
# Resolve those files, then:
git add <resolved-files>
git commit
5. Merge Conflict Markers Left in File
Sometimes you accidentally commit files with conflict markers still in them. Catch this early:
# Search for conflict markers in your codebase
git diff --check
# Pre-commit hook to prevent committing conflict markers
# .husky/pre-commit
git diff --cached --check
Add this to CI as well:
# In your CI pipeline
git diff --check HEAD~1 || (echo "Conflict markers found!" && exit 1)
Best Practices
Enable
diff3globally. The three-way conflict view with the common ancestor makes resolution dramatically easier. Rungit config --global merge.conflictstyle diff3right now.Enable
rerereglobally. There is no downside and it saves real time on rebase-heavy workflows. Rungit config --global rerere.enabled true.Never manually resolve lock files. Accept either version, regenerate with
npm installoryarn install, and move on. This alone will save you hours over the life of a project.Rebase onto the target branch before opening a PR. Resolve conflicts in your branch, not during the merge to main. This keeps main's history clean and puts the resolution burden on the feature author who best understands the changes.
Use
--force-with-leaseinstead of--force. After rebasing and needing to push,--force-with-leasewill refuse to overwrite changes that someone else pushed to your branch. It is a safety net with no downside.Keep branches short-lived. A branch that lives for 2 days will have fewer conflicts than one that lives for 2 weeks. Break large features into smaller, independently mergeable pieces.
Enforce consistent formatting with automated tools. Prettier, ESLint, and similar tools eliminate an entire class of conflicts. If everyone's code is formatted identically, formatting differences can never cause conflicts.
Communicate when touching shared files. A quick message to the team — "I'm refactoring
config.jstoday" — prevents parallel conflicting changes. UseCODEOWNERSto formalize file ownership.Run tests after resolving conflicts. A conflict resolution that produces syntactically valid but semantically wrong code is worse than a conflict. Always run your test suite after resolving:
git merge feature/branch
# Resolve conflicts
npm test
# Only commit if tests pass
git commit
- Learn your merge tool deeply. Whether it is VS Code, Beyond Compare, or
vimdiff, invest time in learning the keyboard shortcuts and workflows. The speed difference between a developer who knows their merge tool and one who does not is an order of magnitude.
References
- Git Documentation: git-merge — Official documentation for merge command and strategies
- Git Documentation: git-rerere — Reuse recorded resolution documentation
- Git Documentation: gitattributes — Custom merge drivers and file-level merge strategies
- Pro Git Book: Advanced Merging — In-depth treatment of merge strategies
- VS Code Merge Editor Documentation — Three-way merge editor guide
- Git Tower: Merge Conflicts — Practical merge conflict walkthrough
