All explainers

Real Git situations: the ones that actually happen at work

12 min+15 XP

Every Git emergency at work starts with the same question, and it is never "which command do I run". It is has anyone else already seen this history? Once you know the answer, the command picks itself.

The commands themselves are not hard. What makes Git stressful at work is everything it doesn't tell you: whether someone already pulled what you're about to rewrite, whether the commit CI just built is the one your teammate thinks they pushed, whether "clean" in git status has anything to do with what's actually running. Below are twenty-five situations that come up on a real team, grouped by what they actually have in common rather than by which command fixes them.

When you made the mistake

These four are all "I did the thing, now what", and in every one, the important fact is that Git rarely deletes anything as fast as you think it does.

"I accidentally committed a secret"

You committed .env, an AWS key, a database password, or an API token. Deleting the file in your next commit does not make the secret go away: it is still sitting in the parent commit, in everyone's clone, and in every CI cache that pulled before you noticed.

So the order matters. Rotate the credential first. That is the one step that actually makes you safe; everything else is cleanup.

git rm --cached .env
echo ".env" >> .gitignore
git commit --amend          # if it's local and unpushed
 
# if it's already deep in history:
git filter-repo --invert-paths --path .env

Tricky part: rotating the key is what fixes the incident. Rewriting history is what fixes the repository, and it forces everyone to re-clone, so don't skip straight to it before the credential is dead.

"I ran git reset --hard and think I lost everything"

This happens to everyone eventually. The good news: reset moves a branch pointer, it does not delete the commit object underneath it. git reflog keeps a private log of everywhere HEAD has pointed, including commits no branch reaches any more.

After git reset --hard, the commit is still there

1 / 4
mainABCHEADmain

Three commits in. C is today's work: nothing is wrong yet.

Tricky part: reflog is local and it does expire (30 days by default for unreachable commits like this one, 90 for entries still reachable some other way). It saves you almost every time. It is not a permanent backup.

"Someone deleted a branch"

Same idea as above, one level up: deleting a branch just removes the pointer. The commits are still in the object database until Git garbage-collects them. Find the tip commit through git reflog, or through anyone else's clone or a CI workspace that still has it, then bring the branch back:

git switch -c recovered-branch <commit-sha>

Tricky part: reflog only has your history of HEAD. If the branch was deleted on someone else's machine, you need their reflog, not yours.

"My .gitignore isn't working"

You add .env to .gitignore and Git keeps showing it anyway. The usual cause: Git is already tracking the file, and .gitignore only controls untracked files: it never automatically untracks something already committed.

git rm --cached .env
git commit -m "stop tracking .env"

Tricky part: this is the exact same root cause as situation 1. If the file being untracked ever held a real secret, adding it to .gitignore now does not undo the fact that it was already pushed.

When history disagrees with someone else's

Once more than one person touches a branch, Git problems stop being about recovering your work and start being about whose version of events wins.

"I pulled and now I have conflicts"

git pull combines a fetch and a merge, and the merge is what's failing: your changes and the remote's changes touched the same lines. git status shows you which files are affected; resolve them, then continue whichever operation you were in:

git add .
git commit                  # if it was a merge
git rebase --continue        # if it was a rebase

Tricky part: Git can tell you the text conflict is resolved. It cannot tell you whether the logic is still correct. That part is still on you.

"I rebased and now my branch is a mess"

git rebase main hit conflict after conflict and it's not clear it's going anywhere good. You have two honest options: keep resolving with git rebase --continue, or admit it and back out entirely with git rebase --abort, which restores your branch exactly as it was before the rebase started.

Tricky part: --abort is not a failure. The moment you think "this is going in the wrong direction" is exactly when to use it. It's free, and grinding through ten conflicts you don't understand is not.

"Someone force-pushed and my branch is different"

A colleague rebased and force-pushed a shared branch. Now your local history and the remote's don't match: the commit hashes actually changed, not just the file contents. Do not reflexively run git push --force; that would overwrite the very rewrite they just did. Fetch first, look at what actually changed, and rebase your own work onto the new remote history.

Tricky part: this is why git push --force-with-lease exists instead of plain --force: it refuses to push if the remote has moved since you last saw it, which is exactly the situation this describes.

