Introduction: When Things Go Sideways

Welcome to Chapter 15! So far, we’ve explored the incredible power of Git and GitHub for managing code, collaborating with teams, and building amazing projects. But let’s be honest: even the most experienced developers sometimes face a hiccup or two. Git, while powerful, can sometimes feel a bit like a puzzle when things don’t go exactly as planned.

This chapter is your trusty toolkit for those “uh-oh” moments. We’re going to dive deep into diagnosing and fixing the most common Git and GitHub issues you’ll encounter in real-world development. From dreaded merge conflicts to accidental changes and mysterious “detached HEAD” states, we’ll cover it all. Our goal isn’t just to give you solutions, but to help you understand why these problems occur and how to confidently navigate them yourself.

Before we jump in, make sure you’re comfortable with the core Git concepts we’ve covered: committing, branching, merging, rebasing, and interacting with remote repositories. These fundamentals are your foundation for effective troubleshooting. Ready to become a Git problem-solving guru? Let’s get started!

Core Concepts: Understanding Git’s Safety Nets

One of Git’s unsung heroes is its robust safety net. It’s designed to make it very hard to permanently lose work, even when you make mistakes. Understanding how Git tracks changes and references (like branches and commits) is key to troubleshooting. Most “problems” are just situations where Git needs your explicit instructions to proceed.

The Dreaded Merge Conflict: A Clash of Changes

Merge conflicts occur when Git attempts to combine two divergent sets of changes and finds that both sets have modified the same lines in the same file, or one has deleted a file that the other has modified. Git is smart, but it can’t read minds – it doesn’t know which change you intend to keep. This is where you, the developer, step in to resolve the ambiguity.

Why do they happen? Imagine Alice and Bob both edit line 10 of index.html. Alice changes it to “Hello World!” and Bob changes it to “Welcome Home!”. When their branches merge, Git sees two different changes to the same line and pauses, asking you to decide.

How Git presents a conflict: When a merge conflict occurs, Git will tell you which files are conflicted. If you open a conflicted file, you’ll see special markers:

<<<<<<< HEAD
This is the line from your current branch (HEAD).
=======
This is the line from the branch you're trying to merge.
>>>>>>> feature-branch-name
  • <<<<<<< HEAD: Marks the beginning of the conflicting change from your current branch (where HEAD is pointing).
  • =======: Separates the changes from the two branches.
  • >>>>>>> feature-branch-name: Marks the end of the conflicting change from the other branch (the one you’re merging in).

Let’s visualize the merge conflict resolution process:

flowchart TD A["Start Merge/Rebase"] --> B{"Conflict Detected?"}; B -- Yes --> C["Git Pauses, Marks Conflicts"]; C --> D["Open Conflicted Files"]; D --> E["Manually Edit Files"]; E --> F["Remove Conflict Markers"]; F --> G["Choose Desired Code"]; G --> H["Save Files"]; H --> I["git add <file>"]; I --> J["Repeat for All Conflicted Files"]; J --> K["git commit -m \"Resolved merge conflict\""]; K --> L["Merge/Rebase Complete"]; B -- No --> L;

Figure 15.1: Flowchart of Merge Conflict Resolution

Step-by-Step: Resolving a Merge Conflict

