G17 · Part 4 · Mastery
Interview bank
Questions interviewers actually ask, from fresher to senior, with short answers you can defend under follow-up.
Each answer is written to be said out loud in under a minute. Scenario questions are marked. In those, the interviewer cares less about the exact command than about the order you do things in and the risk you notice.
Fresher
Git is the version control tool that runs on your machine. GitHub is a hosting service for Git repositories that adds collaboration on top: pull requests, issues, CI, permissions. GitLab and Bitbucket are alternatives. You can use Git with no host at all.
A snapshot of the whole project at one moment, not a diff. It stores a pointer to a tree (the files), pointers to its parent commits, the author, the committer, and a message. Its ID is a hash of all of that, so changing anything about a commit, even its parent, gives it a new ID.
It's a draft of the next commit. It lets you choose which of your current changes go into this commit and which wait for the next one, so commits stay focused even when you edited several things at once. git add -p goes further and stages part of a file.
A branch is a file containing one commit hash: a movable label. Creating one writes a few dozen bytes and copies nothing. When you commit, the current branch moves forward to the new commit. That's why Git encourages a branch per task.
A pointer to where you are. Normally it points to a branch, which points to a commit. If it points straight at a commit instead, you're in detached HEAD state.
Fetch downloads new commits and updates remote-tracking branches like origin/main. It never touches your files, so it's always safe. Pull is fetch followed by a merge or rebase into your current branch, so it can produce conflicts.
main and origin/main?
main is your local branch. origin/main is your repository's memory of where main was on the remote the last time you fetched. It only moves when you fetch, pull or push, and you never commit to it directly.
git clone actually do?
It creates a repository, downloads every commit and object from the remote, adds the remote under the name origin, creates remote-tracking branches for its branches, and checks out the default branch. You get the full history, not just the latest files.
git status lists the conflicted files. Open each one and decide what the final code should be, often a mix of both sides. Delete the conflict markers, git add the file to mark it resolved, then git commit (or git rebase --continue during a rebase). git merge --abort backs out completely.
.gitignore but Git still tracks it. Why?
.gitignore only affects untracked files. Once a file is tracked, Git keeps tracking it. Run git rm --cached path to stop tracking it while keeping it on disk, then commit. If it held a secret, removing it now does nothing for history: rotate the secret.
git stash?
When you need a clean working tree for a moment, for example to switch branches for an urgent fix, but your current work isn't ready to commit. git stash sets it aside, git stash pop brings it back. Use -u to include untracked files. For anything longer than an hour, a commit on a branch is safer.
git diff and git diff --staged?
Plain git diff compares your files with the staging area, so it shows what you have not staged yet. git diff --staged compares the staging area with the last commit, which is exactly what the next commit will contain. After git add ., plain git diff prints nothing. git diff HEAD shows both together.
git switch and git restore instead of git checkout?
checkout does two unrelated jobs: changing branch and overwriting files, and a typo can turn one into the other. Since Git 2.23, switch only changes branches and refuses to detach HEAD unless asked, and restore only restores files. checkout still works and the Git project has said it will stay, so you need to read both.
It happens when you check out a commit or tag directly. You can look around and even commit, but no branch moves with you, so switching away leaves those commits unreachable. If you made commits you want, run git switch -c new-branch before leaving.
main that should have been on a feature branch. Nothing is pushed.
git branch feature labels the current commit, so the work is safe. Then git reset --hard origin/main moves main back to where the remote is. git switch feature and carry on. The commits never moved. Only the labels did.
git commit --amend opens the message for editing and replaces the commit. If it's not pushed, that's the end of it. If it's pushed to your own branch, git push --force-with-lease. If it's on a shared branch, leave it. A typo isn't worth rewriting shared history.
Mid-level
Merge keeps the true shape of history and never rewrites anything, so it's correct for shared branches. Rebase gives a clean linear history but creates new commits, so it's only for work nobody else has. A common policy: rebase your feature branch onto main while you work, then merge or squash-merge the pull request.
git reset.
All three move the current branch to another commit. --soft stops there, so your changes stay staged. --mixed, the default, also resets the staging area, so changes stay in your files but unstaged. --hard also overwrites the working tree, which throws away uncommitted work permanently.
Whenever the commit is already pushed to a shared branch. Revert adds a new commit that undoes the old one, so nobody's history breaks and the record honestly shows the change went in and came out. Reset rewrites history and forces everyone else to recover.
--no-ff?
If your branch hasn't moved since the feature branched off, Git can just slide the branch label forward, with no merge commit. --no-ff always makes a merge commit, which records that these commits arrived together as one feature and lets you revert the whole feature with one git revert -m 1.
Rewrite a branch before review: squash noisy commits, reword messages, drop commits, reorder them, or stop at one to change its code. Combined with git commit --fixup and --autosquash, fixes land in the right commit automatically. The goal is commits that each make one coherent, revertable change.
It applies one commit's changes onto another branch, typically to backport a fix to a release branch. The catch is that it creates a new commit with a new hash, so the same change exists twice in the graph. Use -x so the message records the original hash, which makes the duplicate traceable.
--force versus --force-with-lease?
--force overwrites the remote branch no matter what is there. --force-with-lease only overwrites it if it's still where your origin/... ref says, so it fails instead of deleting a teammate's new commits. Add --force-if-includes to also guard against a background fetch having moved that ref without you noticing.
A local log of every position HEAD and each branch has had: commits, resets, rebases, checkouts. It's how you find commits that no branch points to any more. By default entries are kept 90 days, or 30 days for commits that are no longer reachable. It's per clone and never pushed.
git pull --rebase do, and why do teams set it as default?
It fetches, then replays your local unpushed commits on top of the new remote tip instead of creating a merge commit. That avoids the "Merge branch 'main' of github.com…" noise in history. Set it with git config --global pull.rebase true. It's safe because it only rewrites commits you haven't pushed.
Pro: one clean commit per pull request on main, easy to read and revert. Con: the branch's individual commits are lost from main, so bisect can only narrow things down to the whole PR. Also, Git no longer sees the branch as merged, so git branch -d refuses and continuing work on that branch causes repeat conflicts. Start a fresh branch after a squash merge.
A..B and A...B mean?
In git log, A..B is the commits reachable from B but not from A, "what B has that A lacks", and A...B is the commits on either side but not both. In git diff the meanings shift: A..B is just the two tips compared, while A...B compares B with the merge base, which is what a pull request shows. Mixing them up is how people get a diff that seems to delete main's newer work.
rerere do, and when does it help?
"Reuse recorded resolution". With rerere.enabled on, Git records each conflict and how you resolved it, and replays the resolution when the identical conflict appears again. It pays off when you rebase a long-lived branch repeatedly, or test-merge a branch, abort, and merge again later. Git still stops so you can check the result, and git rerere forget <path> drops a bad recording.
Check out the top branch and run git rebase -i --update-refs main (Git 2.38 and later). The to-do list contains update-ref lines for the lower branches, so one rebase moves all three. Then push each with --force-with-lease. If the bottom PR was squash-merged, replay only the commits above it with git rebase --update-refs --onto origin/main <old bottom branch>.
A lightweight tag is just a ref pointing at a commit. An annotated tag is a real object with a tagger, date, message and optional signature. git describe and git push --follow-tags use annotated tags by default. Releases should always be annotated.
git fetch, then git rebase -i origin/main. Squash the noise into a few meaningful commits and reword the messages. Run the tests at the end, then git push --force-with-lease. It's your branch, so rewriting is fine. Mention that review comments tied to old commits may show as outdated.
main must also ship in the release/2.3 patch.
git switch release/2.3, git cherry-pick -x <hash>, resolve any conflicts, run tests, push, and tag v2.3.1. For a fix spread across several commits, cherry-pick the range, or cherry-pick the merge commit with -m 1. Fixing on main first and backporting keeps main from missing a fix.
git reset --hard HEAD~3 on the wrong branch.
git reflog shows the entry just before the reset, something like HEAD@{1}. git reset --hard HEAD@{1} puts the branch back. The three commits were never deleted, only unreferenced. Any uncommitted changes in files at the time, though, are gone.
Senior
As a content-addressed object database with four object types: blobs (file contents), trees (directories), commits (a tree, parents and metadata) and annotated tags. Each object's ID is a hash of its content, so identical content is stored once and changing any commit changes the IDs of every commit after it. Objects start as loose zlib-compressed files and are later packed into packfiles with delta compression.
No. A commit only has a tree, and there's no rename record. Commands detect renames on the fly by comparing removed and added files by similarity, 50% by default. That's why a rename plus heavy edits in one commit can show as a delete and an add. Keep big renames in their own commit, and use git log --follow for a single file's history.
ort is the default for two-head merges since Git 2.34, replacing recursive, and since Git 2.50 recursive is only an alias for ort. It's much faster and handles renames better. octopus merges more than two branches if there are no conflicts. The ours strategy records a merge but keeps your tree entirely, which is different from -X ours: that option only picks your side for conflicting hunks and still merges everything else.
git commit?
The index already holds a blob for every tracked file, because git add wrote them. Commit turns the index into tree objects, one per directory (git write-tree), writes a commit object naming the root tree, the parent (the current HEAD), author, committer and message (git commit-tree), then moves the branch HEAD points at to the new commit and appends to the reflog (git update-ref). Hooks run around it: pre-commit and commit-msg can abort. Nothing touches the network, and unchanged files cost nothing because their blobs and subtrees are reused.
git show --remerge-diff <merge> (Git 2.36 and later). It redoes the merge in memory, conflict markers included, and diffs that against the commit that was recorded, so the output is exactly the human resolution. The default combined diff hides hunks where the result matches one parent, so a resolution that silently dropped one side's change can look clean. git log --remerge-diff --merges audits a whole range.
Git 3.0 has no release date yet, but the planned changes are documented. New repositories will default to SHA-256 object IDs, to the reftable ref backend instead of loose files plus packed-refs, and to main as the first branch name. Existing repositories are not converted. What to do now: set init.defaultBranch, stop assuming hashes are 40 characters, read refs through git rev-parse and for-each-ref rather than files under .git/refs, and check that your tools and host support the new formats before you opt in.
git gc?
It packs loose objects into packfiles with delta compression, packs refs into one file, expires old reflog entries, and prunes unreachable objects older than the grace period, two weeks by default. The grace period protects objects a running command might be about to reference. Modern setups rely on git maintenance to do this in small background steps.
SHA-1 collisions have been demonstrated, so Git switched to a hardened SHA-1 implementation that detects the known collision attack and refuses such objects. Git has also supported SHA-256 repositories (git init --object-format=sha256) since 2.29, and the project is moving toward making it the default. Host support is still uneven. Trust in history really comes from signatures on commits and tags, plus protected branches.
That someone holding a particular private key created that commit object. Not that the code was reviewed, and not that the author field is true when the signer is someone else, for example after a rebase or a host squash-merge. It's useful for supply-chain policies combined with required reviews and branch protection, not instead of them.
main. Recover them.
The old tip is in the force-push output, in anyone's git reflog show origin/main, or in the host's branch activity log. git branch rescue <hash> to pin it, combine it with the new work, check, and push normally. Then fix the cause: branch protection that blocks force-pushes, and a team alias for --force-with-lease --force-if-includes.
Rotate it first. It's compromised the moment it left the machine, and that's the only step that actually closes the hole. Check the provider's logs for use. Then purge with git filter-repo from a fresh clone, force-push every branch and tag, have everyone re-clone, and ask the host to clear cached views and pull request refs. Check forks. Afterwards add push protection and a pre-commit scanner.
git bisect start, git bisect bad, git bisect good <last known good>. Git binary-searches, about nine steps for 400 commits. With a script that exits 0 for good and 1 for bad, git bisect run ./check.sh does it unattended. Exit code 125 means skip, for commits that don't build. Merge-heavy history works fine. Commits that don't build weaken it, which is an argument for atomic commits.
The revert undid the changes, but Git still records the feature's commits as merged. The second merge only brings in commits made after the first merge, which is just the fix. The fix is to revert the revert first, then merge the fixed branch. Or rebuild the feature on new commits before merging.
Measure first: GIT_TRACE2_PERF=1, git count-objects -vH and git-sizer tell you whether it's history size, file count or big blobs. Then: blobless partial clones and cone-mode sparse-checkout for developers, or just scalar clone. git maintenance start with commit-graph for history operations. fsmonitor and the untracked cache for status. LFS for binaries. In CI, shallow or treeless clones plus caching.
git diff against main fails in CI.
The CI checkout is probably shallow, so git merge-base can't find the common ancestor and git diff origin/main...HEAD breaks. Fetch the base branch and enough depth, or use a blobless clone with full history (fetch-depth: 0 plus --filter=blob:none), which is cheap and keeps merge-base working.
Tie it to release cadence. A web app deployed continuously: trunk-based or GitHub Flow, because long-lived branches only add merge pain when you ship daily. Several supported versions, or mobile and on-premise releases: release branches cut from main with fixes backported by cherry-pick. Then name the cost: trunk-based needs feature flags and strong CI. Release branches need discipline about backports.
main for six months. How do you land it?
Don't try one big rebase. Merge main into the branch in steps, maybe a month of main at a time, with rerere on so repeated conflicts resolve themselves. Get the tests green at each step. Then ship it in slices behind a feature flag rather than one enormous merge. Next time, prevent it by merging main in weekly or not letting branches live that long.
Submodules pin another repository at an exact commit. Precise, but everyone must remember --recurse-submodules, and changes that span repositories take two commits and two reviews. Subtree copies the other project's files into yours, so clones are simple but syncing back is manual. A monorepo gives atomic cross-project changes and one set of tooling, at the cost of needing sparse-checkout, partial clone and build tools that understand what changed.
On the server, because client hooks are optional. Organisation-wide rulesets or branch protection: no force-push or deletion of main, required reviews, required status checks, and optionally signed commits and linear history. CODEOWNERS for review routing. Secret scanning with push protection. Lint commit messages or PR titles in CI. Then give developers a shared hook config so they see failures locally before CI does.
Name the concept, then give one concrete situation where you used it. "Rebase rewrites commits so
their hashes change" is a textbook answer. "We rebase feature branches so main stays
linear, but not after review starts, because reviewers lose track of what changed" tells the
interviewer you've actually lived with the trade-off.
Finished reading?
0 of 18 chapters read in this series