Git Advanced - Footguns¶
Mistakes that lose work, break team workflows, or create messes that take hours to untangle.
1. Force push to a shared branch¶
You rebase your feature branch and force push. Your teammate had pushed two commits to the same branch an hour ago. Their work is gone from the remote. They will not get an error — their next pull just overwrites their local with your rewritten history, or they get a confusing divergence.
What happens: Their commits still exist locally (reflog) but are no longer reachable from any remote ref. If they do not notice and recover, the work is lost.
Fix: Use --force-with-lease instead of --force. It checks that the remote ref matches what you last fetched. If someone pushed in between, the push is rejected.
# Safe force push — fails if remote was updated by someone else
git push --force-with-lease origin feature/auth
Even with --force-with-lease, coordinate with your team before rewriting shared history.
2. Rebase published commits¶
You rebase 10 commits that are already on origin/main. Now every teammate's local main has diverged from origin. They get merge conflicts on pull. The Git history shows duplicate commits — the originals and the rebased copies.
What happens: Rebasing rewrites commit SHAs. Anyone who based work on the old SHAs now has orphaned history.
Fix: Never rebase commits that have been pushed to a shared branch. Rebase is for cleaning up local work before pushing. Once pushed, use merge.
3. git reset --hard with uncommitted work¶
You run git reset --hard HEAD to discard some changes. You forgot you had been editing three other files that you had not staged. Those changes are gone. They were never committed, so they are not in the reflog. They are not in stash. They are unrecoverable.
What happens: --hard resets the working tree and the index. Uncommitted, unstaged changes are permanently deleted.
Fix: Use git stash before any hard reset. Or use git reset --keep which refuses to reset if it would discard uncommitted changes. If you only need to unstage, use git reset HEAD (no --hard).
4. git clean -fd on the wrong directory¶
You run git clean -fd to remove untracked files. Your .gitignore does not cover everything you care about. Configuration files, local test data, scratch scripts — all gone. Untracked means Git has no record of them.
What happens: git clean -fd force-deletes all untracked files and directories. No confirmation, no recovery.
Fix: Always dry-run first: git clean -fdn shows what would be deleted. Better yet, use git clean -fdi for interactive mode. Keep your .gitignore comprehensive.
5. Amending a pushed commit¶
You amend a commit that you already pushed. Now your local and remote diverge. You force push to "fix" it. Anyone who pulled the original commit now has a conflict.
What happens: git commit --amend replaces the commit with a new SHA. If the old SHA was pushed, the remote and any clones have the old version.
Fix: Only amend unpushed commits. For pushed commits, make a new commit with the fix. If you must amend, use --force-with-lease and notify the team.
6. Submodule update leaves detached HEAD¶
You run git submodule update and start making changes inside the submodule directory. You commit. But you are on a detached HEAD, not a branch. When you switch back to the parent repo and run git submodule update again, your commit becomes unreachable.
What happens: Submodules check out a specific commit, not a branch. Any commits you make are on a detached HEAD and will be garbage collected if not referenced.
Fix: Always create a branch inside the submodule before making changes:
cd lib/shared
git checkout -b my-fix
# make changes, commit
git push origin my-fix
cd ../..
git add lib/shared
git commit -m "chore: update shared lib submodule"
7. Shallow clone limitations¶
You cloned with --depth=1 to save time in CI. Now git log shows one commit, git blame is useless, git bisect cannot work, and merges with branches that diverged before the shallow boundary fail with confusing errors.
What happens: Shallow clones have truncated history. Operations that need full history either fail or give wrong results.
Fix: Use --filter=blob:none (blobless clone) instead of --depth=1. It downloads all commits and trees but fetches blobs on demand. You get full history for log/blame/bisect without downloading every file version upfront.
If you already have a shallow clone: git fetch --unshallow.
8. Stash apply vs pop — data loss on conflict¶
You use git stash pop. There is a conflict. Git applies the stash and marks conflicts, but because the apply was not clean, the stash is NOT dropped. Good so far. But many people do not realize this. They resolve the conflict, then assume the stash is gone. Later they git stash pop again and get confused by the old stash reappearing, or they git stash clear thinking it is safe, losing a different stash.
What happens: pop = apply + drop. On conflict, the drop does not happen. The stash remains.
Fix: Use git stash apply instead of pop. When you confirm the apply worked, explicitly git stash drop stash@{0}. This separates the two operations and prevents surprises.
9. .gitignore not ignoring already-tracked files¶
You add config/local.yaml to .gitignore. It is still showing up in git status and getting committed. .gitignore only prevents untracked files from being added. It does not affect files already tracked by Git.
What happens: Once a file is tracked (has been git add-ed at any point), .gitignore has no effect on it.
Fix:
# Stop tracking the file (remove from index, keep on disk)
git rm --cached config/local.yaml
git commit -m "chore: stop tracking local config"
# Now .gitignore will work for this file
echo "config/local.yaml" >> .gitignore
For an entire directory: git rm -r --cached config/local/
10. Merge commit in rebase pulls in unexpected changes¶
You rebase a branch that contains a merge commit. The rebase replays individual commits but flattens the merge, potentially applying changes from the merged branch that you did not expect to see in the rebase.
What happens: By default, git rebase linearizes history. Merge commits are dropped, and their first-parent commits are replayed. If the merge brought in changes from another branch, those changes appear as individual commits in your rebased history.
Fix: Use git rebase --rebase-merges to preserve merge commit topology during rebase. Or avoid rebasing branches that contain merges — use merge instead.
11. Large files committed to history¶
Someone committed a 200MB model file, then deleted it in the next commit. The file is gone from the working tree, but the blob is in Git history forever. Every clone downloads it. git gc will not remove it because it is referenced by a reachable commit.
What happens: Git stores the full content of every file ever committed. Deletion only removes it from the current tree, not from history.
Fix: Prevention is easier than cure. Use .gitattributes with Git LFS for large files. Use a pre-commit hook to reject files over a size threshold:
# .git/hooks/pre-commit
MAX_SIZE=$((10 * 1024 * 1024)) # 10MB
git diff --cached --name-only --diff-filter=ACM | while read file; do
size=$(git cat-file -s "$(git ls-files -s "$file" | awk '{print $2}')" 2>/dev/null || echo 0)
if [ "$size" -gt "$MAX_SIZE" ]; then
echo "ERROR: $file is $(( size / 1024 / 1024 ))MB (max 10MB). Use Git LFS."
exit 1
fi
done
To remove after the fact: git-filter-repo --strip-blobs-bigger-than 10M.
12. git gc removing unreferenced commits you need¶
You ran a complex rebase, abandoned it, then ran git gc. The commits you wanted to recover from the reflog are gone because GC pruned unreachable objects.
What happens: git gc prunes objects not reachable from any ref (branch, tag, stash, reflog). Reflog entries expire: 90 days for reachable, 30 days for unreachable. git gc --prune=now removes everything unreachable immediately.
Fix: Never run git gc --prune=now casually. If you need to recover something, do it before GC. To keep a commit safe, tag it or create a branch pointing to it:
13. Case sensitivity — macOS vs Linux¶
You create Config.yaml on macOS. Your teammate on Linux creates config.yaml. Git treats them as different files (Git is case-sensitive in its index). On macOS (case-insensitive filesystem), checking out a branch with both files silently gives you only one of them. Renaming files by changing case (git mv Readme.md README.md) does not work directly on macOS.
What happens: Git's internal data structures are case-sensitive. macOS's default filesystem (APFS) is case-insensitive. This mismatch causes phantom changes, lost files, and CI failures (Linux CI sees different files than macOS developer).
Fix: Set git config core.ignorecase false on macOS to make Git stricter. To rename with case change on macOS:
Establish a team convention: all lowercase filenames, no exceptions.
Recovery & Rescue Footguns¶
14. Never using reflog — recovering from "I destroyed my work" the hard way¶
After a bad merge, reset, or accidental branch deletion, developers assume their work is permanently gone and start from scratch. They recreate hours of work not knowing the reflog has a complete 90-day history of all HEAD movements and could recover everything in 30 seconds.
Fix: Make git reflog your first response to any "I lost my work" situation. The reflog records every commit, reset, rebase, checkout, merge, and cherry-pick. To recover: git reflog to find the hash where your work existed, then git checkout -b recovery <hash>. For deleted branches: git reflog show <deleted-branch> if you remember the name, or search git reflog | grep "commit: " for the last known commit message.
15. Botching merge conflict resolution — silently losing code¶
During a merge conflict, you're in a hurry. You accept "ours" or "theirs" wholesale for a file with complex conflicts without reading both sides. The version you discarded contained a critical bug fix or a security patch. The code compiles and tests pass, so nobody notices. The bug resurfaces weeks later.
Fix: Never resolve conflicts without reading both sides. Use a visual diff tool: git mergetool or configure your editor (VS Code, IntelliJ) as the merge tool. For each conflict, understand why both sides changed the same line. After resolving all conflicts, run git diff HEAD on the merged result to see the full change and verify nothing was accidentally dropped. Test after every merge — don't commit unreviewed resolutions.