Git Interview Questions and Answers, Proved in a Terminal
Git interview questions are usually answered with a definition, and the follow-up is where the definition runs out. Every answer here came from a terminal on git 2.50.1. A commit held a tree, an author, a committer and a message, and no diff. After git gc, the older version of a file was stored as a 35-byte delta against the newer one. A plain git pull on diverged branches refused to run. And following the hint git prints after a rejected push brought back the bug a reset had removed.
Git interview questions get asked as definitions. "What does git reset --soft do" has a textbook answer, and the follow-up is usually "so where is my change now?" You answer that by running the command.
Everything below was run on git 2.50.1 in throwaway repositories, with a global config holding only a name and an email, and fixed timestamps, so the hashes printed here came out the same when a script was rerun. Two of the results go against advice you will hear in interviews.
The openers, in one line each
Git against GitHub. Git is the version-control program on your machine. GitHub is one place that hosts repositories. Every command in this article ran with no GitHub involved, and the "remote" was a bare repository in the next folder.
HEAD~ against HEAD^ on a merge commit. ~ always follows the first parent. ^2 picks the second parent:
HEAD^ a3c9fc1 fix: null check
HEAD~2 e0fac02 main: other work
HEAD^2 b3f8d96 wip: unfinished
HEAD^2~1 94d9743 fix: null check
Detached HEAD. HEAD points at a commit instead of a branch. git switch --detach HEAD~1 printed HEAD is now at f1717e4 c1, and git status opened with HEAD detached at f1717e4. Why that matters is covered in the reflog section.
What is actually inside a commit?
Ask git for the raw object:
git cat-file -p HEAD
tree b0fee90213cb7e3a4babbb714eeb2799cb91f98f
author Dev <dev@example.com> 1789273800 +0530
committer Dev <dev@example.com> 1789273800 +0530
first
A commit holds a tree, the people and times, and a message. There is no diff in it. (A first commit also has no parent line.) The tree lists files and subtrees, and the id of each file is a hash of its content alone:
$ echo hello | git hash-object --stdin
ce013625030ba8dba906f756967f9e9ca394464a
$ printf 'blob 6\0hello\n' | shasum
ce013625030ba8dba906f756967f9e9ca394464a -
src/a.txt and docs/b.txt both contained hello and got the same blob id, so the repository held 6 objects for three files: one commit, three trees and two blobs. Equal content becomes one entry, much as a hash-based set keeps one copy of equal elements.
The follow-up question is what changes a commit's hash. git commit --amend --no-edit with identical timestamps gave back the same hash, 57f1744. The same amend with the committer date one second later gave 7bda1db, with the tree still b0fee902. Same code, new identity. So a pipeline that builds and tags by commit SHA sees an amended commit as a different commit, even when not a byte of code changed.
Does git store snapshots or diffs?
Both. The folklore answer, "snapshots, not diffs", is only half right. A 20,000-line file, 348,894 bytes, had one line changed and was committed again. That produced two complete blobs, 55e9fbb and 8c8bb78. Then git gc packed them, and git verify-pack -v printed:
8c8bb782a9de9f4d12d8ad9c5e2599188cdb6909 blob 348901 50496 272
55e9fbba1bbd9704436485a3310da62b01558cf5 blob 35 48 50860 1 8c8bb782a9de9f4d12d8ad9c5e2599188cdb6909
The newer version is stored whole. The older one is a 35-byte delta against it. The model you work with is snapshots, and the storage on disk is deltas, pointing backwards from the newest version.
git reset --soft, --mixed, --hard: where did the change go?
Two commits, then a reset of one step, in a fresh repository for each mode. git status --short shows the answer in its two columns: the first column is the index, the second is the working tree.
git reset --soft HEAD~1 -> "M app.txt" change staged
git reset --mixed HEAD~1 -> " M app.txt" change unstaged
In both cases HEAD moved to 78cb671 c1: one and the file on disk still read one two. --soft moves only the branch. --mixed, the default, also resets the index.
--hard was not run for this article, so its row comes from the 2.50 manual: it "Resets the index and working tree. Any changes to tracked files in the working tree since <commit> are discarded."
Can you get back a commit you lost?
Usually, and here is a common way to lose one. Commit while detached, then switch back to main:
Warning: you are leaving 1 commit behind, not connected to
any of your branches:
cc852bb fix made while detached
git log --all --oneline did not list cc852bb, because --all means all refs, and no ref pointed at it. git reflog still had it as HEAD@{1}: commit: fix made while detached, so git branch rescued cc852bb brought it back.
The better follow-up is whether garbage collection deletes it. git gc --prune=now did not: git cat-file -t cc852bb still answered commit, because a reflog entry counts as a reference. Only expiring the reflog first removed it:
$ git reflog expire --expire=now --all
$ git gc --prune=now
$ git cat-file -t cc852bb
fatal: Not a valid object name cc852bb
Nobody runs that by accident. The git-gc manual gives the default for unreachable entries as "defaults to 30 days", and 90 days for the rest.
And work you never committed?
Staged work leaves a copy behind. Unstaged work does not. A file was staged, edited again, and then both changes were discarded with git restore --staged --worktree notes.txt:
$ git fsck --lost-found
dangling blob 95d5ee55f697aa8f7921bd62fad52323ad5d227e
$ git show 95d5ee55f697aa8f7921bd62fad52323ad5d227e
two hours of work, staged
The staged version was still there, because git add had already written it into the object database. fsck found no copy of the later edit that was never staged.
git fetch or git pull?
Alice pushed a commit, and Bob ran git fetch:
Your branch is behind 'origin/main' by 1 commit, and can be fast-forwarded.
Bob's app.txt still held only line 1. Fetch updates origin/main and nothing else. Then Bob committed his own work, so the two branches had diverged, and he ran a plain git pull:
hint: You have divergent branches and need to specify how to reconcile them.
fatal: Need to specify how to reconcile divergent branches.
The usual answer, "pull is fetch plus merge", did not hold. With no pull.rebase or pull.ff setting, git 2.50.1 refused to integrate anything, and HEAD stayed at 7c05647 bob: own work. The 2.50 git-pull manual says that when the branches have diverged, "the user needs to specify how to reconcile the divergent branches".
git revert or git reset on a pushed branch?
c2 put a bug on main and was pushed. Two copies of that repository each tried to undo it.
The copy that ran git reset --mixed HEAD~1 and pushed was refused:
! [rejected] main -> main (non-fast-forward)
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart. If you want to integrate the remote changes,
hint: use 'git pull' before pushing again.
The copy that ran git revert HEAD pushed cleanly as ce3c7c0..54fa577. The revert's tree, b69ed90e, is identical to c1's, so the code went back and the history kept the record.
Common mistake
Following the hint. In a third copy, the reset was followed by exactly what git suggested,
git pull, and it printedFast-forward. HEAD was back atce3c7c0 c2: introduces bug, and theBUGline was back inapp.txt. The hint is written for someone who is behind the remote, not for someone who deliberately removed a commit from it. On a shared branch, revert. If you really mean to rewrite history, you need a force push, and a team that agrees to it.
Does git cherry-pick copy a commit?
It makes a new commit with the same change. 94d9743 picked onto main became a3c9fc1, with a different parent, a later committer time, and with -x, a (cherry picked from commit 94d9743...) line in the message. The hash is new, but the patch is the same:
$ git show <original> | git patch-id --stable
3c5eb97fe2e9bbe1df3424ee1a5dcc36a36c7145 94d97431aa01881580dddf8704aad49dbf5a2768
$ git show <picked> | git patch-id --stable
3c5eb97fe2e9bbe1df3424ee1a5dcc36a36c7145 a3c9fc179e0ffd6585ecf7729ee351c2a6c781c0
git cherry -v main feature marked the original -, meaning it is already upstream, and the unpicked commit +. When feature was later merged, there was no conflict. The log then showed fix: null check twice, once per branch. It is the same change recorded as two commits.
Why doesn't .gitignore ignore my file?
Because the file is already tracked. debug.log was committed, then *.log went into .gitignore, and the next edit still showed as M debug.log. A new other.log did not show at all.
The debugging tool stays silent too. git check-ignore -v debug.log printed nothing and exited 1. With --no-index it printed the matching rule:
.gitignore:1:*.log debug.log
The check-ignore manual says so: "tracked files are not shown at all since they are not subject to exclude rules". The gitignore manual says it plainly too: "Files already tracked by Git are not affected".
git rm --cached debug.log plus a commit fixed it. The file stayed on disk, and git show HEAD~2:debug.log still printed the old content. For a JWT signing key, that means rotate the key. Untracking it does not undo the leak.
How does git bisect find a bad commit?
It does a binary search, and git bisect run does it for you. There were 100 commits, a config change broke commit 73, and the test script was a grep for the good setting:
Bisecting: 49 revisions left to test after this (roughly 6 steps)
...
Bisecting: 0 revisions left to test after this (roughly 0 steps)
b4070b4685cf74959442930f58a0d6a0b2203cc9 is the first bad commit
test script ran: 7 times for 100 commits (first bad = commit 73)
Seven runs, and also seven when the bad commit was number 2. With 1,000 commits and the break at 731, it took ten runs. Bisect costs about log2 of the history. What it needs from you is a test that answers yes or no by its exit code.
Answering git interview questions out loud
Each answer has two parts: what the command does, and what follows from that. A commit hashes its tree, parent and timestamps, so an amend one second later is a new commit. The reflog is a reference, so gc keeps what it points at. Ignore rules apply only to untracked paths, so .gitignore never untracks anything.
[!TAKEAWAY] Keep a throwaway repository and run the question.
cat-file -pshows there is no diff in a commit.fsck --lost-foundrecovered staged work that seemed discarded. A rejected push, followed by git's own hint, brought the bug back. You can recite a definition. You remember a result you ran yourself.
Frequently asked questions
- What is the difference between git reset --soft and --mixed?
- Where your changes end up. After git reset --soft HEAD~1, git status --short printed "M app.txt", with the M in the first column, so the change was still staged. After --mixed, the default, it printed " M app.txt", with the M in the second column: the same change, now unstaged. The file on disk was the same both times. --hard also resets the working tree, which throws the change away.
- Does git gc delete commits that are not on any branch?
- Not while a reflog entry still points at them. A commit left behind by switching away from a detached HEAD survived git gc --prune=now. It was gone only after git reflog expire --expire=now --all followed by another gc. Without that step, the documented defaults keep unreachable reflog entries for 30 days.
- Is git pull the same as git fetch plus git merge?
- Only when your branch has no commits of its own. With diverged branches and no pull.rebase setting, git 2.50.1 stopped with "fatal: Need to specify how to reconcile divergent branches." HEAD did not move. Set pull.rebase or pull.ff once, or pass --rebase, --no-rebase or --ff-only.
- How do I stop tracking a file that is now in .gitignore?
- Run git rm --cached on it and commit. The file stays on disk, git status goes quiet, and new files matching the pattern are ignored. The old content is still in history: git show on the earlier commit printed it. If that file was a secret, rotate the secret. Untracking it does not remove it from the commits that already exist.
- How many steps does git bisect take?
- About log2 of the commit count. With 100 commits, git bisect run ran the test 7 times and named the right commit, whether the bug came in at commit 73 or at commit 2. With 1,000 commits it ran 10 times. Ten times the history cost three more test runs.