"My local branch is behind main"

local main:   A → B → C
origin/main:  A → B → C → D → E

Whether you git pull, or git fetch + git rebase origin/main, or merge, depends on your team's convention, not on Git. A merge preserves exactly what happened; a rebase replays your commits on top and keeps history linear. Pick whichever one your team actually uses. See merge vs rebase for the full trade-off.

Tricky part: whichever you pick, pick it consistently. A repo where half the team merges and half rebases has the most tangled history of all.

"I need one commit from another branch"

Another team fixed a critical bug on feature, and you only need that one commit, not the whole branch:

git cherry-pick <commit-sha>

This is exactly how hotfixes get backported into a release branch, or a fix lands on a stable version without dragging in unrelated work.

Tricky part: cherry-pick creates a new commit with a new SHA. It's a copy of the change, not the same object. If that commit later gets merged the normal way too, expect Git to (correctly) treat them as two different commits with the same diff.

"I need to undo a commit"

This is really two different situations wearing one name:

  • Not pushed yet? git reset --soft HEAD~1 keeps the changes staged; git reset --mixed leaves them unstaged. Either way, nobody else has seen the commit, so rewriting it is free.
  • Already pushed? git revert <commit>: it adds a new commit that applies the exact opposite change, rather than erasing the old one.

Tricky part: reset moves history backwards; revert adds to it going forwards. Confusing the two on a shared branch is how a "quick undo" turns into a broken pull request for everyone who already pulled. The full breakdown, with the animated version of this, is in reset vs revert.

Getting a fix out safely

Once the "whose history wins" question is settled, the next one is how to change production without making things worse in the process.

"I pushed something bad to production"

A → B → C

C is the bug. Don't reset main back to B: anyone who already pulled C would have it silently vanish from their branch on the next sync, and you'd lose the record that it ever happened. Instead:

git revert C
A → B → C → D

          undo C

History stays intact, and the log still shows both the mistake and the fix.

Tricky part: this is the production version of the reset-vs-revert question above, and on a branch other people rely on, revert always wins.

"Two developers changed the same infrastructure line"

Developer A set replicas = 3. Developer B set replicas = 5. Git flags a conflict, but resolving the text conflict doesn't answer the real question, which is what should production actually be. That's an infra decision, not a Git one. Resolve the conflict to reflect the real intent, then verify it before merging:

terraform plan

Tricky part: Git will happily let you "resolve" this conflict by picking either number, and both will look clean. Only terraform plan (or the equivalent for your stack) tells you whether the one you picked is actually correct.

"We need an emergency production fix"

The normal flow (feature → PR → review → CI → merge → deploy) is too slow for a broken production. Emergency doesn't mean bypass everything though; it means a shorter, still-controlled version of the same thing:

main → hotfix branch → minimal fix → tests → approval → production → merge fix back

Tricky part: that last step, merging the fix back into your normal branches, is the one people skip under pressure, and skipping it is how the same bug quietly reappears in the next regular release.

Is this actually what's running?

This is where Git stops being a solo tool and becomes a DevOps problem: every one of these is really asking does Git's record match reality.

"The CI pipeline is building the wrong code"

A developer says "I pushed commit abc123," but CI built something else. Check what CI actually checked out, git rev-parse HEAD on the runner, and trace it forward: developer commit → CI checkout → build artifact → deployment → production. Somewhere in that chain the wrong commit entered.

Tricky part: the most common culprit isn't Git at all. It's a trigger that ran before the push finished propagating, or a branch protection rule that let an older commit merge after a newer one.

"CI suddenly fails because Git history isn't available"

Someone "optimized" the pipeline's checkout step:

git clone --depth=1 <repo>

Now git describe, git log HEAD~20, and git diff HEAD~10 can fail: there's no history to look back into. A shallow clone is smaller and faster, but it trades away exactly the thing some build steps need.

Tricky part: this failure often shows up weeks after the pipeline change, the first time someone runs a command that needs depth, which makes it hard to connect back to the actual cause.

"Git says my branch is clean, but the application changed"

git status says nothing to commit, and yet the app behaves differently. Git being clean only means your working tree matches a commit. It says nothing about environment variables, generated files, the Docker image that's actually deployed, untracked-but-ignored files, dependency versions, or config that lives outside the repo entirely.

