This page is for developers facing a Git round or Git questions inside a wider coding interview, from a first job to a lead role. Most interviewers start with the basics: Git versus GitHub, the staging area and what a commit really is. Then they push on branches, merge versus rebase, conflicts, and how to undo things safely, with reset, revert, stash and reflog. Senior rounds add force pushes, branching strategy, pull request habits and a story about a real mess you cleaned up. Each question shows what the interviewer is checking and an answer you can say out loud.
Search all questions by round, difficulty and level, or save the ones you want to practise.
Git: a distributed version control tool that runs on your machine; every clone holds the full history.
GitHub: a hosting service for Git repositories that adds pull requests, reviews, issues, CI and access control.
Independence: Git works fully without any host; GitHub is one of several hosts, next to GitLab, Bitbucket or a plain server.
"Git is the version control tool itself. It runs on my laptop, and because it's distributed, every clone has the whole history, so I can commit, branch, look at logs and diff old versions with no network at all. GitHub is a hosting service built around Git. It stores a copy of the repository on a server and adds the team features: pull requests and code review, issues, permissions, and CI through Actions. So yes, I can use Git without GitHub, for a personal project or with another host like GitLab or Bitbucket, or even a bare repository on a shared server. Using GitHub without Git doesn't really make sense, because what GitHub hosts is a Git repository. The web editor just makes Git commits for you."
Treating Git and GitHub as the same thing, or saying you need an internet connection to commit.
Working tree: the files you edit on disk.
Index (staging area): the exact snapshot the next commit will contain; git add copies changes into it.
Repository: the .git folder with all commits; git commit turns the index into a permanent commit.
Seeing the gaps: git diff is working tree versus index, git diff --staged is index versus the last commit.
"There are three places a change can be. The working tree is just my files on disk, where I edit. The index, or staging area, is the draft of my next commit. When I run git add, Git copies the current content of that file into the index. Then git commit takes whatever is in the index and saves it as a commit in the repository, which is the .git folder. The useful part is that the index lets me commit only part of my work. If I fixed a bug and also tidied some formatting, I can stage just the bug fix, even just some hunks with git add -p, and commit that alone. To see where things are, git status shows all three, git diff shows what I changed but haven't staged, and git diff --staged shows what's about to be committed."
git status # what differs in each area
git diff # working tree vs index
git add -p src/cart.js # stage only some hunks
git diff --staged # index vs last commit
git commit -m "Fix rounding in cart total"
Thinking git commit saves whatever is in your files right now, rather than what is in the index.
Snapshot: a commit points to a tree that describes the whole project at that moment, not a list of changes.
Metadata: it also records its parent commit or commits, author, committer, time and message.
Hash: the commit is named by a hash of all that content, so changing anything gives a new commit.
Storage: unchanged files are shared between commits, and packfiles compress similar objects, so snapshots stay cheap.
"Conceptually a commit is a snapshot, not a diff. It points to a tree object, which lists every file and folder in the project at that moment, and the files themselves are stored as blobs. On top of that it records its parent, or two parents for a merge, the author and committer with timestamps, and the message. The whole thing is hashed, and that hash is the commit's ID. Because the parent is part of what gets hashed, each commit locks in the full history behind it. That's why commits are effectively immutable: if I amend a message or rebase, I don't change a commit, I create a new one with a new hash. Diffs you see in git log or git show are computed on the fly by comparing snapshots. It stays space-efficient because an unchanged file is the same blob in every commit, and packfiles compress similar content."
Saying a commit stores only the lines that changed, or that you can edit an existing commit in place.
Save: git stash push -m "msg" shelves tracked changes, staged and unstaged, and cleans the working tree.
Untracked files: not included unless you add -u.
Restore: pop applies and drops the entry; apply keeps it; on a conflict, pop keeps the entry too.
Alternative: a quick wip commit on the branch is often safer for anything long-lived.
"Stash takes my uncommitted changes, saves them on a stack, and gives me a clean working tree, so I can switch branches and fix the urgent thing. When I'm back, git stash pop puts the changes back and removes the entry. There are a few gotchas. By default it only stashes tracked files, so a brand new file I haven't added stays behind in the working tree; I use -u to include untracked files. If popping causes a conflict, Git keeps the stash entry so nothing's lost, and I resolve and drop it myself. And stashes pile up with vague names, so I always add a message with -m. For anything I'll be away from for more than an hour, I honestly prefer a quick 'wip' commit on my branch and amending it later, because a commit lives on a branch and is harder to forget about than an old stash entry."
git stash push -u -m "half-done filter panel"
git switch hotfix/login-timeout
# ...fix, commit, push...
git switch feature/filters
git stash list
git stash pop
Assuming stash saves brand new untracked files by default, or treating the stash as long-term storage.
Tag vs branch: both point at a commit, but a tag is meant to stay put while a branch moves with new commits.
Lightweight: just a name for a commit, nothing else.
Annotated: a full object with tagger, date and message, and it can be signed; the right choice for releases.
Pushing: a plain git push doesn't send tags; push them by name or with --tags.
"A branch and a tag both point at a commit. The difference is that a branch moves forward every time someone commits on it, while a tag is meant to stay fixed, so it's how we mark releases like v1.4.0. There are two kinds. A lightweight tag is only a name pointing at a commit, fine for a private bookmark. An annotated tag is a proper object stored in the repository, with who created it, when, and a message, and it can be signed so people can verify it. For releases I always use annotated tags. One thing that catches people: a normal git push doesn't send tags. I push the tag by name, or use --tags. And moving a published tag is a bad idea, because anyone who already fetched it keeps the old one, so if a release was wrong I cut a new version instead."
git tag -a v1.4.0 -m "Release 1.4.0"
git push origin v1.4.0
git tag -l "v1.*" # list matching tags
Thinking tags are pushed automatically, or treating a tag like a branch you keep committing to.
Cause: .gitignore only stops untracked files from being added; a file already tracked stays tracked.
Fix: git rm --cached <file> removes it from the index but keeps it on disk; commit that with the ignore rule.
Side effect: when teammates pull that commit, Git deletes the file from their working trees, so warn them.
Debug: git check-ignore -v <file> shows which rule matches.
"The .gitignore file only applies to untracked files. It tells Git not to pick up new files that match. If the file was committed before the rule existed, Git is already tracking it and keeps showing changes. To fix it, I run git rm --cached on the file. That removes it from the index, so it'll be deleted from the repository in the next commit, but it stays on my disk. I commit that together with the .gitignore change. There's one side effect to warn the team about: when they pull that commit, Git removes the file from their working trees too, so for something like a local config file they should back it up first. And if the file ever held secrets and was pushed, ignoring it now doesn't help, because it's still in history, so the secret needs rotating. For debugging rules, git check-ignore -v tells me exactly which pattern matched a path."
echo ".env" >> .gitignore
git rm --cached .env # stop tracking, keep the file on disk
git commit -m "Stop tracking .env"
git check-ignore -v .env # shows the matching rule
Expecting .gitignore to affect files already tracked, or deleting the file from disk to make it disappear.
Branch: a named pointer to one commit; committing moves it forward. That's why branches are cheap.
HEAD: points to the branch you're on, so Git knows which pointer to move on the next commit.
Detached HEAD: HEAD points straight at a commit, for example after checking out a tag or hash.
The risk: commits made while detached belong to no branch; create one with git switch -c to keep them.
"A branch is just a name that points to one commit. Internally it's a tiny file holding a commit hash. When I commit on that branch, Git makes the new commit with the old tip as its parent and moves the pointer forward. That's why creating a branch is instant: it's not a copy of anything. HEAD tells Git where I am. Normally it points to a branch, like main, so a new commit moves main. Detached HEAD means HEAD points directly at a commit instead of a branch, which happens if I check out a tag or an old hash to look around. I can still commit there, but no branch moves, so if I switch away those commits aren't on any branch and are easy to lose. If I want to keep them, I run git switch -c with a new branch name right there. And if I already switched away, the reflog still has the hash."
Describing a branch as a separate copy of the code, or saying detached HEAD means the repository is broken.
Fast-forward: the target branch has no new commits since you branched, so Git just moves its pointer to your tip. No merge commit.
Merge commit: when both sides moved, Git makes a commit with two parents.
--no-ff: forces a merge commit even when a fast-forward is possible, so each feature stays grouped and easy to revert as one unit.
--ff-only: the opposite guard: merge only if it can fast-forward, otherwise stop.
"A fast-forward happens when the branch I'm merging into hasn't moved since I branched off. Say main is at commit A, I branch, and add B and C. If nobody touched main, merging my branch doesn't need a new commit, because C already contains everything. Git just slides main forward to C. The history stays a straight line. Some teams use --no-ff to always create a merge commit anyway. The benefit is that the history shows 'feature X came in here', all its commits hang off that merge, and if the feature has to come out, I can revert that one merge commit. The cost is more merge commits in the log. There's also --ff-only, which I like for pulling main: it refuses to merge if my local branch has diverged, so I never get a surprise merge commit."
git switch main
git merge --ff-only feature/search # only if main hasn't moved
git merge --no-ff feature/search # always record a merge commit
Saying a fast-forward merge creates a merge commit, or not knowing when Git can fast-forward.
Merge: keeps both histories and adds a merge commit with two parents; nothing existing is rewritten.
Rebase: replays your commits one by one on top of the new base, creating new commits with new hashes and a straight line.
Conflicts: a merge surfaces them once; a rebase can surface them per replayed commit.
When: rebase your own unshared work for a clean history; merge when the branch is shared or you want the true record.
"Both get main's changes into my branch, but they shape history differently. A merge takes the two tips and creates a merge commit with two parents. Nothing that already exists changes, so it's safe on any branch, and the log shows what really happened, but it can get noisy with lots of 'merge main into feature' commits. A rebase takes my commits, sets them aside, moves my branch to the tip of main, and replays them one by one. The result looks like I started from the latest main, a clean straight line, but every replayed commit is new with a new hash. If there are conflicts, I may resolve them commit by commit. My rule is simple: I rebase my own branch while nobody else is building on it, to keep it tidy before review. Once a branch is shared, I merge, because rewriting commits other people have makes a mess for them."
git fetch origin
# option 1: merge main into my branch
git merge origin/main
# option 2: replay my commits on top of main
git rebase origin/main
Saying rebase is always better because it looks cleaner, without mentioning that it rewrites commits.
Find: git status lists the unmerged paths.
Understand: read both sides between the markers and work out what each change was for.
Resolve: edit to the correct combined result, remove the markers, run the tests.
Finish or back out: git add then git commit, or git merge --abort to return to where you started.
"First I run git status to see which files are unmerged. In each file, Git has put conflict markers: the part between the opening marker and the equals line is my side, HEAD, and the part after is the incoming branch. I don't just pick one side. I read both and figure out what each change was trying to do, and if it's not obvious I look at the commits on each side or ask the person who wrote them. Then I edit the file to what the combined code should be, delete all the markers, and search for any stray ones. Before committing I build and run the tests, because a file can merge cleanly and still be broken. Then git add on each resolved file and git commit to finish the merge. If I realise it's messier than I thought, git merge --abort puts me back exactly where I was before the merge."
git merge feature/login
# CONFLICT (content): Merge conflict in src/auth.js
git status # lists unmerged paths
# edit src/auth.js: keep the right lines, remove the markers
npm test
git add src/auth.js src/session.js
git commit # completes the merge
# changed your mind?
git merge --abort
Resolving by blindly keeping 'mine' or 'theirs' everywhere, or committing without running the code.
Merge base: the best common ancestor of the two branches (git merge-base).
Three-way compare: Git diffs base to ours and base to theirs; a region changed on only one side is taken automatically.
Conflict: both sides changed the same region differently; identical changes on both sides are fine.
Blind spot: changes to different lines can merge cleanly and still break, so tests decide, not Git.
"Git compares each branch tip to their common ancestor, the merge base. That's the three-way merge. For each part of a file, if only my side changed it compared to the base, Git takes my version. If only their side changed it, Git takes theirs. If both sides made the identical change, that's fine too. It's only a conflict when both sides changed the same region in different ways, because Git has no way to know which is right. That model also explains the blind spot: Git works on text, not meaning. If I rename a function on my branch and a teammate adds a new call to the old name in a different file, the merge is perfectly clean and the build is broken. So a clean merge tells me there's no textual overlap, and the tests tell me if it works. I also like the diff3 conflict style, which shows the base version inside the markers."
git merge-base main feature/pricing # the common ancestor
git config --global merge.conflictStyle diff3
Saying Git conflicts whenever both branches touch the same file, or trusting a clean merge without testing.
Rewrite: rebase replaces commits with new ones that have different hashes.
Others' copies: teammates still have the old commits, and their work is built on them.
Fallout: you must force push; they get diverged branches, duplicate commits or lost work when they sync.
Where it's fine: your own branch that nobody has pulled or built on, with the team's agreement.
"Rebase doesn't move commits, it makes new copies with new hashes and drops the old ones from the branch. On my own branch that's harmless. But say I rebase a branch two teammates have pulled, and force push it. Their local branches still point at the old commits, and anything they committed since is built on top of those. When they pull, Git sees two different histories with the same changes in different commits. Depending on what they do, they get a merge that brings the old commits back next to the new ones, so every change appears twice, or they reset to the remote and lose their own work. Either way someone spends an afternoon untangling it. So the real rule is: don't rewrite commits that other people have based work on. Rebasing my feature branch before I open the pull request, or while I'm the only one pushing to it, is fine and I do it all the time."
Reciting 'never rebase public branches' without being able to explain why commit hashes changing causes the problem.
Interactive rebase: git rebase -i origin/main opens the list of your commits, oldest first.
Actions: pick keeps, squash or fixup folds into the one above, reword edits a message, lines can be reordered or dropped.
Aim: a few commits that each make sense on their own, with clear messages.
Pushing: the hashes changed, so push with --force-with-lease, and only on your own branch.
"I use an interactive rebase. I fetch first, then run git rebase -i against origin/main, so it covers exactly the commits on my branch and also puts them on top of the latest main. Git opens a list of my commits, oldest at the top. I keep the meaningful ones as pick, mark the 'wip' and 'fix typo' ones as fixup so they fold into the commit above them and their messages disappear, and use reword where a message needs fixing. I can also reorder lines to group related changes. I'm aiming for a handful of commits that each tell one part of the story. When I save, Git replays them. Because the hashes are new, I push with --force-with-lease, which is fine since it's my branch and nobody's built on it. Going forward, git commit --fixup plus rebase with --autosquash saves me doing this by hand."
git fetch origin
git rebase -i origin/main
# in the editor (oldest first):
# pick a1b2c3d Add search endpoint
# fixup d4e5f6a wip
# fixup 0718293 fix typo
# reword 9a8b7c6 add paging
git push --force-with-lease
Cleaning up with rebase on a shared branch and plain force push, or not knowing how to get out with git rebase --abort.
Stage the missing file: git add it first.
Amend: git commit --amend replaces the last commit with one that has the new content and message.
New hash: amend makes a new commit, so if it was pushed you'd need a force push; on a shared branch, make a new commit instead.
"If I haven't pushed yet, it's one command. I git add the file I forgot, then run git commit --amend with the corrected message. That replaces my last commit with a new one containing both the original changes and the file, with the fixed message. If the message was fine and I only forgot the file, I add --no-edit so it keeps the old message. The thing to remember is that amend doesn't edit the commit, it creates a new one with a new hash. So if I'd already pushed, the remote has the old commit and a normal push would be rejected. On my own feature branch I'd amend and push with --force-with-lease. If other people might have pulled it, I'd leave history alone and just make a small follow-up commit adding the file."
git add src/config/defaults.js
git commit --amend -m "Add retry settings to API client"
# keep the old message:
git commit --amend --no-edit
Not realising amend changes the commit hash, and amending commits on a shared branch.
What it does: applies the changes from one existing commit onto your current branch as a brand new commit.
Good uses: backporting a fix to a release branch, rescuing a commit made on the wrong branch.
Cost: the same change now exists as two commits with different hashes, which can confuse history and cause later conflicts.
Tip: -x records the original hash in the message so people can trace it.
"Cherry-pick takes the change introduced by one commit, the diff between it and its parent, and applies it on my current branch as a new commit. It's the same change, but a different commit with a different hash. The classic use is a hotfix: we fix a bug on main and also need it on the release branch that's already in testing, but we can't merge main there because it would drag in unfinished features. So I switch to the release branch and cherry-pick just the fix. I use -x so the message records which commit it came from. Another use is when I committed on the wrong branch and want to move that one commit. I don't use it as a substitute for merging whole features, because you end up with duplicate commits all over the place, and when the branches are merged later, Git may have to reconcile the same change twice."
git switch release/2.4
git cherry-pick -x 4e1c9a2
# on conflict: fix, git add, then
git cherry-pick --continue
Thinking cherry-pick moves the original commit, or using it routinely to sync long-lived branches.
All three: move the current branch pointer to the commit you name.
--soft: stops there; index and files untouched, so the undone changes show as staged.
--mixed (default): also resets the index; your files keep the changes, now unstaged.
--hard: also resets the working tree; uncommitted changes are gone for good.
"All three modes start the same way: they move the branch I'm on back to the commit I give, say HEAD~1. The difference is how far the reset spreads into the other two areas. With --soft, it only moves the branch. My index and files stay as they were, so the changes from the undone commit show up as staged, ready to recommit. I use that to redo a commit or combine the last few into one. --mixed is the default. It also resets the index, so the changes are still in my files but unstaged. Good for splitting a commit differently. --hard also resets the working tree to match, so everything since that commit is thrown away, including any edits I hadn't committed. Committed work can usually be found again through the reflog, but uncommitted changes wiped by --hard are usually gone for good. So I run git status before any hard reset."
git reset --soft HEAD~1 # undo commit, changes stay staged
git reset HEAD~1 # mixed: undo commit, changes unstaged
git reset --hard HEAD~1 # undo commit and discard the changes
Not knowing that --hard discards uncommitted work, or not knowing which mode is the default.
reset: moves the branch back, so the commits disappear from it; history is rewritten.
revert: adds a new commit that applies the opposite of the old one; history only grows.
Rule: reset for local, unpushed work; revert for anything already shared.
"They undo things in opposite ways. Reset moves my branch pointer backwards, so the commits I reset past are no longer on the branch. That's rewriting history, which is fine when the commits only exist on my machine. Revert doesn't remove anything. It creates a new commit that does the exact opposite of the one I'm undoing, so the bad change is cancelled but both commits stay in the history. That's what I use once a commit has been pushed to a shared branch like main. Everyone can just pull the revert like any other commit, nobody's history breaks, and there's a clear record that the change was backed out and why. If I reset and force pushed main instead, I'd be pulling commits out from under everyone who already has them."
git revert 7d3f0b1 # new commit that undoes 7d3f0b1
git revert --no-edit HEAD # undo the latest commit
Suggesting reset plus force push to undo a commit that's already on main.
Revert the merge: git revert -m 1 <merge>; -m 1 says the first parent, main, is the line to keep.
Why -m: a merge has two parents, so Git needs to know which side to undo against.
The trap: Git still considers the branch merged, so merging it again later won't bring those changes back.
Re-landing: revert the revert first, then merge the fixes.
"Because main is shared, I revert rather than reset. A merge commit has two parents, so a plain revert doesn't know which side to go back to. I run git revert -m 1 with the merge hash. Parent one is the branch that was merged into, main, so this says: make main look like it did before the feature came in. That lands as a normal new commit everyone can pull. The part people miss comes later. The feature's commits are still in main's history, the revert just cancelled their effect. So when the team fixes the feature and merges the same branch again, Git sees those old commits as already merged and only brings in the new fixes, and the original code stays missing. The fix is to revert the revert first, which restores the feature's changes, and then merge the new work on top."
git log --merges --oneline -5 # find the merge commit
git revert -m 1 b8e21f4
git push origin main
# later, when the feature is ready again:
git revert <hash-of-that-revert>
Resetting main and force pushing, or not knowing why re-merging the branch later won't restore the feature.
Own it: tell the team straight away that it's your merge and you're on it.
Unblock: if the fix isn't obvious within minutes, revert the merge with a new commit; never reset and force push main.
Fix forward: find the cause on your branch, add a test that would have caught it, and re-land.
Learn: ask why CI or review didn't catch it and close that gap.
"First I'd post in the team channel that it's my merge and I'm looking at it, so nobody else wastes time investigating. Then I'd check whether it's a one-line obvious fix. If it isn't clear within a few minutes, I'd revert. If it was a merge commit, that's git revert -m 1 on the merge; if it was squash-merged, a plain revert of that commit. I'd push the revert through the normal path, so main goes green and people can deploy again. I wouldn't reset main and force push, because others have already pulled it. Then, with no pressure, I'd find the cause on my branch. Often it's something that passed on my branch but conflicts with what landed on main in between. I'd add a test that fails without the fix, re-open the pull request, and afterwards ask whether our checks should run against the latest main before merging."
Resetting main and force pushing, or quietly trying fixes on main while everyone stays blocked.
fetch: downloads new commits and updates remote-tracking branches like origin/main; your branches and files don't change.
pull: a fetch followed by integrating the remote branch into yours, by merge or rebase.
Why fetch first: you can look at what changed before deciding how to combine it.
Config: setting pull.rebase to true, or pull.ff to only, makes pull behave predictably.
"git fetch talks to the remote, downloads any new commits, and updates my remote-tracking branches, like origin/main. It doesn't touch my own branches or my files, so it's always safe to run. git pull is a fetch plus a second step that integrates the remote branch into my current branch, by default with a merge, or with a rebase if I pass --rebase or set it in config. Some developers avoid pull because that second step can surprise you: if your branch and the remote both have new commits, you get an automatic merge commit or a conflict you weren't expecting. Recent versions of Git refuse and tell you to choose merge or rebase when the branches have diverged and you haven't configured it. I usually fetch, look at git log HEAD..origin/main to see what came in, then rebase or merge deliberately."
git fetch origin
git log --oneline HEAD..origin/main # what's new on the remote
git rebase origin/main
# or set a default once:
git config --global pull.rebase true
Saying fetch and pull are the same, or that fetch changes your working files.
Meaning: the remote branch has commits you don't have, so your push would drop them.
Wrong fix: force pushing, which deletes their commits from the remote.
Right fix: fetch, then rebase onto or merge the remote branch, resolve, test, push again.
"It means someone pushed to that branch after I last synced. My branch and the remote have diverged, so if Git accepted my push, the remote branch would jump to my commit and their commits would drop off it. Git refuses rather than lose work. The wrong fix is adding --force, because that's exactly what deletes their commits. The right fix is to bring their work in first. I run git fetch, check what came in, then either rebase my commits on top of origin's version, which is what I do on a feature branch to keep it tidy, or merge it if that's the team's habit. git pull --rebase does both steps in one. If there are conflicts I resolve them, run the tests, because their change and mine now meet for the first time, and then push again, which goes through as a normal fast-forward."
git fetch origin
git rebase origin/feature/checkout
# resolve any conflicts, run tests
git push origin feature/checkout
Immediately suggesting git push --force to make the error go away.
--force: overwrites the remote branch with yours no matter what is there now.
--force-with-lease: only overwrites if the remote still points where your origin/<branch> says it did; otherwise it refuses.
Blind spot: a background fetch updates origin/<branch> without you looking, so the lease passes and can still drop new commits.
Extra guards: --force-if-includes, and protected branches that block force pushes to main.
"Plain --force says: make the remote branch equal to mine, whatever's there. If a teammate pushed five minutes ago, their commits are gone from the remote. --force-with-lease adds a check. It compares the remote branch with my remote-tracking ref, origin slash that branch, which is what I last saw. If the remote has moved since, it refuses, so I don't overwrite work I haven't seen. That's why I use it after rebasing my own branch. It isn't perfectly safe, though. The check is against my last fetch, not against what I actually looked at. If my editor fetches in the background, origin's ref updates to include the teammate's commit, the lease passes, and I overwrite it anyway. The --force-if-includes option closes that gap by also checking that the remote tip I last fetched is something I actually integrated into my local branch. And for main and release branches, the real protection is branch protection on the server, so nobody can force push there at all."
git push --force-with-lease origin feature/billing
# stricter: also require that you've integrated the remote tip
git push --force-with-lease --force-if-includes origin feature/billing
Calling --force-with-lease completely safe, or using plain --force on shared branches as a habit.
Commits survive: reset only moved the pointer; unreferenced commits stay in the repository for a while.
Reflog: git reflog lists every position HEAD has been in, with the hash before the reset.
Recover: pin the hash with a new branch first, then reset or merge back.
Limits: the reflog is local and entries expire; uncommitted changes wiped by --hard were never commits and aren't in it.
"The commits aren't gone, the branch just stopped pointing at them. Git keeps unreferenced commits around until garbage collection eventually cleans them up, and the reflog records every place HEAD has pointed, locally. So I run git reflog and look for the line just before the reset, something like 'commit: add export'. That hash is my old tip. Before doing anything clever I pin it by creating a branch at that hash, so it can't be lost again. Then I can reset my branch back to it, or merge it. The same trick recovers a deleted branch or commits made in detached HEAD. The limits matter. The reflog exists only on my machine, it's not pushed, and old entries expire. And if the reset --hard also wiped changes I never committed, the reflog can't help, because those were never commits. If they had been staged, git fsck can sometimes find the loose file content, but that's a last resort."
git reflog
# 3f9e2a1 HEAD@{0}: reset: moving to HEAD~3
# 8c4d7b0 HEAD@{1}: commit: Add CSV export
git branch rescue 8c4d7b0 # pin the lost work first
git reset --hard rescue # move my branch back to it
Saying the commits are permanently gone, or not knowing the reflog exists.
Contain first: revoke or rotate the key immediately; assume it's already been copied.
Tell people: inform your lead or security contact and check the key's usage logs.
Clean up: remove it from the code, load it from the environment or a secrets manager, and ignore the file.
History: a new commit leaves it in history; rewriting with a tool like git filter-repo needs a coordinated force push, and copies may already exist.
"The first step isn't a Git command, it's revoking the key. Once it's been pushed, I have to assume someone has it, especially on a public repository, where automated scanners look for exactly this. So I'd rotate it with the provider, update the running services with the new key, and tell my lead or the security team, and check the provider's logs for any use I don't recognise. Then the code: remove the key, read it from an environment variable or the secrets manager, and add the config file to .gitignore. Deleting it in a new commit doesn't remove it from history, so the old commit still shows it. Whether to rewrite history depends on the team. If we do, I'd use git filter-repo to strip it, force push with everyone warned, and have people re-clone. But clones, forks and caches may still hold it, which is exactly why rotating the key is the real fix and rewriting is just tidying up."
Deleting the key in a new commit and calling it fixed, or rewriting history before revoking the key.
Don't overwrite back: a counter force push would drop their new work.
Your copy is safe: your local branch, or its reflog, still has your commits.
Rebuild: fetch, keep a rescue branch, match the new remote, cherry-pick only your commits, push normally.
Fix the cause: talk to the teammate, agree on --force-with-lease or no force pushes on shared branches.
"First, I don't force push back, because that would wipe whatever they just pushed, and we'd be taking turns deleting each other's work. My commits are almost certainly still on my machine, on my local branch. Even if I'd already pulled, the reflog would have them. So I'd fetch, then create a rescue branch at my current local tip so nothing can be lost. I'd compare it with the new remote to find the commits the remote no longer has and pick out my two. Then I'd reset my local branch to match the new remote and cherry-pick just those two commits on top, run the tests, and push normally, with no force needed. Then I'd talk to the teammate, not to blame them, but because they probably rebased without realising others were pushing to the branch. We'd agree that shared branches never get force pushed, or only with --force-with-lease after a heads-up in the channel."
git fetch origin
git branch rescue feature/reports # keep my old copy safe
git log --oneline origin/feature/reports..rescue
git switch feature/reports
git reset --hard origin/feature/reports # match the new remote
git cherry-pick 2b7c1e0 91fd3a4 # only my two commits
git push origin feature/reports
Force pushing your version back over theirs, or assuming the commits are lost because the remote no longer has them.
Trunk-based: everyone merges small, short-lived branches into main often; unfinished work hides behind feature flags; strong CI is required.
Git Flow: long-lived develop and main branches plus feature, release and hotfix branches; fits scheduled, versioned releases.
Trade-off: fewer, smaller conflicts and faster feedback versus more process and a separate stabilising step.
Choice: for many daily deploys, trunk-based; for boxed releases or several supported versions, something closer to Git Flow.
"In trunk-based development, main is the one branch that matters. People work on small branches that live a day or two at most, merge them often, and anything unfinished ships switched off behind a feature flag. It needs good automated tests and CI, because main must always be releasable. Git Flow has a develop branch where features collect, release branches where a version is stabilised, main holding what's been released, and hotfix branches for urgent fixes. It fits software that ships as numbered versions on a schedule, like a mobile app or a library where you support several versions at once. For a web app deploying several times a day, I'd pick trunk-based. Git Flow's extra branches slow that down, and long-lived branches are exactly what causes big painful merges. I'd back it with branch protection, required checks on every pull request, and feature flags, so merging often doesn't mean releasing half-built features."
Calling one model best in all cases, or describing trunk-based as everyone committing untested code straight to main.
Size: one purpose per pull request, small enough to review properly in one sitting.
Context: title and description say what and why, how to test, and link the ticket; self-review and green CI before asking.
During review: answer every comment, push follow-up commits so reviewers see what changed, don't rewrite history silently.
As reviewer: be prompt and specific, explain why, and mark what blocks versus what's a nit.
"As an author, the biggest thing is size. One pull request does one thing, so a reviewer can actually read it instead of skimming. The description says what changed, why, and how to test it, with screenshots for UI work and a link to the ticket. I review my own diff first, because I always catch something, and I make sure CI is green before I ask anyone. During review I reply to every comment, even if it's just 'done', and I push fixes as new commits so reviewers can see what changed since last time, rather than force pushing over everything silently. As a reviewer, I try to respond within the day, because a pull request waiting three days turns into a merge conflict. I explain why I'm asking for a change, I separate blocking issues from nits, and I comment on the code, not the person. Approving with small suggestions is fine."
Seeing review as a formality, or opening huge mixed-purpose pull requests with an empty description.
Problem: the pain the old way caused, with something concrete you observed.
Proposal: what you changed and why that fit the team.
Buy-in: how you handled doubts, trialled it and adjusted.
Outcome: how you knew it worked, and what you'd still change.
"At my last company we used Git Flow with a develop branch and fortnightly release branches, but we deployed a web app and customers were waiting two weeks for tiny fixes. Worse, release branches regularly needed a day of conflict fixing and cherry-picks. I pulled the last few releases' history and showed the team how much time went into merging rather than building. I proposed trunk-based development: short branches merged to main within a couple of days, feature flags for unfinished work, and main protected, with required CI and one approval. Some people worried about half-built features going live, so we ran it on one service for a month first, with flags and a clear rollback plan, which was just reverting. It worked, merge pain mostly disappeared, and we went from fortnightly releases to deploying most days. Then we rolled it out to the rest. What I'd change is investing in tests earlier, because a flaky suite nearly killed the trial."
Imposing a new workflow without evidence or a trial, or claiming success with no way of measuring it.
Situation: what was lost, how, and why it mattered right then.
Action: the steps you took, in order, and why you avoided making it worse.
Result: what was recovered and how long it took.
Change: the habit or guard the team added so it doesn't repeat.
"In my last job, the evening before a client demo, a teammate ran git reset --hard on the wrong branch and his whole afternoon of commits vanished. He was about to start rewriting. I asked him to stop and not run anything else, because the commits were almost certainly still there. We ran git reflog, found the commit line from just before the reset, and I had him create a branch at that hash first so it was safe, then reset his branch back to it. Everything was back in about ten minutes and the demo went fine. Afterwards we changed two things. We agreed to push feature branches at least daily, so work always lives somewhere besides one laptop. And I did a short session for the team on reflog and on using git status before any hard reset. Since then, when someone panics about lost work, they check the reflog before messaging anyone."
A story where the fix was luck or re-typing the work, with no understanding of why it was recoverable.
Cause: what made it painful, such as a long-lived branch or overlapping refactors.
Collaboration: how you involved the other person rather than guessing their intent.
Verification: how you proved the combined result worked.
Prevention: what you changed so it didn't happen again.
"In my final-year project, I spent three weeks on a branch reworking the booking module while a teammate refactored the same service layer for a new feature. Neither of us had merged main in that time. When I tried, there were conflicts in about fifteen files, and in several the right answer needed both our changes combined. Instead of resolving her side by guessing, I asked her to sit with me for an hour. We went file by file: she explained the intent of her changes, I explained mine, and we wrote the combined version together. Then we ran the full test suite and clicked through the booking flow, because a couple of files had merged cleanly but called methods she'd renamed. The lesson was about branch lifetime, not Git. After that we rebased on main every day and split work into smaller pull requests, and we never had a merge like that again."
Resolving someone else's side without asking, or blaming the teammate instead of the long-lived branch.
ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your resume and notes are never stored on our servers. It stays out of screen share on every plan; only you can see it.