Let’s simulate a conflict and resolve it.

  1. Preparation: First, ensure you have a clean working directory. If you have any pending changes, commit or stash them.

    git status
    # Should say "nothing to commit, working tree clean"
    

    Create a new repository and make an initial commit:

    mkdir git-troubleshooting
    cd git-troubleshooting
    git init
    echo "Initial content" > file.txt
    git add file.txt
    git commit -m "Initial commit"
    
  2. Create Divergent Branches: Now, let’s create two branches, feature-alice and feature-bob, and make conflicting changes.

    # Create and switch to Alice's branch
    git switch -c feature-alice
    echo "Alice's important change on line 2" >> file.txt
    echo "Common line 3" >> file.txt # This line will also be changed by Bob
    git add file.txt
    git commit -m "Alice adds her feature"
    
    # Switch back to main and create Bob's branch
    git switch main
    git switch -c feature-bob
    echo "Bob's critical change on line 2" >> file.txt
    echo "Bob's version of common line 3" >> file.txt # Bob's change
    git add file.txt
    git commit -m "Bob adds his feature"
    

    What just happened? We’ve created two separate timelines where both Alice and Bob modified file.txt in a way that Git can’t automatically reconcile. Specifically, they both added a second line, and both modified the third line.

  3. Initiate the Merge and Encounter Conflict: Let’s try to merge feature-bob into main, then feature-alice into main. We’ll start by merging Bob’s changes into main (which should be clean).

    git switch main
    git merge feature-bob
    

    This should merge cleanly. Now, let’s try to merge feature-alice into main.

    git merge feature-alice
    

    You should now see output similar to this:

    Auto-merging file.txt
    CONFLICT (content): Merge conflict in file.txt
    Automatic merge failed; fix conflicts and then commit the result.
    

    Aha! A merge conflict!

  4. Inspect the Conflict: Use git status to see which files are conflicted:

    git status
    

    Output will show file.txt as both modified.

    Now, open file.txt in your favorite text editor. You’ll see:

    Initial content
    <<<<<<< HEAD
    Bob's critical change on line 2
    Bob's version of common line 3
    =======
    Alice's important change on line 2
    Common line 3
    >>>>>>> feature-alice
    

    Observe: Git clearly shows you the HEAD (which is main after merging feature-bob) and the feature-alice version.

  5. Resolve the Conflict: Decide which parts you want to keep. Let’s say we want to keep Bob’s line 2, but combine Alice’s and Bob’s changes for line 3. We’ll manually edit the file to look like this:

    Initial content
    Bob's critical change on line 2
    Bob's version of common line 3 (and Alice's too!)
    

    Explanation: We removed all the <<<<<<<, =======, and >>>>>>> markers and combined the content as desired.

  6. Mark as Resolved and Commit: After saving the file, tell Git the conflict is resolved by adding the file to the staging area:

    git add file.txt
    

    Now, commit the resolution. Git often provides a default commit message; you can use it or customize it.

    git commit -m "Resolved merge conflict in file.txt, keeping Bob's line 2 and combining line 3"
    

    Congratulations! You’ve successfully resolved your first merge conflict!

Rebase Conflicts: A Different Kind of Challenge

Remember git rebase from Chapter 13? It rewrites history by applying your branch’s commits on top of another branch’s latest state. While powerful for keeping history clean, it can also lead to conflicts.

How rebase conflicts differ: During a merge, Git tries to combine two branches at once. During a rebase, Git applies each of your branch’s commits one by one. If a conflict occurs, it happens per commit as Git tries to apply it. This means you might resolve the same conflict multiple times if multiple commits touch the same lines.

Rebase Conflict Resolution Commands:

  • git status: Always your first friend to see what’s conflicted.
  • git rebase --abort: Stops the rebase entirely and rewinds your branch to its state before the rebase started. This is your “get out of jail free” card if you get overwhelmed.
  • git rebase --continue: After you’ve resolved a conflict in a file and staged it (git add <file>), this command tells Git to continue applying the remaining commits.
  • git rebase --skip: If you decide a particular commit that’s causing a conflict is no longer needed, you can skip applying it. Use with caution, as it will drop that commit from your history.

Step-by-Step: Handling a Rebase Conflict

Let’s set up a scenario for a rebase conflict.

  1. Preparation: Ensure you’re in the git-troubleshooting directory. Let’s start with a fresh main and a feature-rebase branch.

    git switch main
    git reset --hard HEAD~2 # Go back to initial commit for a clean slate
    echo "Initial content" > file.txt
    git add file.txt
    git commit -m "Fresh initial commit for rebase demo"
    
    # Create feature-rebase branch
    git switch -c feature-rebase
    echo "Feature line 1" >> file.txt
    git add file.txt
    git commit -m "feat: added line 1"
    echo "Feature line 2" >> file.txt
    git add file.txt
    git commit -m "feat: added line 2"
    
    # Go back to main and make a conflicting change
    git switch main
    echo "Main line A" >> file.txt
    git add file.txt
    git commit -m "main: added line A"
    echo "Main line B" >> file.txt
    git add file.txt
    git commit -m "main: added line B"
    

    What just happened? feature-rebase has two commits (feat: added line 1, feat: added line 2). main also has two new commits (main: added line A, main: added line B) that touch the same area of file.txt.

  2. Initiate the Rebase: Now, let’s try to rebase feature-rebase onto main.

    git switch feature-rebase
    git rebase main
    

    You’ll likely see a conflict:

    Applying: feat: added line 1
    Auto-merging file.txt
    CONFLICT (content): Merge conflict in file.txt
    error: could not apply 1234567... feat: added line 1
    Resolve all conflicts manually, mark them as resolved with
    "git add/rm <conflicted/removed_files>", then run "git rebase --continue".
    

    Git tells you exactly what to do! It paused on the first commit from feature-rebase (feat: added line 1).

  3. Resolve and Continue/Abort: Open file.txt. It will look something like this:

    Initial content
    <<<<<<< HEAD
    Main line A
    =======
    Feature line 1
    >>>>>>> 1234567... feat: added line 1
    

    Let’s say we want to keep both. Edit file.txt:

    Initial content
    Main line A
    Feature line 1
    

    Save the file.

    Now, stage the changes and continue the rebase:

    git add file.txt
    git rebase --continue
    

    Git will try to apply the next commit (feat: added line 2). You might encounter another conflict! If so, repeat the resolve, git add, git rebase --continue steps.

    If at any point you realize you’ve made a mess or don’t want to continue, simply run:

    git rebase --abort
    

    This will take you back to the state before git rebase main was executed. Super helpful!