Tricky part: "clean" is a claim about files, not about the running environment. Treat them as two separate things to check.

"A release tag points to the wrong commit"

v2.0 → abc123     (expected)
v2.0 → xyz789     (after someone moved it)

Once that happens, the release isn't reproducible any more. Two people checking out v2.0 can get different code. Production tags should generally be treated as immutable: create a new tag for a new build, never move an old one.

Tricky part: Git lets you force-move a tag with no warning, which is exactly what makes this mistake so easy to make by accident.

"We deployed 'latest' but don't know what code is running"

production → latest answers nothing. What you actually want is a chain you can walk backwards:

production → image digest → build ID → Git commit SHA

With that chain in place you can answer, precisely, "exactly which source code is running in production right now?"

Tricky part: this has to be built in before the incident, not during one : by the time you need the answer, "latest" has usually already moved.

"Production was changed manually, but Git says something else"

Very common with GitOps. Git says replicas: 3; production says replicas: 5. That's configuration drift. If a controller like Argo CD or Flux is watching that resource, it may just revert the manual change back to what Git says, which looks like the fix undid itself.

production change → update Git → reconciliation → production

Tricky part: in a GitOps setup, Git is meant to be the source of truth on purpose. The long-term fix isn't to stop the controller from reverting your change. It's to never make the change outside Git in the first place.

"I need to see exactly what changed between production and main"

git fetch origin
git diff production-commit..origin/main

Or compare any two SHAs directly: git diff abc123..xyz789. The key habit is comparing actual commit SHAs, not vague labels like "the old version" and "the new version". Those mean something different to everyone in the room.

Tricky part: this only works if you actually know the SHA that's deployed, which loops straight back to the "latest" tag problem above.

"A developer says: 'It works on my branch.'"

Ask which exact commit. Then trace it: developer branch → commit SHA → CI → artifact → deployment → production. If you can't follow that chain end to end, you don't have real release traceability. You have a vibe.

Tricky part: "works on my branch" is often true and still irrelevant, because the branch, the CI build, and what's actually deployed have quietly diverged from each other.

Keeping a repo healthy at scale

The last three aren't really Git problems. Git is just where they become visible.

"Our repository is 8 GB"

Usually from committing node_modules, zip files, logs, Docker images, or other large binaries. Deleting the file today doesn't shrink the repo. It still exists in every commit before the deletion. Fixing it for real usually means Git LFS for large files going forward, git filter-repo to remove the old ones from history, and pushing large artifacts out to a proper artifact store instead of the repo.

Tricky part: the deletion commit makes the working tree smaller. It does nothing for .git/ itself, which is where the size actually lives.

"The PR has 40 tiny commits"

fix, fix2, final, final2, oops, actual-final: before merging, squashing these into one commit like Implement payment retry logic makes the history something a reviewer, or future-you doing a git blame, can actually read.

Tricky part: don't make this a reflex. Sometimes the individual commits are the valuable part: a long-lived branch with meaningful checkpoints is worth keeping intact for debugging or audit reasons.

"We have 500 branches"

The fix isn't "delete 450 of them." That's a symptom, not a cause. Ask why branches are living so long, why merges are slow, and whether shorter-lived branches, trunk-based development, or feature flags would stop them piling up in the first place, and whether stale branches are even being cleaned up automatically.

Tricky part: this is the clearest example on this whole page of Git becoming a process problem. No command fixes a branching habit.

Remember this

  1. 1History only on your machine is safe to rewrite: reset, amend, rebase. History someone else already pulled is not; undo it forward with revert instead.
  2. 2Git rarely deletes anything the moment you think it does: reflog remembers commits, deleted branches and pre-rebase state for weeks, which is why almost none of this is actually unrecoverable.
  3. 3A clean git status only means your working tree matches a commit. It says nothing about which commit is deployed, what environment variables are set, or what Terraform or a GitOps controller actually applied.
  4. 4'Too many branches', a 40-commit PR, and an 8 GB repo are process problems wearing a Git costume. The fix is shorter-lived branches and cleaner habits, not a bigger cleanup script.

Now try these questions