Resolving Merge Conflicts and Using .gitignore — Two Essential Git Skills
Series: Version Control with Git Topics Covered:
Resolving Merge Conflicts&Gitignore
Table of Contents
1. What Causes a Merge Conflict?
Git is remarkably good at merging changes automatically. When two developers edit different parts of the same file — one changes the header, the other changes the footer — Git can combine both changes cleanly without any help from either developer.
But there is one situation Git cannot resolve on its own: when two developers edit the exact same lines of the same file in different ways. At that point, Git has two conflicting versions of the same piece of code and genuinely does not know which one should survive. Should it keep your version? Your colleague's version? Some combination of both? Only a human can answer that question, which is why Git stops and asks you to decide.
This situation is called a merge conflict, and it is one of the most feared moments for Git beginners. In reality, however, conflicts follow a predictable pattern, have a clear resolution process, and become routine once you have been through a few of them. The goal of this section is to walk through every step so that the next time you see a conflict, you know exactly what to do.
2. Reproducing the Problem — How Conflicts Happen in Real Teams
To understand how conflicts are resolved, it helps to first understand exactly how they are triggered. Here is an example scenario, presented as a concrete story.
You are working on a project. You open server.js in your editor, find a specific line, and change it. You stage the change and commit it locally:
git add server.js
git commit -m "Update server configuration locally"
At the same time — perhaps while you were writing your code, perhaps just moments after you committed — a colleague edits the exact same line in server.js and pushes their change directly to the remote branch. They did not know you were working on the same line. You did not know they were making changes either. This happens constantly in real teams, especially on busy branches.
Your local repository now has your version of that line. The remote repository now has your colleague's version of that same line. The two versions are different. Neither is "wrong" in isolation — both represent real decisions made by real developers. But they cannot both be the final answer. One or both of them needs to change, and only you can make that call.
3. Attempting to Push — And Getting Blocked
With your local commit ready, you try to push:
git push
Git refuses immediately:
! [rejected] bugfix/payment-error -> bugfix/payment-error (non-fast-forward)
hint: Updates were rejected because the remote contains work that you do not have locally.
hint: Integrate the remote changes before pushing again.
This is not an error so much as a safety check. Git is saying: "There are commits on the remote that your local branch does not have yet. If I let you push, those commits would be overwritten and lost. Bring those changes down first, then we can talk about pushing."
This is the correct behavior. Git is protecting your colleague's work.
4. Running git pull --rebase When There Is a Conflict
Following the best practice from the previous section, you use git pull --rebase instead of a plain git pull to avoid adding an unnecessary merge commit to your history:
git pull --rebase
This is where the conflict surfaces. Git fetches the remote commit, sets your local commit aside, and begins replaying it on top of the remote. Midway through that replay, it hits the conflicting line and stops:
CONFLICT (content): Merge conflict in server.js
error: could not apply a3f7bc2... Update server configuration locally
hint: Resolve all conflicts manually, mark them as resolved with
hint: "git add/rm <conflicted_files>", then run "git rebase --continue".
Your terminal prompt also changes to indicate you are in a special rebase-in-progress state. Nothing is broken — Git has simply paused the operation at the exact point where it needs a human decision.
Notice the helpful information Git provides even here: it tells you exactly which file contains the conflict (server.js), and it tells you exactly what to do when you are done (git add the resolved files, then git rebase --continue). Git is a very communicative tool — when something needs your attention, it tells you what, where, and what to do next.
5. What a Conflict Actually Looks Like Inside a File
Open the conflicted file — server.js in this case — in your editor. Git has inserted special markers directly into the file to show you exactly where the conflict is and what both versions look like:
<<<<<<< HEAD (remote changes)
console.log("Server started — remote version");
=======
console.log("Server started — local version");
>>>>>>> a3f7bc2 (Update server configuration locally)
Let us break down what each part means:
<<<<<<< HEAD — This marker indicates the start of the conflict. Everything between this line and the ======= line is the incoming version — the change that came from the remote branch (your colleague's work).
======= — This is the dividing line between the two conflicting versions.
>>>>>>> a3f7bc2 — This marker indicates the end of the conflict. Everything between the ======= line and this line is your local version — the change you committed locally. The a3f7bc2 is the commit hash of your local commit.
The section between <<<<<<< and ======= is what is currently in the remote. The section between ======= and >>>>>>> is what you have locally. Neither of these sections is the final answer yet — that is what you have to decide.
6. Resolving the Conflict — Your Three Options
When you open a conflicted file, you have three possible outcomes to choose from. Your job is to manually edit the file until it contains exactly the code that the final, correct version should have — then remove all the conflict markers entirely.
Option 1: Keep only the remote version (your colleague's change)
If you review the conflict and realize your colleague's version is correct — perhaps their approach is better, or your change was a mistake — keep their version and delete everything else, including all the conflict markers:
console.log("Server started — remote version");
Option 2: Keep only your local version
If your change is the correct one, keep your version and delete the remote version along with all the markers:
console.log("Server started — local version");
Option 3: Combine both versions
This is often the right answer in real conflicts — both changes were valid and both need to be in the final file. Edit the file to include both, in the order and format that makes the most sense:
console.log("Server started — remote version");
console.log("Server started — local version");
The key rule is simple: the final file must contain no conflict markers. The <<<<<<<, =======, and >>>>>>> lines are not code — they are Git's annotations. They must all be deleted before the file is considered resolved. If you accidentally leave a conflict marker in the file and commit it, that marker becomes part of your actual code, which will almost certainly cause your application to break.
How do you know which option to choose?
This is where communication comes in. When you encounter a conflict with a colleague's changes, the standard practice is to talk to them:
"Hey, I see we both changed the same line in server.js. Can you explain what your change was trying to do?"
"I see — my change was doing X. Let's figure out if we need both, or which one takes priority."
This conversation turns a technical problem into a collaborative decision. Most experienced development teams treat conflict resolution not as a Git problem but as a brief alignment meeting between developers. It takes minutes, not hours.
Using an editor for visual conflict resolution
A more visual approach: modern editors like IntelliJ IDEA, VS Code, and others have built-in conflict resolution tools that present the conflict as a three-panel view:
Left panel: the remote version
Right panel: your local version
Center/bottom panel: the final result you are building
You can click buttons to accept the left, accept the right, or accept both, and the result panel updates in real time. This visual approach makes it much harder to accidentally leave conflict markers in the file and is the preferred method for many experienced developers. The underlying concept is identical — you are making the same three-choice decision — but the interface makes it more intuitive.
7. After Resolving — Continuing the Rebase
Once you have edited the conflicted file to your satisfaction and removed all conflict markers, you need to tell Git that this file is resolved. You do that by staging it:
git add server.js
If multiple files were conflicted, resolve and stage each one. Once all conflicts across all files are resolved and staged, continue the rebase:
git rebase --continue
Git resumes the rebase operation, applies your commit on top of the resolved state, and completes the process. Your terminal prompt returns to normal, indicating the rebase is finished.
Now you can push:
git push
Your changes are now on the remote branch, cleanly combined with your colleague's changes. The conflict is resolved, the history is linear (thanks to rebase), and neither developer's work was lost.
8. Using git status as Your Guide Through Conflict Resolution
One of the most useful habits you can build during conflict resolution is to run git status frequently. At any point during a conflict, git status tells you exactly where you are in the process and what to do next.
Before resolving:
interactive rebase in progress; onto def5678
You are currently rebasing branch 'bugfix/payment-error' on 'def5678'.
(fix conflicts and then run "git rebase --continue")
(use "git rebase --skip" to skip this patch)
(use "git rebase --abort" to check out the original branch)
Unmerged paths:
(use "git restore --staged <file>..." to unstage)
(use "git add <file>..." to mark resolution)
both modified: server.js
After resolving and staging the file:
interactive rebase in progress; onto def5678
You are currently rebasing branch 'bugfix/payment-error' on 'def5678'.
(all conflicts fixed: run "git rebase --continue")
Changes to be committed:
modified: server.js
The git status output is essentially a contextual checklist that tells you what step you are on and what to do next. Lean on it heavily during any complex Git operation.
There is also an escape hatch if you get overwhelmed: git rebase --abort. This cancels the entire rebase and returns everything to the state it was in before you ran git pull --rebase. It is a safe way to step back, take a breath, and start over.
9. What Is .gitignore and Why Every Project Needs One
Now we move to the second topic: .gitignore. This is a small but critically important file that belongs in virtually every Git repository.
When Git tracks a project, it watches every single file and folder inside the repository directory. Any file you create, any folder that appears — Git knows about it and considers it a candidate for tracking. But not every file in your project folder should go into the repository. In fact, quite a few files should actively be kept out of the repository, and .gitignore is how you tell Git which ones to leave alone.
The .gitignore file is a plain text file that lives in the root of your repository. Each line contains a pattern — a file name, folder name, or wildcard — that Git should completely ignore. Files matching those patterns will never appear in git status, will never be staged accidentally with git add ., and will never be committed or pushed to the remote repository.
10. What Belongs in .gitignore
There are several clear categories of files that should almost always be excluded from a Git repository:
Editor and IDE configuration folders
When you open a project in an editor like IntelliJ IDEA, it automatically creates a hidden configuration folder — .idea/ — that stores your personal editor settings, workspace layout, local run configurations, and similar preferences. These files are specific to your machine and your editor. A colleague using VS Code or Vim has no use for them and would only be confused by them cluttering the repository.
.idea/
.vscode/
*.suo
*.user
Build output and compiled files
Running or building an application typically generates output files — compiled bytecode, transpiled JavaScript, minified CSS, cached build artifacts. These files are created automatically from the source code and can always be regenerated. Committing them wastes space in the repository, inflates clone sizes, and creates meaningless diffs every time a build runs.
build/
dist/
target/
*.class
*.pyc
Dependency directories
In JavaScript projects, npm install populates a node_modules/ folder with potentially hundreds of megabytes of downloaded packages. In Python projects, virtual environments serve a similar purpose. These dependencies are defined in a manifest file (package.json, requirements.txt, Gemfile) and can always be reinstalled from that manifest. They should never be committed.
node_modules/
vendor/
venv/
__pycache__/
Operating system metadata files
macOS automatically creates .DS_Store files in every folder it touches — these store Finder view preferences, folder icon positions, and other local display settings. Windows creates Thumbs.db for similar purposes. These are entirely machine-specific and have no place in a shared repository.
.DS_Store
Thumbs.db
Sensitive configuration and secrets
This category deserves extra emphasis because it is a security concern, not just a cleanliness concern. Files containing API keys, database passwords, environment-specific configuration values, and private credentials should never be committed to a repository — especially a public one. Use environment variables or dedicated secrets management tools instead.
.env
.env.local
config/secrets.yml
credentials.json
11. Creating and Writing a .gitignore File
Creating a .gitignore file is straightforward. In the root directory of your project, create a file named exactly .gitignore (the dot prefix is required — this is what makes it a hidden file on Unix systems):
touch .gitignore
Open it in any text editor and list the files and folders you want to exclude, one per line:
# Editor configuration
.idea/
.vscode/
# Build output
build/
dist/
# Dependencies
node_modules/
# OS files
.DS_Store
Thumbs.db
# Environment and secrets
.env
Lines beginning with # are comments and are ignored by Git. Use them freely to organize and document your ignore rules.
You can also use wildcards for pattern matching:
*.log # Ignore all files ending in .log
*.tmp # Ignore all temporary files
logs/ # Ignore an entire folder called logs
!important.log # Exception: do NOT ignore this specific log file
The exclamation mark (!) at the start of a line is a negation pattern — it un-ignores something that would otherwise be ignored by a broader rule above it.
Once you save the .gitignore file, commit and push it:
git add .gitignore
git commit -m "Add .gitignore to exclude editor, build, and dependency files"
git push
From this point on, every file and folder matching your patterns will be invisible to Git. Running git status will not show them, and git add . will not stage them.
12. The Catch — Files Already Tracked by Git
Here is an important caveat that catches many beginners off guard: .gitignore only affects files that Git is not already tracking.
If you created a file, committed it, and then later decided you wanted to ignore it, simply adding it to .gitignore will not be enough. Git is already watching that file. It has a record of it. Adding the file name to .gitignore tells Git not to start tracking any new files matching that pattern — but it does not un-track files that are already in the repository's history.
You will notice this immediately when you run git status after adding a previously-committed file to .gitignore. Instead of seeing nothing (the file being silently ignored), you will see the file listed as modified. Git is still watching it, still reporting changes in it, and still expecting you to commit any updates.
This is what happens: the .idea/ folder and node_modules/ were already committed and pushed to the remote before .gitignore was created. Adding them to .gitignore alone would not stop Git from tracking them.
The fix is a two-step process, covered next.
13. Removing Already-Tracked Files from Git's Cache
To stop tracking a file or folder that Git is already watching, you remove it from Git's tracking cache without deleting it from your local filesystem. The command for this is git rm --cached:
# Remove a folder from Git's tracking cache (recursively)
git rm -r --cached .idea/
# Remove a specific file from Git's tracking cache
git rm --cached .DS_Store
# Remove node_modules from Git's tracking cache
git rm -r --cached node_modules/
Let us break down the flags:
rstands for recursive. When removing a folder, Git needs this flag to remove all files inside it, not just the folder itself.-cachedis the critical part. Without it,git rmwould delete the file from your local filesystem entirely. With-cached, it only removes the file from Git's tracking index — the file remains on your computer, untouched. You just stop committing it.
After running this command, check git status. You will see the file or folder marked as "deleted" — Git is staging the removal of this file from the repository. Commit that deletion:
git add .gitignore
git commit -m "Remove .idea folder and node_modules from repository tracking"
git push
When the remote repository receives this commit, it will delete the folder from the remote. Go to GitLab and refresh the repository — the .idea/ folder and node_modules/ folder are gone from the remote. They still exist on your local machine, inside your project folder, but Git no longer tracks them. Future changes to those folders will be completely invisible to Git, exactly as intended.
The complete workflow for adding an already-tracked item to .gitignore
# Step 1: Add the item to .gitignore
echo "node_modules/" >> .gitignore
# Step 2: Remove it from Git's tracking cache
git rm -r --cached node_modules/
# Step 3: Stage both the .gitignore change and the deletion
git add .gitignore
# Step 4: Commit with a clear message
git commit -m "Exclude node_modules from Git tracking"
# Step 5: Push to remote
git push
After this, the folder is gone from the remote repository but still present on your local machine. Any developer who clones the repository fresh will not receive the folder — they will install dependencies locally using npm install (or the appropriate equivalent for their project), which is exactly the correct behavior.
14. Quick Reference: Commands Covered in This Section
Conflict resolution
# Trigger: attempt to push when remote has diverged
git push
# If rejected, pull with rebase
git pull --rebase
# During a conflict: check which files have conflicts
git status
# After manually resolving conflicts in each file, stage them
git add server.js
git add another-conflicted-file.js
# Continue the rebase after all conflicts are resolved
git rebase --continue
# If you want to give up and start over (safe escape hatch)
git rebase --abort
Working with .gitignore
# Create the .gitignore file
touch .gitignore
# Add entries to .gitignore (or edit in any text editor)
echo "node_modules/" >> .gitignore
echo ".idea/" >> .gitignore
echo ".DS_Store" >> .gitignore
echo ".env" >> .gitignore
# Remove a folder from Git's tracking cache (without deleting local files)
git rm -r --cached folder-name/
# Remove a file from Git's tracking cache
git rm --cached filename
# Stage and commit the changes
git add .gitignore
git commit -m "Add .gitignore and remove previously tracked files"
git push
Key Takeaways
Merge conflicts happen when two developers edit the same lines of the same file differently. Git cannot decide which version to keep, so it pauses and asks you to resolve it manually.
The conflict markers in a file — <<<<<<<, =======, >>>>>>> — are not code. They are Git's annotations. Your job is to edit the file until it contains only the correct final content, with all markers removed.
You have three resolution options: keep the incoming (remote) change, keep your local change, or combine both. The right choice depends on the context — talk to your colleague when in doubt.
git rebase --continue resumes the process after all conflicts are resolved and staged. git rebase --abort is your safe exit if you need to start over.
git status is your navigation tool during conflict resolution. It tells you which files still have conflicts, which are resolved, and what command to run next.
.gitignore tells Git which files and folders to never track. Every project needs one. Use it to exclude editor configuration, build output, dependency directories, OS files, and secrets.
Adding a file to .gitignore does not un-track it if Git is already tracking it. You must first run git rm --cached filename to remove it from Git's tracking cache, then commit that removal. The --cached flag ensures the file is only removed from tracking, not deleted from your local machine.
Up next in the series: Git Statsh & Going back in History.