Detached HEAD State: Where Am I?

A “detached HEAD” state sounds scary, but it just means your HEAD pointer (which normally points to a branch name) is directly pointing to a specific commit, not a branch.

When does it happen?

  • Checking out an old commit directly: git checkout <commit-hash>
  • Checking out a remote branch’s commit (without creating a local tracking branch): git checkout origin/main
  • During certain rebase operations or when inspecting specific points in history.

Why is it an issue? If you make new commits while in a detached HEAD state, those commits won’t belong to any branch. If you then check out a different branch, those “orphan” commits might become unreachable and could be garbage-collected by Git later!

How to fix it: The solution is simple: create a new branch from your current detached HEAD position.

  1. Simulate Detached HEAD:

    git switch main
    git log --oneline
    # Copy the hash of an earlier commit, e.g., "Initial commit"
    git checkout <paste-commit-hash>
    

    You’ll see a message like:

    You are in 'detached HEAD' state. You can look around, make experimental
    changes and commit them, and you can discard any commits you make in this
    state without impacting any branch by switching back to a branch.
    ...
    

    Your prompt might also indicate (HEAD detached at <commit-hash>).

  2. Make a new commit (optional, for demonstration):

    echo "New content in detached state" >> detached-file.txt
    git add detached-file.txt
    git commit -m "Commit made in detached HEAD"
    

    Now, git log will show this new commit, but git branch won’t show it as part of any branch.

  3. Recover by creating a new branch:

    git switch -c my-new-feature
    

    Now, your HEAD points to my-new-feature, which includes the commit you just made. You’re no longer detached! You can then merge my-new-feature into main or another branch as usual.

Undoing Changes: The Art of Reversal

Mistakes happen. Git provides several powerful commands to undo changes, each with different implications for your working directory, staging area (index), and commit history.

git restore: Undoing Local Changes

Introduced in Git 2.23 (circa 2019), git restore is the modern, safer way to undo local changes in your working directory or staging area. It’s more intuitive than git checkout for this purpose.

  • Discard unstaged changes: Reverts a file in your working directory to its state in the staging area (or the last commit if not staged).
    git restore <file>
    
  • Unstage changes (move from staging to working directory): Reverts a file in the staging area back to its state in the last commit, but keeps your working directory changes.
    git restore --staged <file>
    
  • Discard both staged and unstaged changes: Reverts a file to its state in the last commit, discarding all local modifications.
    git restore --source=HEAD <file>
    # Or simply:
    git restore <file> # if the file is not staged, it restores from HEAD.
                       # if staged, you'd use --staged first, then restore again.
    

git reset: Rewriting History (Use with Caution!)

git reset is a powerful command that moves your HEAD pointer and, depending on the mode, can also affect your staging area and working directory. It’s often used to undo commits or unstage files.

The three main modes:

  1. git reset --soft <commit>:

    • Moves HEAD to the specified commit.
    • Keeps all changes from the undone commits in your staging area.
    • Your working directory remains untouched.
    • Useful for combining multiple small commits into one larger, more meaningful commit.
    graph TD A[Original State] --> B(HEAD points to Commit D); B --> C[Working Directory: No changes]; B --> D[Staging Area: Empty]; subgraph Commits C1(Commit A) --> C2(Commit B) --> C3(Commit C) --> C4(Commit D); end B --> C4; style A fill:#f9f,stroke:#333,stroke-width:2px style B fill:#bbf,stroke:#333,stroke-width:2px style C fill:#ccf,stroke:#333,stroke-width:2px style D fill:#ddf,stroke:#333,stroke-width:2px E[git reset --soft C2] --> F(New State); F --> G(HEAD points to Commit B); F --> H[Working Directory: Unchanged]; F --> I[Staging Area: Contains changes from C3 and C4]; G --> C2;

    Figure 15.2: git reset --soft impact

  2. git reset --mixed <commit> (default):

    • Moves HEAD to the specified commit.
    • Unstages changes from the undone commits (moves them from staging area to working directory).
    • Your working directory remains untouched.
    • Useful for completely undoing commits but keeping the changes to re-work them.
    graph TD A[Original State] --> B(HEAD points to Commit D); B --> C[Working Directory: No changes]; B --> D[Staging Area: Empty]; subgraph Commits C1(Commit A) --> C2(Commit B) --> C3(Commit C) --> C4(Commit D); end B --> C4; style A fill:#f9f,stroke:#333,stroke-width:2px style B fill:#bbf,stroke:#333,stroke-width:2px style C fill:#ccf,stroke:#333,stroke-width:2px style D fill:#ddf,stroke:#333,stroke-width:2px E[git reset --mixed C2] --> F(New State); F --> G(HEAD points to Commit B); F --> H[Working Directory: Contains changes from C3 and C4]; F --> I[Staging Area: Empty]; G --> C2;

    Figure 15.3: git reset --mixed impact

  3. git reset --hard <commit>:

    • DANGER! Moves HEAD to the specified commit.
    • Discards all changes from the undone commits from both your staging area and working directory.
    • Irreversible local data loss if not committed elsewhere. Only use if you are absolutely sure you want to throw away all local changes since that commit.
    • Never use --hard on commits that have been pushed to a shared remote repository, as it rewrites history and can cause major problems for collaborators.
    graph TD A[Original State] --> B(HEAD points to Commit D); B --> C[Working Directory: No changes]; B --> D[Staging Area: Empty]; subgraph Commits C1(Commit A) --> C2(Commit B) --> C3(Commit C) --> C4(Commit D); end B --> C4; style A fill:#f9f,stroke:#333,stroke-width:2px style B fill:#bbf,stroke:#333,stroke-width:2px style C fill:#ccf,stroke:#333,stroke-width:2px style D fill:#ddf,stroke:#333,stroke-width:2px E[git reset --hard C2] --> F(New State); F --> G(HEAD points to Commit B); F --> H[Working Directory: Discarded changes from C3 and C4]; F --> I[Staging Area: Empty]; G --> C2;

    Figure 15.4: git reset --hard impact

git revert: A Safer Way to Undo History

git revert is the safest way to undo a commit that has already been pushed to a shared remote repository. Instead of rewriting history (like git reset), git revert creates a new commit that undoes the changes introduced by a previous commit.

  • It’s non-destructive and preserves project history.
  • It’s ideal for shared branches where rewriting history is problematic.
git revert <commit-hash>

This will open your editor to allow you to write a commit message for the new “revert” commit.

Remote Repository Issues: Pushing and Pulling Problems

Sometimes, the issues aren’t just local. Interacting with GitHub (or GitLab, Bitbucket) can also present challenges.

  • Authentication Errors:

    • Problem: Authentication failed for 'https://github.com/...'
    • Cause: Incorrect username/password, expired personal access token (PAT), or SSH key issues. GitHub stopped supporting password authentication for Git operations in 2021, requiring PATs or SSH keys.
    • Solution:
      • PAT: Generate a new PAT with the necessary scopes on GitHub (Developer Settings -> Personal access tokens -> Tokens (classic) or Fine-grained tokens) and use it when prompted for a password, or configure Git Credential Manager.
      • SSH: Ensure your SSH key is added to your GitHub account and your SSH agent is running. Test with ssh -T git@github.com.
      • Refer to GitHub’s official documentation for detailed setup.
  • Non-Fast-Forward Push (Rejected Push):

    • Problem: ! [rejected] main -> main (non-fast-forward)
    • Cause: Someone else pushed changes to the remote branch you’re trying to push to, and your local branch is behind. Git prevents you from overwriting their work.
    • Solution: Pull the latest changes from the remote (git pull origin main) and resolve any merge conflicts locally, then try pushing again.
  • Force Pushing (git push --force vs. --force-with-lease):

    • git push --force: Overwrites the remote branch with your local branch’s history, regardless of what’s on the remote. Dangerous! Can permanently erase collaborators’ work.
    • git push --force-with-lease: A much safer alternative. It will only force push if the remote branch hasn’t been updated since you last pulled. If someone else pushed changes, it will fail, preventing accidental overwrites. Always prefer --force-with-lease if you must force push.

    When to use force-with-lease? Only when you’ve rebased your local branch and want to update a feature branch on the remote that only you are working on. Never force push to shared branches like main/develop unless explicitly coordinated with the entire team.

  • Stale Remote-Tracking Branches:

    • Problem: You see old branches listed locally that no longer exist on GitHub.
    • Cause: When a remote branch is deleted (e.g., after a pull request merge), your local Git repository still has a reference to it.
    • Solution: Use git remote prune origin to remove these stale references.
      git remote prune origin
      

Mini-Challenge: The Accidental Deletion

You’re working on a feature branch. In a moment of distraction, you accidentally delete my_important_file.js and then continue working on other files without realizing the deletion. Later, you remember the file and need it back, but you haven’t committed the deletion.

Challenge:

  1. Create a new file my_important_file.js with some content.
  2. Add and commit it.
  3. Simulate accidentally deleting my_important_file.js (e.g., rm my_important_file.js).
  4. Create another file another_file.txt with some content.
  5. Without committing the deletion of my_important_file.js or the creation of another_file.txt, restore my_important_file.js to its last committed state. Ensure another_file.txt remains in your working directory as a new, unstaged file.

Hint: Think about which git restore command specifically targets unstaged changes for a particular file without affecting other files.

What to observe/learn: How git restore can precisely target and undo uncommitted changes for individual files, leaving other pending changes untouched.

Common Pitfalls & Troubleshooting

  1. Ignoring .gitignore issues:

    • Pitfall: Files you expect to be ignored are still showing up in git status or being committed.
    • Cause:
      • The file was already tracked (committed) before you added it to .gitignore. Git won’t ignore files it’s already tracking.
      • Incorrect .gitignore syntax or placement.
      • Global .gitignore conflicts.
    • Solution:
      • If already tracked: git rm --cached <file> to remove it from Git’s index (but not your local filesystem), then commit the removal. After this, .gitignore will work.
      • Verify .gitignore syntax (one pattern per line, ! for exceptions, / for directory, ** for arbitrary depth).
      • Check git check-ignore -v <file> to see which rule is (or isn’t) matching.
  2. Large Files in Git History:

    • Pitfall: Your repository is huge, slow to clone, and GitHub gives warnings about large files.
    • Cause: Accidentally committed large binaries, archives, or media files directly into Git’s history. Git stores every version of every file, so even if you delete it later, it’s still in the history.
    • Solution: This is complex and involves rewriting history, which should only be done before pushing or on branches that haven’t been shared.
      • Git LFS (Large File Storage): The recommended modern solution for tracking large files by storing pointers in Git and the actual content on a remote LFS server.
      • git filter-repo: A powerful tool (successor to git filter-branch) for rewriting history to remove large files. This is a drastic measure and requires careful execution.
      • Never commit large files directly. Add them to .gitignore before committing.
    • Recommendation: Use Git LFS from the start for any binary assets. For existing repos, consult git filter-repo documentation or seek expert help.
  3. Accidentally force-pushing and losing work:

    • Pitfall: You used git push --force and overwrote someone else’s (or your own) work on the remote.
    • Cause: Misunderstanding git push --force or using it carelessly.
    • Solution:
      • Immediate action: If you realize quickly, you might be able to recover the lost commits from the reflog of a collaborator who had the correct history, or from your own local reflog (git reflog) if you had a copy of the “correct” remote state before the force push. Find the hash of the lost commit and git cherry-pick or git reset to it.
      • Prevention: Always prefer git push --force-with-lease. Educate your team on its dangers. Never force push to main/develop without explicit team coordination.

Summary

Phew! We’ve covered a lot of ground in this troubleshooting chapter. You’ve learned to:

  • Resolve merge conflicts by understanding conflict markers and manually editing files.
  • Handle rebase conflicts using git rebase --continue and git rebase --abort.
  • Recover from a detached HEAD state by creating a new branch.
  • Undo changes safely with git restore, git reset (understanding its soft, mixed, and hard modes), and git revert.
  • Address common remote issues like authentication and non-fast-forward pushes, and learned the critical difference between git push --force and --force-with-lease.
  • Identify and prevent pitfalls like .gitignore problems and large files in history.

Troubleshooting is an essential skill for any developer. The more you practice these techniques, the more confident and efficient you’ll become. Remember, Git is designed to be resilient, and with these tools, you can fix almost anything!

What’s Next?

Now that you’re a Git troubleshooting expert, it’s time to put all your knowledge into practice in more complex, real-world scenarios. In the next chapter, we’ll explore advanced Git internals and real-world project scenarios, deepening your understanding of how Git truly works under the hood and how to apply these skills in large-scale team environments. Get ready to master Git!

References

This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.